From ed4def8286b6c313efb012575909d210f021242b Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 22 Apr 2026 13:34:15 -0700 Subject: [PATCH 01/12] feat(auto-review) short-circuit (#18890) ## Summary Short circuit the convo if auto-review hits too many denials ## Testing - [x] Added unit tests --------- Co-authored-by: Codex --- .../schema/json/ServerNotification.json | 37 ++++++ .../codex_app_server_protocol.schemas.json | 39 ++++++ .../codex_app_server_protocol.v2.schemas.json | 39 ++++++ .../json/v2/GuardianWarningNotification.json | 19 +++ .../schema/typescript/ServerNotification.ts | 3 +- .../v2/GuardianWarningNotification.ts | 13 ++ .../schema/typescript/v2/index.ts | 1 + .../src/protocol/common.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 10 ++ .../app-server/src/bespoke_event_handling.rs | 17 +++ codex-rs/app-server/src/outgoing_message.rs | 23 ++++ codex-rs/core/src/guardian/mod.rs | 54 ++++++++ codex-rs/core/src/guardian/review.rs | 102 +++++++++++++-- codex-rs/core/src/guardian/tests.rs | 72 +++++++++- codex-rs/core/src/session/session.rs | 2 + codex-rs/core/src/session/tests.rs | 123 ++++++++++++++++++ codex-rs/core/src/session/turn.rs | 1 + codex-rs/core/src/state/service.rs | 4 + codex-rs/core/src/tasks/mod.rs | 50 +++++++ codex-rs/mcp-server/src/codex_tool_runner.rs | 2 +- codex-rs/protocol/src/protocol.rs | 3 + codex-rs/rollout/src/policy.rs | 1 + codex-rs/tui/src/app/app_server_adapter.rs | 15 +++ codex-rs/tui/src/app/replay_filter.rs | 4 +- codex-rs/tui/src/chatwidget.rs | 6 +- codex-rs/tui/src/chatwidget/tests.rs | 1 + .../tui/src/chatwidget/tests/app_server.rs | 21 +++ codex-rs/tui/src/chatwidget/tests/guardian.rs | 4 +- 28 files changed, 645 insertions(+), 22 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/GuardianWarningNotification.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/GuardianWarningNotification.ts diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 167c637918..aa320f8b41 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1704,6 +1704,23 @@ ], "type": "string" }, + "GuardianWarningNotification": { + "properties": { + "message": { + "description": "Concise guardian warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Thread target for the guardian warning.", + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "type": "object" + }, "HookCompletedNotification": { "properties": { "run": { @@ -5342,6 +5359,26 @@ "title": "WarningNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "guardianWarning" + ], + "title": "GuardianWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/GuardianWarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "GuardianWarningNotification", + "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 cf54aca282..677a7fa940 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 @@ -4437,6 +4437,26 @@ "title": "WarningNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "guardianWarning" + ], + "title": "GuardianWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/GuardianWarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "GuardianWarningNotification", + "type": "object" + }, { "properties": { "method": { @@ -9288,6 +9308,25 @@ ], "type": "string" }, + "GuardianWarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "message": { + "description": "Concise guardian warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Thread target for the guardian warning.", + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "title": "GuardianWarningNotification", + "type": "object" + }, "HookCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { 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 506d92e604..421abb76c8 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 @@ -6006,6 +6006,25 @@ ], "type": "string" }, + "GuardianWarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "message": { + "description": "Concise guardian warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Thread target for the guardian warning.", + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "title": "GuardianWarningNotification", + "type": "object" + }, "HookCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -10786,6 +10805,26 @@ "title": "WarningNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "guardianWarning" + ], + "title": "GuardianWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/GuardianWarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "GuardianWarningNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/GuardianWarningNotification.json b/codex-rs/app-server-protocol/schema/json/v2/GuardianWarningNotification.json new file mode 100644 index 0000000000..5a4ef82f0a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/GuardianWarningNotification.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "message": { + "description": "Concise guardian warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Thread target for the guardian warning.", + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "title": "GuardianWarningNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index f55700bf54..614dd611d2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -18,6 +18,7 @@ import type { ExternalAgentConfigImportCompletedNotification } from "./v2/Extern import type { FileChangeOutputDeltaNotification } from "./v2/FileChangeOutputDeltaNotification"; import type { FileChangePatchUpdatedNotification } from "./v2/FileChangePatchUpdatedNotification"; import type { FsChangedNotification } from "./v2/FsChangedNotification"; +import type { GuardianWarningNotification } from "./v2/GuardianWarningNotification"; import type { HookCompletedNotification } from "./v2/HookCompletedNotification"; import type { HookStartedNotification } from "./v2/HookStartedNotification"; import type { ItemCompletedNotification } from "./v2/ItemCompletedNotification"; @@ -62,4 +63,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": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "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": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "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": "warning", "params": WarningNotification } | { "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/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "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": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "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": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "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": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "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/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "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/GuardianWarningNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GuardianWarningNotification.ts new file mode 100644 index 0000000000..1659f62f1b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GuardianWarningNotification.ts @@ -0,0 +1,13 @@ +// 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 GuardianWarningNotification = { +/** + * Thread target for the guardian warning. + */ +threadId: string, +/** + * Concise guardian warning message for the user. + */ +message: string, }; 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 837b265fc8..aa0ed36d25 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -147,6 +147,7 @@ export type { GuardianApprovalReviewStatus } from "./GuardianApprovalReviewStatu export type { GuardianCommandSource } from "./GuardianCommandSource"; export type { GuardianRiskLevel } from "./GuardianRiskLevel"; export type { GuardianUserAuthorization } from "./GuardianUserAuthorization"; +export type { GuardianWarningNotification } from "./GuardianWarningNotification"; export type { HookCompletedNotification } from "./HookCompletedNotification"; export type { HookEventName } from "./HookEventName"; export type { HookExecutionMode } from "./HookExecutionMode"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 503cb6bc17..a751f11ec0 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1061,6 +1061,7 @@ server_notification_definitions! { ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification), ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification), Warning => "warning" (v2::WarningNotification), + GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification), DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification), ConfigWarning => "configWarning" (v2::ConfigWarningNotification), FuzzyFileSearchSessionUpdated => "fuzzyFileSearch/sessionUpdated" (FuzzyFileSearchSessionUpdatedNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index f8bc91c01e..e15fe5dab9 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -7250,6 +7250,16 @@ pub struct WarningNotification { pub message: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianWarningNotification { + /// Thread target for the guardian warning. + pub thread_id: String, + /// Concise guardian warning message for the user. + pub message: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 12129f7ee5..4730d31679 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -45,6 +45,7 @@ use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::FileChangeRequestApprovalResponse; use codex_app_server_protocol::FileUpdateChange; use codex_app_server_protocol::GrantedPermissionProfile as V2GrantedPermissionProfile; +use codex_app_server_protocol::GuardianWarningNotification; use codex_app_server_protocol::HookCompletedNotification; use codex_app_server_protocol::HookStartedNotification; use codex_app_server_protocol::InterruptConversationResponse; @@ -286,6 +287,22 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } + EventMsg::GuardianWarning(warning_event) => { + if let ApiVersion::V2 = api_version { + let notification = GuardianWarningNotification { + thread_id: conversation_id.to_string(), + message: warning_event.message, + }; + if let Some(analytics_events_client) = analytics_events_client.as_ref() { + analytics_events_client.track_notification( + ServerNotification::GuardianWarning(notification.clone()), + ); + } + outgoing + .send_server_notification(ServerNotification::GuardianWarning(notification)) + .await; + } + } EventMsg::GuardianAssessment(assessment) => { if let ApiVersion::V2 = api_version { let pending_command_execution = match build_item_from_guardian_event( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 3c5bef3ea4..e3c92f70b5 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -657,6 +657,7 @@ mod tests { use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::DynamicToolCallParams; use codex_app_server_protocol::FileChangeRequestApprovalParams; + use codex_app_server_protocol::GuardianWarningNotification; use codex_app_server_protocol::ModelRerouteReason; use codex_app_server_protocol::ModelReroutedNotification; use codex_app_server_protocol::RateLimitSnapshot; @@ -812,6 +813,28 @@ mod tests { ); } + #[test] + fn verify_guardian_warning_notification_serialization() { + let notification = ServerNotification::GuardianWarning(GuardianWarningNotification { + thread_id: "thread-1".to_string(), + message: "Automatic approval review denied the requested action.".to_string(), + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "guardianWarning", + "params": { + "threadId": "thread-1", + "message": "Automatic approval review denied the requested action.", + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + #[test] fn verify_model_rerouted_notification_serialization() { let notification = ServerNotification::ModelRerouted(ModelReroutedNotification { diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index a2c9b45c8d..155db6f1f4 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -30,6 +30,8 @@ pub(crate) use review::guardian_rejection_message; pub(crate) use review::guardian_timeout_message; pub(crate) use review::is_guardian_reviewer_source; pub(crate) use review::new_guardian_review_id; +#[cfg(test)] +pub(crate) use review::record_guardian_denial_for_test; pub(crate) use review::review_approval_request; #[cfg(test)] pub(crate) use review::review_approval_request_with_cancel; @@ -40,6 +42,8 @@ pub(crate) use review_session::GuardianReviewSessionManager; const GUARDIAN_PREFERRED_MODEL: &str = "codex-auto-review"; pub(crate) const GUARDIAN_REVIEW_TIMEOUT: Duration = Duration::from_secs(90); pub(crate) const GUARDIAN_REVIEWER_NAME: &str = "guardian"; +pub(crate) const MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN: u32 = 3; +pub(crate) const MAX_TOTAL_GUARDIAN_DENIALS_PER_TURN: u32 = 10; const GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS: usize = 10_000; const GUARDIAN_MAX_TOOL_TRANSCRIPT_TOKENS: usize = 10_000; const GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS: usize = 2_000; @@ -63,6 +67,56 @@ pub(crate) struct GuardianRejection { pub(crate) source: GuardianAssessmentDecisionSource, } +#[derive(Debug, Default)] +pub(crate) struct GuardianRejectionCircuitBreaker { + turns: std::collections::HashMap, +} + +#[derive(Debug, Default)] +struct GuardianRejectionCircuitBreakerTurn { + consecutive_denials: u32, + total_denials: u32, + interrupt_triggered: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum GuardianRejectionCircuitBreakerAction { + Continue, + InterruptTurn { + consecutive_denials: u32, + total_denials: u32, + }, +} + +impl GuardianRejectionCircuitBreaker { + pub(crate) fn clear_turn(&mut self, turn_id: &str) { + self.turns.remove(turn_id); + } + + pub(crate) fn record_denial(&mut self, turn_id: &str) -> GuardianRejectionCircuitBreakerAction { + let turn = self.turns.entry(turn_id.to_string()).or_default(); + turn.consecutive_denials = turn.consecutive_denials.saturating_add(1); + turn.total_denials = turn.total_denials.saturating_add(1); + if !turn.interrupt_triggered + && (turn.consecutive_denials >= MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN + || turn.total_denials >= MAX_TOTAL_GUARDIAN_DENIALS_PER_TURN) + { + turn.interrupt_triggered = true; + GuardianRejectionCircuitBreakerAction::InterruptTurn { + consecutive_denials: turn.consecutive_denials, + total_denials: turn.total_denials, + } + } else { + GuardianRejectionCircuitBreakerAction::Continue + } + } + + pub(crate) fn record_non_denial(&mut self, turn_id: &str) { + let turn = self.turns.entry(turn_id.to_string()).or_default(); + turn.consecutive_denials = 0; + } +} + #[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 ce01ac970e..3b5c63b4dc 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; @@ -30,6 +31,7 @@ use super::GuardianApprovalRequest; use super::GuardianAssessment; use super::GuardianAssessmentOutcome; use super::GuardianRejection; +use super::GuardianRejectionCircuitBreakerAction; use super::approval_request::guardian_assessment_action; use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; @@ -172,6 +174,64 @@ fn track_guardian_review( .track_guardian_review(tracking, result); } +async fn record_guardian_non_denial(session: &Arc, turn_id: &str) { + session + .services + .guardian_rejection_circuit_breaker + .lock() + .await + .record_non_denial(turn_id); +} + +async fn record_guardian_denial(session: &Arc, turn: &Arc, turn_id: &str) { + let action = session + .services + .guardian_rejection_circuit_breaker + .lock() + .await + .record_denial(turn_id); + let GuardianRejectionCircuitBreakerAction::InterruptTurn { + consecutive_denials, + total_denials, + } = action + else { + return; + }; + + if session.turn_context_for_sub_id(turn_id).await.is_none() { + return; + } + + session + .send_event( + turn.as_ref(), + EventMsg::GuardianWarning(WarningEvent { + message: format!( + "Automatic approval review rejected too many approval requests for this turn ({consecutive_denials} consecutive, {total_denials} total); interrupting the turn." + ), + }), + ) + .await; + + let runtime_handle = session.services.runtime_handle.clone(); + let session = Arc::clone(session); + let turn_id = turn_id.to_string(); + let _abort_task = runtime_handle.spawn(async move { + session + .abort_turn_if_active(&turn_id, TurnAbortReason::Interrupted) + .await; + }); +} + +#[cfg(test)] +pub(crate) async fn record_guardian_denial_for_test( + session: &Arc, + turn: &Arc, + turn_id: &str, +) { + record_guardian_denial(session, turn, turn_id).await; +} + /// This function always fails closed: timeouts, review-session failures, and /// parse failures all block execution, but timeouts are still surfaced to the /// caller as distinct from explicit guardian denials. @@ -234,7 +294,7 @@ async fn run_guardian_review( EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: review_id, target_item_id, - turn_id: assessment_turn_id, + turn_id: assessment_turn_id.clone(), status: GuardianAssessmentStatus::Aborted, risk_level: None, user_authorization: None, @@ -244,6 +304,7 @@ async fn run_guardian_review( }), ) .await; + record_guardian_non_denial(&session, &assessment_turn_id).await; return ReviewDecision::Abort; } @@ -259,7 +320,7 @@ async fn run_guardian_review( )) .await; - let assessment = match outcome { + let (assessment, count_denial_for_circuit_breaker) = match outcome { GuardianReviewOutcome::Completed(assessment) => { let approved = matches!(assessment.outcome, GuardianAssessmentOutcome::Allow); track_guardian_review( @@ -284,7 +345,9 @@ async fn run_guardian_review( ..analytics_result }, ); - assessment + let count_denial_for_circuit_breaker = + matches!(assessment.outcome, GuardianAssessmentOutcome::Deny); + (assessment, count_denial_for_circuit_breaker) } GuardianReviewOutcome::Error(error) => match error { GuardianReviewError::Timeout => { @@ -305,7 +368,7 @@ async fn run_guardian_review( session .send_event( turn.as_ref(), - EventMsg::Warning(WarningEvent { + EventMsg::GuardianWarning(WarningEvent { message: rationale.clone(), }), ) @@ -316,7 +379,7 @@ async fn run_guardian_review( EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: review_id, target_item_id, - turn_id: assessment_turn_id, + turn_id: assessment_turn_id.clone(), status: GuardianAssessmentStatus::TimedOut, risk_level: None, user_authorization: None, @@ -326,6 +389,7 @@ async fn run_guardian_review( }), ) .await; + record_guardian_non_denial(&session, &assessment_turn_id).await; return ReviewDecision::TimedOut; } GuardianReviewError::Cancelled => { @@ -346,7 +410,7 @@ async fn run_guardian_review( EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: review_id, target_item_id, - turn_id: assessment_turn_id, + turn_id: assessment_turn_id.clone(), status: GuardianAssessmentStatus::Aborted, risk_level: None, user_authorization: None, @@ -356,6 +420,7 @@ async fn run_guardian_review( }), ) .await; + record_guardian_non_denial(&session, &assessment_turn_id).await; return ReviewDecision::Abort; } GuardianReviewError::PromptBuild { .. } @@ -381,12 +446,15 @@ async fn run_guardian_review( ..analytics_result }, ); - GuardianAssessment { - risk_level: GuardianRiskLevel::High, - user_authorization: GuardianUserAuthorization::Unknown, - outcome: GuardianAssessmentOutcome::Deny, - rationale, - } + ( + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + }, + false, + ) } }, }; @@ -410,7 +478,7 @@ async fn run_guardian_review( session .send_event( turn.as_ref(), - EventMsg::Warning(WarningEvent { message: warning }), + EventMsg::GuardianWarning(WarningEvent { message: warning }), ) .await; let status = if approved { @@ -436,7 +504,7 @@ async fn run_guardian_review( EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: review_id, target_item_id, - turn_id: assessment_turn_id, + turn_id: assessment_turn_id.clone(), status, risk_level: Some(assessment.risk_level), user_authorization: Some(assessment.user_authorization), @@ -447,6 +515,12 @@ async fn run_guardian_review( ) .await; + if count_denial_for_circuit_breaker { + record_guardian_denial(&session, &turn, &assessment_turn_id).await; + } else { + record_guardian_non_denial(&session, &assessment_turn_id).await; + } + if approved { ReviewDecision::Approved } else { diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 02ff93dd06..cf29c54112 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -67,6 +67,74 @@ fn fixed_guardian_parent_session_id() -> ThreadId { .expect("fixed parent session id should be a valid UUID") } +#[test] +fn guardian_rejection_circuit_breaker_interrupts_after_three_consecutive_denials() { + let mut circuit_breaker = GuardianRejectionCircuitBreaker::default(); + assert_eq!( + circuit_breaker.record_denial("turn-1"), + GuardianRejectionCircuitBreakerAction::Continue + ); + assert_eq!( + circuit_breaker.record_denial("turn-1"), + GuardianRejectionCircuitBreakerAction::Continue + ); + assert_eq!( + circuit_breaker.record_denial("turn-1"), + GuardianRejectionCircuitBreakerAction::InterruptTurn { + consecutive_denials: 3, + total_denials: 3, + } + ); + assert_eq!( + circuit_breaker.record_denial("turn-1"), + GuardianRejectionCircuitBreakerAction::Continue + ); +} + +#[test] +fn guardian_rejection_circuit_breaker_resets_consecutive_denials_on_non_denial() { + let mut circuit_breaker = GuardianRejectionCircuitBreaker::default(); + assert_eq!( + circuit_breaker.record_denial("turn-1"), + GuardianRejectionCircuitBreakerAction::Continue + ); + circuit_breaker.record_non_denial("turn-1"); + assert_eq!( + circuit_breaker.record_denial("turn-1"), + GuardianRejectionCircuitBreakerAction::Continue + ); + assert_eq!( + circuit_breaker.record_denial("turn-1"), + GuardianRejectionCircuitBreakerAction::Continue + ); + assert_eq!( + circuit_breaker.record_denial("turn-1"), + GuardianRejectionCircuitBreakerAction::InterruptTurn { + consecutive_denials: 3, + total_denials: 4, + } + ); +} + +#[test] +fn guardian_rejection_circuit_breaker_interrupts_after_ten_total_denials() { + let mut circuit_breaker = GuardianRejectionCircuitBreaker::default(); + for _ in 0..9 { + assert_eq!( + circuit_breaker.record_denial("turn-1"), + GuardianRejectionCircuitBreakerAction::Continue + ); + circuit_breaker.record_non_denial("turn-1"); + } + assert_eq!( + circuit_breaker.record_denial("turn-1"), + GuardianRejectionCircuitBreakerAction::InterruptTurn { + consecutive_denials: 1, + total_denials: 10, + } + ); +} + async fn guardian_test_session_and_turn( server: &wiremock::MockServer, ) -> (Arc, Arc) { @@ -739,7 +807,7 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() { while let Ok(event) = rx.try_recv() { match event.msg { EventMsg::GuardianAssessment(event) => guardian_statuses.push(event.status), - EventMsg::Warning(event) => warnings.push(event.message), + EventMsg::GuardianWarning(event) => warnings.push(event.message), _ => {} } } @@ -1491,7 +1559,7 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> let mut denial_rationales = Vec::new(); while let Ok(event) = rx.try_recv() { match event.msg { - EventMsg::Warning(event) => warnings.push(event.message), + EventMsg::GuardianWarning(event) => warnings.push(event.message), EventMsg::GuardianAssessment(event) if event.status == GuardianAssessmentStatus::Denied => { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 5803d9e42e..29b6f4e299 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -695,6 +695,8 @@ impl Session { models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), guardian_rejections: Mutex::new(HashMap::new()), + guardian_rejection_circuit_breaker: Mutex::new(Default::default()), + runtime_handle: tokio::runtime::Handle::current(), skills_manager, plugins_manager: Arc::clone(&plugins_manager), mcp_manager: Arc::clone(&mcp_manager), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b502737137..5e50ea5fb1 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3093,6 +3093,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), guardian_rejections: Mutex::new(std::collections::HashMap::new()), + guardian_rejection_circuit_breaker: Mutex::new(Default::default()), + runtime_handle: tokio::runtime::Handle::current(), skills_manager, plugins_manager, mcp_manager, @@ -4313,6 +4315,8 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), guardian_rejections: Mutex::new(std::collections::HashMap::new()), + guardian_rejection_circuit_breaker: Mutex::new(Default::default()), + runtime_handle: tokio::runtime::Handle::current(), skills_manager, plugins_manager, mcp_manager, @@ -5545,6 +5549,125 @@ impl SessionTask for NeverEndingTask { } } +#[derive(Clone, Copy)] +struct GuardianDeniedApprovalTask; + +impl SessionTask for GuardianDeniedApprovalTask { + fn kind(&self) -> TaskKind { + TaskKind::Regular + } + + fn span_name(&self) -> &'static str { + "session_task.guardian_denied_approval" + } + + async fn run( + self: Arc, + session: Arc, + ctx: Arc, + _input: Vec, + cancellation_token: CancellationToken, + ) -> Option { + let session = session.clone_session(); + for _ in 0..3 { + crate::guardian::record_guardian_denial_for_test(&session, &ctx, &ctx.sub_id).await; + } + + cancellation_token.cancelled().await; + None + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn guardian_auto_review_interrupts_after_three_consecutive_denials() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; + let input = vec![UserInput::Text { + text: "trigger guardian denials".to_string(), + text_elements: Vec::new(), + }]; + sess.spawn_task(Arc::clone(&tc), input, GuardianDeniedApprovalTask) + .await; + + let mut observed = Vec::new(); + let aborted = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let event = rx.recv().await.expect("event"); + if let EventMsg::TurnAborted(event) = &event.msg { + let event = event.clone(); + observed.push(EventMsg::TurnAborted(event.clone())); + break event; + } + observed.push(event.msg); + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "guardian denial circuit breaker should interrupt the turn; observed events: {observed:?}" + ) + }); + assert_eq!(aborted.reason, TurnAbortReason::Interrupted); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn guardian_helper_review_interrupts_after_three_consecutive_denials() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; + let input = vec![UserInput::Text { + text: "keep turn active for helper reviews".to_string(), + text_elements: Vec::new(), + }]; + sess.spawn_task( + Arc::clone(&tc), + input, + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, + ) + .await; + + let session_for_review = Arc::clone(&sess); + let turn_for_review = Arc::clone(&tc); + let turn_id = tc.sub_id.clone(); + let review_thread = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("helper review runtime"); + runtime.block_on(async move { + for _ in 0..3 { + crate::guardian::record_guardian_denial_for_test( + &session_for_review, + &turn_for_review, + &turn_id, + ) + .await; + } + }); + }); + review_thread.join().expect("helper review thread"); + + let mut observed = Vec::new(); + let aborted = timeout(StdDuration::from_secs(5), async { + loop { + let event = rx.recv().await.expect("event"); + if let EventMsg::TurnAborted(event) = &event.msg { + let event = event.clone(); + observed.push(EventMsg::TurnAborted(event.clone())); + break event; + } + observed.push(event.msg); + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "helper review circuit breaker should interrupt the turn; observed events: {observed:?}" + ) + }); + assert_eq!(aborted.reason, TurnAbortReason::Interrupted); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_log::test] async fn abort_regular_task_emits_turn_aborted_only() { diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 780cee5f87..15b48f3922 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1484,6 +1484,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { }, EventMsg::Error(_) | EventMsg::Warning(_) + | EventMsg::GuardianWarning(_) | EventMsg::RealtimeConversationStarted(_) | EventMsg::RealtimeConversationSdp(_) | EventMsg::RealtimeConversationRealtime(_) diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 29f4433ce0..f93a44d7fe 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -8,6 +8,7 @@ use crate::client::ModelClient; use crate::config::StartedNetworkProxy; use crate::exec_policy::ExecPolicyManager; use crate::guardian::GuardianRejection; +use crate::guardian::GuardianRejectionCircuitBreaker; use crate::mcp::McpManager; use crate::plugins::PluginsManager; use crate::skills_watcher::SkillsWatcher; @@ -26,6 +27,7 @@ use codex_rollout::state_db::StateDbHandle; use codex_rollout_trace::RolloutTraceRecorder; use codex_thread_store::LocalThreadStore; use std::path::PathBuf; +use tokio::runtime::Handle; use tokio::sync::Mutex; use tokio::sync::RwLock; use tokio::sync::watch; @@ -52,6 +54,8 @@ pub(crate) struct SessionServices { pub(crate) session_telemetry: SessionTelemetry, pub(crate) tool_approvals: Mutex, pub(crate) guardian_rejections: Mutex>, + pub(crate) guardian_rejection_circuit_breaker: Mutex, + pub(crate) runtime_handle: Handle, pub(crate) skills_manager: Arc, pub(crate) plugins_manager: Arc, pub(crate) mcp_manager: Arc, diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index d9dfeb7db4..b9621d8fab 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -269,6 +269,12 @@ impl Session { let cancellation_token = CancellationToken::new(); let done = Arc::new(Notify::new()); + self.services + .guardian_rejection_circuit_breaker + .lock() + .await + .clear_turn(&turn_context.sub_id); + let queued_response_items = self.take_queued_response_items_for_next_turn().await; let mailbox_items = self.get_pending_input().await; let turn_state = { @@ -410,6 +416,40 @@ impl Session { } } + pub(crate) async fn abort_turn_if_active( + self: &Arc, + turn_id: &str, + reason: TurnAbortReason, + ) -> bool { + let active_turn = { + let mut active = self.active_turn.lock().await; + if active + .as_ref() + .is_some_and(|active_turn| active_turn.tasks.contains_key(turn_id)) + { + active.take() + } else { + None + } + }; + let Some(mut active_turn) = active_turn else { + return false; + }; + + for task in active_turn.drain_tasks() { + self.handle_task_abort(task, reason.clone()).await; + } + // Let interrupted tasks observe cancellation before dropping pending approvals, or an + // in-flight approval wait can surface as a model-visible rejection before TurnAborted. + active_turn.clear_pending().await; + + if reason == TurnAbortReason::Interrupted { + self.maybe_start_turn_for_pending_work().await; + } + + true + } + pub async fn on_task_finished( self: &Arc, turn_context: Arc, @@ -568,6 +608,11 @@ impl Session { time_to_first_token_ms, }); self.send_event(turn_context.as_ref(), event).await; + self.services + .guardian_rejection_circuit_breaker + .lock() + .await + .clear_turn(&turn_context.sub_id); if should_clear_active_turn { let session = Arc::clone(self); @@ -654,6 +699,11 @@ impl Session { duration_ms, }); self.send_event(task.turn_context.as_ref(), event).await; + self.services + .guardian_rejection_circuit_breaker + .lock() + .await + .clear_turn(&task.turn_context.sub_id); } } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 5ad6b160b1..039aab66b7 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -263,7 +263,7 @@ async fn run_codex_tool_session_inner( outgoing.send_response(request_id.clone(), result).await; break; } - EventMsg::Warning(_) => { + EventMsg::Warning(_) | EventMsg::GuardianWarning(_) => { continue; } EventMsg::GuardianAssessment(_) => { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1b8021bc70..dfbe085a8a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1441,6 +1441,9 @@ pub enum EventMsg { /// indicates the turn continued but the user should still be notified. Warning(WarningEvent), + /// Warning issued by the guardian automatic approval reviewer. + GuardianWarning(WarningEvent), + /// Realtime conversation lifecycle start event. RealtimeConversationStarted(RealtimeConversationStartedEvent), diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index df41d2c766..897cd0bbf2 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -132,6 +132,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) => Some(EventPersistenceMode::Extended), EventMsg::Warning(_) + | EventMsg::GuardianWarning(_) | EventMsg::RealtimeConversationStarted(_) | EventMsg::RealtimeConversationSdp(_) | EventMsg::RealtimeConversationRealtime(_) diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index db5aa1314d..f7793a55ae 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -425,6 +425,7 @@ fn server_notification_thread_target( Some(notification.thread_id.as_str()) } ServerNotification::Warning(notification) => notification.thread_id.as_deref(), + ServerNotification::GuardianWarning(notification) => Some(notification.thread_id.as_str()), ServerNotification::SkillsChanged(_) | ServerNotification::McpServerStatusUpdated(_) | ServerNotification::McpServerOauthLoginCompleted(_) @@ -1067,6 +1068,7 @@ mod tests { use codex_app_server_protocol::CommandExecutionOutputDeltaNotification; use codex_app_server_protocol::CommandExecutionSource; use codex_app_server_protocol::CommandExecutionStatus; + use codex_app_server_protocol::GuardianWarningNotification; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; @@ -1686,4 +1688,17 @@ mod tests { assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id)); } + + #[test] + fn guardian_warning_notifications_route_to_threads() { + let thread_id = ThreadId::new(); + let notification = ServerNotification::GuardianWarning(GuardianWarningNotification { + thread_id: thread_id.to_string(), + message: "warning".to_string(), + }); + + let target = server_notification_thread_target(¬ification); + + assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id)); + } } diff --git a/codex-rs/tui/src/app/replay_filter.rs b/codex-rs/tui/src/app/replay_filter.rs index ec9176ffaf..273ca0dd5a 100644 --- a/codex-rs/tui/src/app/replay_filter.rs +++ b/codex-rs/tui/src/app/replay_filter.rs @@ -25,7 +25,9 @@ pub(super) fn event_is_notice(event: &ThreadBufferedEvent) -> bool { matches!( event, ThreadBufferedEvent::Notification( - ServerNotification::Warning(_) | ServerNotification::ConfigWarning(_) + ServerNotification::Warning(_) + | ServerNotification::GuardianWarning(_) + | ServerNotification::ConfigWarning(_) ) ) } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 46850c8304..093ff91aae 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -6570,6 +6570,9 @@ impl ChatWidget { } ServerNotification::ModelRerouted(_) => {} ServerNotification::Warning(notification) => self.on_warning(notification.message), + ServerNotification::GuardianWarning(notification) => { + self.on_warning(notification.message) + } ServerNotification::DeprecationNotice(notification) => { self.on_deprecation_notice(DeprecationNoticeEvent { summary: notification.summary, @@ -7067,7 +7070,8 @@ impl ChatWidget { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); } - EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), + EventMsg::Warning(WarningEvent { message }) + | EventMsg::GuardianWarning(WarningEvent { message }) => self.on_warning(message), EventMsg::GuardianAssessment(ev) => self.on_guardian_assessment(ev), EventMsg::ModelReroute(_) => {} EventMsg::Error(ErrorEvent { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d81544e4f7..f001c3afdc 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -51,6 +51,7 @@ pub(super) use codex_app_server_protocol::GuardianApprovalReviewStatus; pub(super) use codex_app_server_protocol::GuardianCommandSource as AppServerGuardianCommandSource; pub(super) use codex_app_server_protocol::GuardianRiskLevel as AppServerGuardianRiskLevel; pub(super) use codex_app_server_protocol::GuardianUserAuthorization as AppServerGuardianUserAuthorization; +pub(super) use codex_app_server_protocol::GuardianWarningNotification; pub(super) use codex_app_server_protocol::HookCompletedNotification as AppServerHookCompletedNotification; pub(super) use codex_app_server_protocol::HookEventName as AppServerHookEventName; pub(super) use codex_app_server_protocol::HookExecutionMode as AppServerHookExecutionMode; diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 95ec729403..06acb16d07 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -212,6 +212,27 @@ async fn live_app_server_warning_notification_renders_message() { ); } +#[tokio::test] +async fn live_app_server_guardian_warning_notification_renders_message() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_server_notification( + ServerNotification::GuardianWarning(GuardianWarningNotification { + thread_id: "thread-1".to_string(), + message: "Automatic approval review denied the requested action.".to_string(), + }), + /*replay_kind*/ None, + ); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one warning history cell"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Automatic approval review denied the requested action."), + "expected guardian warning notification message, got {rendered}" + ); +} + #[tokio::test] async fn live_app_server_config_warning_prefixes_summary() { let (mut chat, mut 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 ffdefa534b..dfb74a5d5c 100644 --- a/codex-rs/tui/src/chatwidget/tests/guardian.rs +++ b/codex-rs/tui/src/chatwidget/tests/guardian.rs @@ -28,7 +28,7 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() { }); chat.handle_codex_event(Event { id: "guardian-warning".into(), - msg: EventMsg::Warning(WarningEvent { + msg: EventMsg::GuardianWarning(WarningEvent { message: "Automatic approval review denied (risk: high): The planned action would transmit the full contents of a workspace source file (`core/src/codex.rs`) to `https://example.com`, which is an external and untrusted endpoint.".into(), }), }); @@ -228,7 +228,7 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() { }); chat.handle_codex_event(Event { id: "guardian-warning".into(), - msg: EventMsg::Warning(WarningEvent { + msg: EventMsg::GuardianWarning(WarningEvent { message: "Automatic approval review timed out while evaluating the requested approval." .into(), }), From 18a26d7bbc9dfe7509dc0bac134af604d4f0d145 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 13:34:33 -0700 Subject: [PATCH 02/12] app-server: accept permission profile overrides (#18279) ## Why `PermissionProfile` is becoming the canonical permissions shape shared by core and app-server. After app-server responses expose the active profile, clients need to be able to send that same shape back when starting, resuming, forking, or overriding a turn instead of translating through the legacy `sandbox`/`sandboxPolicy` shorthands. This still needs to preserve the existing requirements/platform enforcement model. A profile-shaped request can be downgraded or rejected by constraints, but the server should keep the user's elevated-access intent for project trust decisions. Turn-level profile overrides also need to retain existing read protections, including deny-read entries and bounded glob-scan metadata, so a permission override cannot accidentally drop configured protections such as `**/*.env = deny`. ## What changed - Adds optional `permissionProfile` request fields to `thread/start`, `thread/resume`, `thread/fork`, and `turn/start`. - Rejects ambiguous requests that specify both `permissionProfile` and the legacy `sandbox`/`sandboxPolicy` fields, including running-thread resume requests. - Converts profile-shaped overrides into core runtime filesystem/network permissions while continuing to derive the constrained legacy sandbox projection used by existing execution paths. - Preserves project-trust intent for profile overrides that are equivalent to workspace-write or full-access sandbox requests. - Preserves existing deny-read entries and `globScanMaxDepth` when applying turn-level `permissionProfile` overrides. - Updates app-server docs plus generated JSON/TypeScript schema fixtures and regression coverage. ## Verification - `cargo test -p codex-app-server-protocol schema_fixtures` - `cargo test -p codex-core session_configuration_apply_permission_profile_preserves_existing_deny_read_entries` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/18279). * #18288 * #18287 * #18286 * #18285 * #18284 * #18283 * #18282 * #18281 * #18280 * __->__ #18279 --- .../schema/json/ClientRequest.json | 313 ++++++++++++++++++ .../codex_app_server_protocol.schemas.json | 44 +++ .../codex_app_server_protocol.v2.schemas.json | 44 +++ .../schema/json/v2/ThreadForkParams.json | 284 ++++++++++++++++ .../schema/json/v2/ThreadResumeParams.json | 284 ++++++++++++++++ .../schema/json/v2/ThreadStartParams.json | 284 ++++++++++++++++ .../schema/json/v2/TurnStartParams.json | 280 ++++++++++++++++ .../schema/typescript/v2/ThreadForkParams.ts | 7 +- .../typescript/v2/ThreadResumeParams.ts | 7 +- .../schema/typescript/v2/ThreadStartParams.ts | 7 +- .../schema/typescript/v2/TurnStartParams.ts | 5 + .../app-server-protocol/src/protocol/v2.rs | 17 + codex-rs/app-server/README.md | 14 +- .../app-server/src/codex_message_processor.rs | 238 ++++++++++--- .../src/message_processor/tracing_tests.rs | 1 + .../app-server/tests/suite/v2/skills_list.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 86 +++++ codex-rs/core/src/codex_thread.rs | 66 ++++ codex-rs/core/src/config/config_tests.rs | 113 +++++++ codex-rs/core/src/config/mod.rs | 60 +++- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/session/handlers.rs | 57 +++- codex-rs/core/src/session/mod.rs | 10 +- codex-rs/core/src/session/session.rs | 51 ++- codex-rs/core/src/session/tests.rs | 72 ++++ .../tests/suite/collaboration_instructions.rs | 13 + codex-rs/core/tests/suite/compact.rs | 1 + codex-rs/core/tests/suite/compact_remote.rs | 2 + .../core/tests/suite/compact_resume_fork.rs | 1 + codex-rs/core/tests/suite/model_overrides.rs | 2 + codex-rs/core/tests/suite/model_switching.rs | 3 + .../core/tests/suite/model_visible_layout.rs | 1 + codex-rs/core/tests/suite/override_updates.rs | 3 + .../core/tests/suite/permissions_messages.rs | 4 + codex-rs/core/tests/suite/personality.rs | 4 + codex-rs/core/tests/suite/prompt_caching.rs | 2 + codex-rs/core/tests/suite/remote_models.rs | 2 + codex-rs/core/tests/suite/resume.rs | 1 + codex-rs/core/tests/suite/review.rs | 1 + codex-rs/exec/src/lib.rs | 2 + codex-rs/protocol/src/models.rs | 7 + codex-rs/protocol/src/protocol.rs | 79 +++++ codex-rs/tui/src/app/tests.rs | 6 + codex-rs/tui/src/app_command.rs | 2 + codex-rs/tui/src/app_server_session.rs | 1 + .../tui/src/chatwidget/tests/permissions.rs | 1 + 46 files changed, 2425 insertions(+), 59 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index dd5e1a9306..ada860ae27 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -897,6 +897,217 @@ ], "type": "object" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "current_working_directory" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "CurrentWorkingDirectoryFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "FsCopyParams": { "description": "Copy a file or directory tree on the host filesystem.", "properties": { @@ -1657,6 +1868,64 @@ ], "type": "string" }, + "PermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "PermissionProfileFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "entries" + ], + "type": "object" + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "Personality": { "enum": [ "none", @@ -3044,6 +3313,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for the forked thread. Cannot be combined with `sandbox`." + }, "sandbox": { "anyOf": [ { @@ -3436,6 +3716,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for the resumed thread. Cannot be combined with `sandbox`." + }, "personality": { "anyOf": [ { @@ -3619,6 +3910,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for this thread. Cannot be combined with `sandbox`." + }, "personality": { "anyOf": [ { @@ -3830,6 +4132,17 @@ "outputSchema": { "description": "Optional JSON Schema used to constrain the final assistant message for this turn." }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Override the full permissions profile for this turn and subsequent turns. Cannot be combined with `sandboxPolicy`." + }, "personality": { "anyOf": [ { 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 677a7fa940..51fb744077 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 @@ -14142,6 +14142,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for the forked thread. Cannot be combined with `sandbox`." + }, "sandbox": { "anyOf": [ { @@ -15560,6 +15571,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for the resumed thread. Cannot be combined with `sandbox`." + }, "personality": { "anyOf": [ { @@ -15866,6 +15888,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for this thread. Cannot be combined with `sandbox`." + }, "personality": { "anyOf": [ { @@ -16671,6 +16704,17 @@ "outputSchema": { "description": "Optional JSON Schema used to constrain the final assistant message for this turn." }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Override the full permissions profile for this turn and subsequent turns. Cannot be combined with `sandboxPolicy`." + }, "personality": { "anyOf": [ { 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 421abb76c8..6291b5b987 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 @@ -12036,6 +12036,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for the forked thread. Cannot be combined with `sandbox`." + }, "sandbox": { "anyOf": [ { @@ -13454,6 +13465,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for the resumed thread. Cannot be combined with `sandbox`." + }, "personality": { "anyOf": [ { @@ -13760,6 +13782,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for this thread. Cannot be combined with `sandbox`." + }, "personality": { "anyOf": [ { @@ -14565,6 +14598,17 @@ "outputSchema": { "description": "Optional JSON Schema used to constrain the final assistant message for this turn." }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Override the full permissions profile for this turn and subsequent turns. Cannot be combined with `sandboxPolicy`." + }, "personality": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index a8fa95e2e9..6b227dec34 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", "enum": [ @@ -59,6 +63,275 @@ } ] }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "current_working_directory" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "CurrentWorkingDirectoryFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, + "PermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "PermissionProfileFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "entries" + ], + "type": "object" + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "SandboxMode": { "enum": [ "read-only", @@ -139,6 +412,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for the forked thread. Cannot be combined with `sandbox`." + }, "sandbox": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 35a9e5b2a8..d4dce1b650 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", "enum": [ @@ -133,6 +137,217 @@ } ] }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "current_working_directory" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "CurrentWorkingDirectoryFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "FunctionCallOutputBody": { "anyOf": [ { @@ -325,6 +540,64 @@ } ] }, + "PermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "PermissionProfileFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "entries" + ], + "type": "object" + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "Personality": { "enum": [ "none", @@ -1052,6 +1325,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for the resumed thread. Cannot be combined with `sandbox`." + }, "personality": { "anyOf": [ { 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 9b1cba6d1a..a382edd66d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", "enum": [ @@ -85,6 +89,275 @@ ], "type": "object" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "current_working_directory" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "CurrentWorkingDirectoryFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, + "PermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "PermissionProfileFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "entries" + ], + "type": "object" + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "Personality": { "enum": [ "none", @@ -181,6 +454,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Full permissions override for this thread. Cannot be combined with `sandbox`." + }, "personality": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 071bc2ac3e..3af55e7294 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -98,6 +98,217 @@ ], "type": "object" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "current_working_directory" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "CurrentWorkingDirectoryFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -113,6 +324,64 @@ ], "type": "string" }, + "PermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfileNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "PermissionProfileFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "entries" + ], + "type": "object" + }, + "PermissionProfileNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "Personality": { "enum": [ "none", @@ -570,6 +839,17 @@ "outputSchema": { "description": "Optional JSON Schema used to constrain the final assistant message for this turn." }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Override the full permissions profile for this turn and subsequent turns. Cannot be combined with `sandboxPolicy`." + }, "personality": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index a7ba311803..bd73b3c3c3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -5,6 +5,7 @@ import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; +import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxMode } from "./SandboxMode"; /** @@ -27,7 +28,11 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, /** + * Full permissions override for the forked thread. Cannot be combined + * with `sandbox`. + */ +permissionProfile?: PermissionProfile | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** * If true, persist additional rollout EventMsg variants required to * reconstruct a richer thread history on subsequent resume/fork/read. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index 770344de8e..d245efe4a2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -7,6 +7,7 @@ import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; +import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxMode } from "./SandboxMode"; /** @@ -36,7 +37,11 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /** +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, /** + * Full permissions override for the resumed thread. Cannot be combined + * with `sandbox`. + */ +permissionProfile?: PermissionProfile | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /** * If true, persist additional rollout EventMsg variants required to * reconstruct a richer thread history on subsequent resume/fork/read. */ 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 904487e81c..8b9dafec9f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts @@ -6,6 +6,7 @@ import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; +import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxMode } from "./SandboxMode"; import type { ThreadStartSource } from "./ThreadStartSource"; @@ -13,7 +14,11 @@ export type ThreadStartParams = {model?: string | null, modelProvider?: string | * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, sessionStartSource?: ThreadStartSource | null, /** +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, /** + * Full permissions override for this thread. Cannot be combined with + * `sandbox`. + */ +permissionProfile?: PermissionProfile | 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/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 8f57a5e68b..3d12e6001c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -9,6 +9,7 @@ import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; +import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { UserInput } from "./UserInput"; @@ -26,6 +27,10 @@ approvalsReviewer?: ApprovalsReviewer | null, /** * Override the sandbox policy for this turn and subsequent turns. */ sandboxPolicy?: SandboxPolicy | null, /** + * Override the full permissions profile for this turn and subsequent + * turns. Cannot be combined with `sandboxPolicy`. + */ +permissionProfile?: PermissionProfile | null, /** * Override the model for this turn and subsequent turns. */ model?: string | null, /** diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e15fe5dab9..2e492e3797 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3147,6 +3147,10 @@ pub struct ThreadStartParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, + /// Full permissions override for this thread. Cannot be combined with + /// `sandbox`. + #[ts(optional = nullable)] + pub permission_profile: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -3280,6 +3284,10 @@ pub struct ThreadResumeParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, + /// Full permissions override for the resumed thread. Cannot be combined + /// with `sandbox`. + #[ts(optional = nullable)] + pub permission_profile: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -3368,6 +3376,10 @@ pub struct ThreadForkParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, + /// Full permissions override for the forked thread. Cannot be combined + /// with `sandbox`. + #[ts(optional = nullable)] + pub permission_profile: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -4816,6 +4828,10 @@ pub struct TurnStartParams { /// Override the sandbox policy for this turn and subsequent turns. #[ts(optional = nullable)] pub sandbox_policy: Option, + /// Override the full permissions profile for this turn and subsequent + /// turns. Cannot be combined with `sandboxPolicy`. + #[ts(optional = nullable)] + pub permission_profile: Option, /// Override the model for this turn and subsequent turns. #[ts(optional = nullable)] pub model: Option, @@ -10007,6 +10023,7 @@ mod tests { approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, model: None, service_tier: None, effort: None, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 2452cba36b..62e490dee3 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -82,7 +82,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat - Initialize once per connection: Immediately after opening a transport connection, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request on that connection before this handshake gets rejected. - Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and you’ll also get a `thread/started` notification. If you’re continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history. Like `thread/start`, `thread/fork` also accepts `ephemeral: true` for an in-memory temporary thread. The returned `thread.ephemeral` flag tells you whether the session is intentionally in-memory only; when it is `true`, `thread.path` is `null`. -- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy, approval policy, approvals reviewer, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. +- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy or `permissionProfile`, approval policy, approvals reviewer, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. - Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes). - Finish the turn: When the model is done (or the turn is interrupted via making the `turn/interrupt` call), the server sends `turn/completed` with the final turn state and token usage. @@ -136,9 +136,9 @@ 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`. 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/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer `permissionProfile`; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissionProfile`. +- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. +- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Accepts the same permission override rules as `thread/start`. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. @@ -155,7 +155,7 @@ Example with notification opt-out: - `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/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". +- `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. Prefer `permissionProfile` for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissionProfile`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. @@ -223,6 +223,8 @@ Start a fresh thread when you need a new Codex conversation. "cwd": "/Users/me/project", "approvalPolicy": "never", "sandbox": "workspaceWrite", + // Prefer "permissionProfile" for full permission overrides. Do not send + // both "sandbox" and "permissionProfile". "personality": "friendly", "serviceName": "my_app_server_client", // optional metrics tag (`service_name`) "sessionStartSource": "startup", // optional: "startup" (default) or "clear" @@ -545,6 +547,8 @@ You can optionally specify config overrides on the new turn. If specified, these "writableRoots": ["/Users/me/project"], "networkAccess": true }, + // Prefer "permissionProfile" for full permission overrides. Do not send + // both "sandboxPolicy" and "permissionProfile". "model": "gpt-5.1-codex", "effort": "medium", "summary": "concise", diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0eb465e54a..1ab367d078 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -103,6 +103,7 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::PermissionProfile as ApiPermissionProfile; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; @@ -218,6 +219,7 @@ use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_config::types::McpServerTransportConfig; use codex_core::CodexThread; +use codex_core::CodexThreadTurnContextOverrides; use codex_core::ForkSnapshot; use codex_core::NewThread; use codex_core::RolloutRecorder; @@ -2349,6 +2351,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, + permission_profile, config, service_name, base_instructions, @@ -2361,6 +2364,14 @@ impl CodexMessageProcessor { session_start_source, persist_extended_history, } = params; + if sandbox.is_some() && permission_profile.is_some() { + self.send_invalid_request_error( + request_id, + "`permissionProfile` cannot be combined with `sandbox`".to_string(), + ) + .await; + return; + } let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, @@ -2369,6 +2380,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, + permission_profile, base_instructions, developer_instructions, personality, @@ -2498,17 +2510,12 @@ impl CodexMessageProcessor { // could be downgraded to ReadOnly (perhaps there is no sandbox // available on Windows or the enterprise config disallows it). The cwd // should still be considered "trusted" in this case. - let requested_sandbox_trusts_project = matches!( - typesafe_overrides.sandbox_mode, - Some( - codex_protocol::config_types::SandboxMode::WorkspaceWrite - | codex_protocol::config_types::SandboxMode::DangerFullAccess - ) - ); + let requested_permissions_trust_project = + requested_permissions_trust_project(&typesafe_overrides, config.cwd.as_path()); if requested_cwd.is_some() && !config.active_project.is_trusted() - && (requested_sandbox_trusts_project + && (requested_permissions_trust_project || matches!( config.permissions.sandbox_policy.get(), codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } @@ -2775,6 +2782,7 @@ impl CodexMessageProcessor { approval_policy: Option, approvals_reviewer: Option, sandbox: Option, + permission_profile: Option, base_instructions: Option, developer_instructions: Option, personality: Option, @@ -2789,6 +2797,7 @@ impl CodexMessageProcessor { approvals_reviewer: approvals_reviewer .map(codex_app_server_protocol::ApprovalsReviewer::to_core), sandbox_mode: sandbox.map(SandboxMode::to_core), + permission_profile: permission_profile.map(Into::into), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), base_instructions, @@ -4341,6 +4350,15 @@ impl CodexMessageProcessor { return; } + if params.sandbox.is_some() && params.permission_profile.is_some() { + self.send_invalid_request_error( + request_id, + "`permissionProfile` cannot be combined with `sandbox`".to_string(), + ) + .await; + return; + } + if self .resume_running_thread(request_id.clone(), ¶ms) .await @@ -4359,6 +4377,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, + permission_profile, config: mut request_overrides, base_instructions, developer_instructions, @@ -4393,6 +4412,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, + permission_profile, base_instructions, developer_instructions, personality, @@ -4894,12 +4914,21 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, + permission_profile, config: cli_overrides, base_instructions, developer_instructions, ephemeral, persist_extended_history, } = params; + if sandbox.is_some() && permission_profile.is_some() { + self.send_invalid_request_error( + request_id, + "`permissionProfile` cannot be combined with `sandbox`".to_string(), + ) + .await; + return; + } let (rollout_path, source_thread_id) = if let Some(path) = path { (path, None) @@ -4976,6 +5005,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, + permission_profile, base_instructions, developer_instructions, /*personality*/ None, @@ -6800,6 +6830,7 @@ impl CodexMessageProcessor { || params.approval_policy.is_some() || params.approvals_reviewer.is_some() || params.sandbox_policy.is_some() + || params.permission_profile.is_some() || params.model.is_some() || params.service_tier.is_some() || params.effort.is_some() @@ -6807,43 +6838,88 @@ impl CodexMessageProcessor { || collaboration_mode.is_some() || params.personality.is_some(); - // If any overrides are provided, update the session turn context first. + if params.sandbox_policy.is_some() && params.permission_profile.is_some() { + self.send_invalid_request_error( + request_id, + "`permissionProfile` cannot be combined with `sandboxPolicy`".to_string(), + ) + .await; + return; + } + + let cwd = params.cwd; + let approval_policy = params.approval_policy.map(AskForApproval::to_core); + let approvals_reviewer = params + .approvals_reviewer + .map(codex_app_server_protocol::ApprovalsReviewer::to_core); + let sandbox_policy = params.sandbox_policy.map(|p| p.to_core()); + let permission_profile = params.permission_profile.map(Into::into); + let model = params.model; + let effort = params.effort.map(Some); + let summary = params.summary; + let service_tier = params.service_tier; + let personality = params.personality; + + // If any overrides are provided, validate them synchronously so the + // request can fail before accepting user input. The actual update is + // still queued together with the input below to preserve submission order. if has_any_overrides { - let _ = self - .submit_core_op( - &request_id, - thread.as_ref(), - Op::OverrideTurnContext { - cwd: params.cwd, - approval_policy: params.approval_policy.map(AskForApproval::to_core), - approvals_reviewer: params - .approvals_reviewer - .map(codex_app_server_protocol::ApprovalsReviewer::to_core), - sandbox_policy: params.sandbox_policy.map(|p| p.to_core()), - windows_sandbox_level: None, - model: params.model, - effort: params.effort.map(Some), - summary: params.summary, - service_tier: params.service_tier, - collaboration_mode, - personality: params.personality, - }, + let result = thread + .validate_turn_context_overrides(CodexThreadTurnContextOverrides { + cwd: cwd.clone(), + approval_policy, + approvals_reviewer, + sandbox_policy: sandbox_policy.clone(), + permission_profile: permission_profile.clone(), + windows_sandbox_level: None, + model: model.clone(), + effort, + summary, + service_tier, + collaboration_mode: collaboration_mode.clone(), + personality, + }) + .await; + if let Err(err) = result { + self.send_invalid_request_error( + request_id, + format!("invalid turn context override: {err}"), ) .await; + return; + } } // Start the turn by submitting the user input. Return its submission id as turn_id. + let turn_op = if has_any_overrides { + Op::UserInputWithTurnContext { + items: mapped_items, + environments, + final_output_json_schema: params.output_schema, + responsesapi_client_metadata: params.responsesapi_client_metadata, + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + windows_sandbox_level: None, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + } + } else { + Op::UserInput { + items: mapped_items, + environments, + final_output_json_schema: params.output_schema, + responsesapi_client_metadata: params.responsesapi_client_metadata, + } + }; let turn_id = self - .submit_core_op( - &request_id, - thread.as_ref(), - Op::UserInput { - items: mapped_items, - environments, - final_output_json_schema: params.output_schema, - responsesapi_client_metadata: params.responsesapi_client_metadata, - }, - ) + .submit_core_op(&request_id, thread.as_ref(), turn_op) .await; match turn_id { @@ -8753,6 +8829,16 @@ fn collect_resume_override_mismatches( )); } } + if let Some(requested_permission_profile) = request.permission_profile.as_ref() { + let requested_permission_profile = + codex_protocol::models::PermissionProfile::from(requested_permission_profile.clone()); + if requested_permission_profile != config_snapshot.permission_profile { + mismatch_details.push(format!( + "permission_profile requested={requested_permission_profile:?} active={:?}", + config_snapshot.permission_profile + )); + } + } if let Some(requested_personality) = request.personality.as_ref() && config_snapshot.personality.as_ref() != Some(requested_personality) { @@ -9685,6 +9771,34 @@ fn thread_response_permission_profile( } } +fn requested_permissions_trust_project(overrides: &ConfigOverrides, cwd: &Path) -> bool { + if matches!( + overrides.sandbox_mode, + Some( + codex_protocol::config_types::SandboxMode::WorkspaceWrite + | codex_protocol::config_types::SandboxMode::DangerFullAccess + ) + ) { + return true; + } + + overrides + .permission_profile + .as_ref() + .is_some_and(|profile| { + profile + .to_legacy_sandbox_policy(cwd) + .is_ok_and(|sandbox_policy| { + matches!( + sandbox_policy, + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } + | codex_protocol::protocol::SandboxPolicy::DangerFullAccess + | codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } + ) + }) + }) +} + fn parse_datetime(timestamp: Option<&str>) -> Option> { timestamp.and_then(|ts| { chrono::DateTime::parse_from_rfc3339(ts) @@ -10196,6 +10310,48 @@ mod tests { ); } + #[test] + fn requested_permissions_trust_project_uses_permission_profile_intent() { + let cwd = test_path_buf("/tmp/project").abs(); + let full_access_profile = + codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::DangerFullAccess, + cwd.as_path(), + ); + let workspace_write_profile = + codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy(), + cwd.as_path(), + ); + let read_only_profile = + codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + cwd.as_path(), + ); + + assert!(requested_permissions_trust_project( + &ConfigOverrides { + permission_profile: Some(full_access_profile), + ..Default::default() + }, + cwd.as_path() + )); + assert!(requested_permissions_trust_project( + &ConfigOverrides { + permission_profile: Some(workspace_write_profile), + ..Default::default() + }, + cwd.as_path() + )); + assert!(!requested_permissions_trust_project( + &ConfigOverrides { + permission_profile: Some(read_only_profile), + ..Default::default() + }, + cwd.as_path() + )); + } + #[test] fn config_load_error_marks_cloud_requirements_failures_for_relogin() { let err = std::io::Error::other(CloudRequirementsLoadError::new( @@ -10322,6 +10478,7 @@ mod tests { #[test] fn collect_resume_override_mismatches_includes_service_tier() { + let cwd = test_path_buf("/tmp").abs(); let request = ThreadResumeParams { thread_id: "thread-1".to_string(), history: None, @@ -10333,6 +10490,7 @@ mod tests { approval_policy: None, approvals_reviewer: None, sandbox: None, + permission_profile: None, config: None, base_instructions: None, developer_instructions: None, @@ -10349,9 +10507,9 @@ mod tests { permission_profile: codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( &codex_protocol::protocol::SandboxPolicy::DangerFullAccess, - std::path::Path::new("/tmp"), + cwd.as_path(), ), - cwd: test_path_buf("/tmp").abs(), + cwd, ephemeral: false, reasoning_effort: None, personality: None, diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index 04178c70bf..e0a1ed4bc4 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -717,6 +717,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { cwd: None, approval_policy: None, sandbox_policy: None, + permission_profile: None, approvals_reviewer: None, model: None, service_tier: None, 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 8675b3a429..195c4a86db 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -315,6 +315,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( approval_policy: None, approvals_reviewer: None, sandbox: None, + permission_profile: None, config: None, service_name: None, base_instructions: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 93e2e4c802..c6eb58a432 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -26,6 +26,9 @@ use codex_app_server_protocol::FileChangeApprovalDecision; use codex_app_server_protocol::FileChangeOutputDeltaNotification; use codex_app_server_protocol::FileChangePatchUpdatedNotification; use codex_app_server_protocol::FileChangeRequestApprovalResponse; +use codex_app_server_protocol::FileSystemAccessMode; +use codex_app_server_protocol::FileSystemPath; +use codex_app_server_protocol::FileSystemSandboxEntry; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCError; @@ -34,6 +37,8 @@ use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; +use codex_app_server_protocol::PermissionProfile; +use codex_app_server_protocol::PermissionProfileFileSystemPermissions; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestResolvedNotification; @@ -59,6 +64,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -79,6 +85,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const TEST_ORIGINATOR: &str = "codex_vscode"; const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer."; +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; fn body_contains(req: &wiremock::Request, text: &str) -> bool { String::from_utf8(req.body.clone()) @@ -736,6 +743,83 @@ async fn turn_start_rejects_combined_oversized_text_input() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_rejects_invalid_permission_profile_before_starting_turn() -> Result<()> { + let codex_home = TempDir::new()?; + let unsupported_write_root = TempDir::new()?; + create_config_toml( + codex_home.path(), + "http://localhost/unused", + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + let unsupported_write_root = AbsolutePathBuf::from_absolute_path(unsupported_write_root.path()) + .expect("tempdir path should be absolute"); + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + permission_profile: Some(PermissionProfile { + network: None, + file_system: Some(PermissionProfileFileSystemPermissions { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: unsupported_write_root, + }, + access: FileSystemAccessMode::Write, + }], + glob_scan_max_depth: None, + }), + }), + ..Default::default() + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(turn_req)), + ) + .await??; + + assert_eq!(err.error.code, INVALID_REQUEST_ERROR_CODE); + assert!(err.error.message.contains("invalid turn context override")); + assert!( + err.error + .message + .contains("filesystem writes outside the workspace root") + ); + let turn_started = tokio::time::timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("turn/started"), + ) + .await; + assert!( + turn_started.is_err(), + "did not expect a turn/started notification after rejected permissionProfile" + ); + + Ok(()) +} + #[tokio::test] async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<()> { // Provide a mock server and config so model wiring is valid. @@ -1750,6 +1834,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }), + permission_profile: None, model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), @@ -1785,6 +1870,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + permission_profile: None, model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 7c8e9fa68a..cda2d22fb0 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -2,11 +2,15 @@ use crate::agent::AgentStatus; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; use crate::session::Codex; +use crate::session::SessionSettingsUpdate; use crate::session::SteerInputError; use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::mcp::CallToolResult; @@ -51,6 +55,23 @@ pub struct ThreadConfigSnapshot { pub session_source: SessionSource, } +/// Turn context overrides that app-server validates before starting a turn. +#[derive(Clone, Default)] +pub struct CodexThreadTurnContextOverrides { + pub cwd: Option, + pub approval_policy: Option, + pub approvals_reviewer: Option, + pub sandbox_policy: Option, + pub permission_profile: Option, + pub windows_sandbox_level: Option, + pub model: Option, + pub effort: Option>, + pub summary: Option, + pub service_tier: Option>, + pub collaboration_mode: Option, + pub personality: Option, +} + pub struct CodexThread { pub(crate) codex: Codex, rollout_path: Option, @@ -126,6 +147,51 @@ impl CodexThread { .await } + /// Validate persistent turn context overrides without committing them. + pub async fn validate_turn_context_overrides( + &self, + overrides: CodexThreadTurnContextOverrides, + ) -> ConstraintResult<()> { + let CodexThreadTurnContextOverrides { + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + windows_sandbox_level, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + } = overrides; + let collaboration_mode = if let Some(collaboration_mode) = collaboration_mode { + collaboration_mode + } else { + self.codex + .session + .collaboration_mode() + .await + .with_updates(model, effort, /*developer_instructions*/ None) + }; + + let updates = SessionSettingsUpdate { + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + windows_sandbox_level, + collaboration_mode: Some(collaboration_mode), + reasoning_summary: summary, + service_tier, + personality, + ..Default::default() + }; + self.codex.session.validate_settings(&updates).await + } + /// Use sparingly: this is intended to be removed soon. pub async fn submit_with_id(&self, sub: Submission) -> CodexResult<()> { self.codex.submit_with_id(sub).await diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index f6589ae23a..3765e31f56 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -54,6 +54,9 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::NetworkPermissions; +use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -62,6 +65,7 @@ use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::RealtimeVoice; +use codex_protocol::protocol::SandboxPolicy; use serde::Deserialize; use tempfile::tempdir; @@ -801,6 +805,115 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: Ok(()) } +#[tokio::test] +async fn permission_profile_override_populates_runtime_permissions() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let permission_profile = PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: Some(FileSystemPermissions { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }], + glob_scan_max_depth: None, + }), + }; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + permission_profile: Some(permission_profile.clone()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!( + config.permissions.sandbox_policy.get(), + &SandboxPolicy::DangerFullAccess + ); + Ok(()) +} + +#[tokio::test] +async fn permission_profile_override_preserves_configured_network_proxy() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let permission_profile = PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: Some(FileSystemPermissions { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }], + glob_scan_max_depth: None, + }), + }; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: Some(NetworkToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + enable_socks5: Some(false), + allow_upstream_proxy: Some(false), + domains: Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + }), + ..Default::default() + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + permission_profile: Some(permission_profile.clone()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + let network = config + .permissions + .network + .as_ref() + .expect("network-enabled override should preserve configured proxy"); + + assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128"); + assert!(!network.socks_enabled()); + assert_eq!(config.permissions.permission_profile(), permission_profile); + Ok(()) +} + #[tokio::test] async fn project_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index aa9919363e..e89cb7ca0e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1378,6 +1378,7 @@ pub struct ConfigOverrides { pub approval_policy: Option, pub approvals_reviewer: Option, pub sandbox_mode: Option, + pub permission_profile: Option, pub model_provider: Option, pub service_tier: Option>, pub config_profile: Option, @@ -1596,6 +1597,7 @@ impl Config { approval_policy: approval_policy_override, approvals_reviewer: approvals_reviewer_override, sandbox_mode, + permission_profile, model_provider, service_tier: service_tier_override, config_profile: config_profile_key, @@ -1616,6 +1618,13 @@ impl Config { additional_writable_roots, } = overrides; + if sandbox_mode.is_some() && permission_profile.is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "`sandbox_mode` and `permission_profile` overrides cannot both be set", + )); + } + let active_profile_name = config_profile_key .as_ref() .or(cfg.profile.as_ref()) @@ -1736,7 +1745,56 @@ impl Config { sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, - ) = if profiles_are_active { + ) = if let Some(permission_profile) = permission_profile { + let (mut file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + let configured_network_proxy_config = + if network_sandbox_policy.is_enabled() && profiles_are_active { + let permissions = cfg.permissions.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "default_permissions requires a `[permissions]` table", + ) + })?; + let default_permissions = cfg.default_permissions.as_deref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "default_permissions requires a named permissions profile", + ) + })?; + let profile = resolve_permission_profile(permissions, default_permissions)?; + + // PermissionProfile only carries the network enabled bit today. Keep the + // configured proxy/allowlist policy so active profiles can round-trip without + // broadening network behavior. + network_proxy_config_from_profile_network(profile.network.as_ref()) + } else { + NetworkProxyConfig::default() + }; + let mut sandbox_policy = permission_profile + .to_legacy_sandbox_policy(resolved_cwd.as_path()) + .map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid permission_profile override: {err}"), + ) + })?; + if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { + file_system_sandbox_policy = file_system_sandbox_policy + .with_additional_writable_roots( + resolved_cwd.as_path(), + &additional_writable_roots, + ); + sandbox_policy = file_system_sandbox_policy + .to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?; + } + ( + configured_network_proxy_config, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + ) + } else if profiles_are_active { let permissions = cfg.permissions.as_ref().ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::InvalidInput, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 373e57737b..548287958d 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -18,6 +18,7 @@ pub use session::SteerInputError; mod codex_thread; mod compact_remote; pub use codex_thread::CodexThread; +pub use codex_thread::CodexThreadTurnContextOverrides; pub use codex_thread::ThreadConfigSnapshot; mod agent; mod codex_delegate; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index bbc66141ec..9c91f78188 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -162,6 +162,7 @@ pub(super) async fn user_input_or_turn_inner( approval_policy: Some(approval_policy), approvals_reviewer, sandbox_policy: Some(sandbox_policy), + permission_profile: None, windows_sandbox_level: None, collaboration_mode, reasoning_summary: summary, @@ -175,6 +176,56 @@ pub(super) async fn user_input_or_turn_inner( environments, ) } + Op::UserInputWithTurnContext { + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + windows_sandbox_level, + model, + effort, + summary, + service_tier, + final_output_json_schema, + items, + responsesapi_client_metadata, + collaboration_mode, + personality, + environments, + } => { + let collaboration_mode = if let Some(collab_mode) = collaboration_mode { + Some(collab_mode) + } else { + let state = sess.state.lock().await; + Some( + state + .session_configuration + .collaboration_mode + .with_updates(model, effort, /*developer_instructions*/ None), + ) + }; + ( + items, + SessionSettingsUpdate { + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + windows_sandbox_level, + collaboration_mode, + reasoning_summary: summary, + service_tier, + final_output_json_schema: Some(final_output_json_schema), + personality, + app_server_client_name: None, + app_server_client_version: None, + }, + responsesapi_client_metadata, + environments, + ) + } Op::UserInput { items, environments, @@ -1062,6 +1113,7 @@ pub(super) async fn submission_loop( approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, windows_sandbox_level, model, effort, @@ -1088,6 +1140,7 @@ pub(super) async fn submission_loop( approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, windows_sandbox_level, collaboration_mode: Some(collaboration_mode), reasoning_summary: summary, @@ -1099,7 +1152,9 @@ pub(super) async fn submission_loop( .await; false } - Op::UserInput { .. } | Op::UserTurn { .. } => { + Op::UserInput { .. } + | Op::UserInputWithTurnContext { .. } + | Op::UserTurn { .. } => { user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 5dc1eadd66..b9c7116973 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -189,7 +189,7 @@ use self::review::spawn_review_thread; use self::session::AppServerClientMetadata; use self::session::Session; use self::session::SessionConfiguration; -use self::session::SessionSettingsUpdate; +pub(crate) use self::session::SessionSettingsUpdate; #[cfg(test)] use self::turn::AssistantMessageStreamParsers; #[cfg(test)] @@ -1308,6 +1308,14 @@ impl Session { Ok(()) } + pub(crate) async fn validate_settings( + &self, + updates: &SessionSettingsUpdate, + ) -> ConstraintResult<()> { + let state = self.state.lock().await; + state.session_configuration.apply(updates).map(|_| ()) + } + pub(crate) async fn set_session_startup_prewarm( &self, startup_prewarm: SessionStartupPrewarmHandle, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 29b6f4e299..c64b1c30ac 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1,4 +1,5 @@ use super::*; +use crate::config::ConstraintError; use tokio::sync::Semaphore; /// Context for an initialized model agent @@ -139,13 +140,6 @@ impl SessionConfiguration { if let Some(approvals_reviewer) = updates.approvals_reviewer { next_configuration.approvals_reviewer = approvals_reviewer; } - let mut sandbox_policy_changed = false; - if let Some(sandbox_policy) = updates.sandbox_policy.clone() { - next_configuration.sandbox_policy.set(sandbox_policy)?; - next_configuration.network_sandbox_policy = - NetworkSandboxPolicy::from(next_configuration.sandbox_policy.get()); - sandbox_policy_changed = true; - } if let Some(windows_sandbox_level) = updates.windows_sandbox_level { next_configuration.windows_sandbox_level = windows_sandbox_level; } @@ -166,13 +160,53 @@ impl SessionConfiguration { let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path(); next_configuration.cwd = absolute_cwd; - if sandbox_policy_changed { + + if let Some(permission_profile) = updates.permission_profile.clone() { + let sandbox_policy = permission_profile + .to_legacy_sandbox_policy(&next_configuration.cwd) + .map_err(|err| ConstraintError::InvalidValue { + field_name: "permission_profile", + candidate: format!("{permission_profile:?}"), + allowed: format!( + "permission profiles that can be represented by the active sandbox constraints: {err}" + ), + requirement_source: codex_config::RequirementSource::Unknown, + })?; + next_configuration.sandbox_policy.set(sandbox_policy)?; + let (mut file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + if file_system_sandbox_policy.glob_scan_max_depth.is_none() { + file_system_sandbox_policy.glob_scan_max_depth = + self.file_system_sandbox_policy.glob_scan_max_depth; + } + for deny_entry in self + .file_system_sandbox_policy + .entries + .iter() + .filter(|entry| { + entry.access == codex_protocol::permissions::FileSystemAccessMode::None + }) + { + if !file_system_sandbox_policy + .entries + .iter() + .any(|entry| entry == deny_entry) + { + file_system_sandbox_policy.entries.push(deny_entry.clone()); + } + } + next_configuration.file_system_sandbox_policy = file_system_sandbox_policy; + next_configuration.network_sandbox_policy = network_sandbox_policy; + } else if let Some(sandbox_policy) = updates.sandbox_policy.clone() { + next_configuration.sandbox_policy.set(sandbox_policy)?; next_configuration.file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries( next_configuration.sandbox_policy.get(), &next_configuration.cwd, &self.file_system_sandbox_policy, ); + next_configuration.network_sandbox_policy = + NetworkSandboxPolicy::from(next_configuration.sandbox_policy.get()); } else if cwd_changed && file_system_policy_matches_legacy { // Preserve richer split policies across cwd-only updates; only // rederive when the session is already using the legacy bridge. @@ -198,6 +232,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) approval_policy: Option, pub(crate) approvals_reviewer: Option, pub(crate) sandbox_policy: Option, + pub(crate) permission_profile: Option, pub(crate) windows_sandbox_level: Option, pub(crate) collaboration_mode: Option, pub(crate) reasoning_summary: Option, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5e50ea5fb1..b88b8a4083 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -108,6 +108,7 @@ use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::W3cTraceContext; use core_test_support::PathBufExt; +use core_test_support::PathExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; @@ -1545,6 +1546,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< approval_policy: Some(AskForApproval::Never), approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -2714,6 +2716,53 @@ async fn session_configuration_apply_preserves_split_file_system_policy_on_cwd_o ); } +#[tokio::test] +async fn session_configuration_apply_permission_profile_preserves_existing_deny_read_entries() { + let mut session_configuration = make_session_configuration_for_tests().await; + let cwd = tempfile::tempdir().expect("create temp dir"); + session_configuration.cwd = cwd.path().abs(); + + let workspace_policy = SandboxPolicy::new_workspace_write_policy(); + session_configuration.sandbox_policy = + codex_config::Constrained::allow_any(workspace_policy.clone()); + let deny_entry = FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }; + let mut existing_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy( + &workspace_policy, + session_configuration.cwd.as_path(), + ); + existing_file_system_policy.glob_scan_max_depth = Some(2); + existing_file_system_policy.entries.push(deny_entry.clone()); + session_configuration.file_system_sandbox_policy = existing_file_system_policy; + + let requested_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy( + &workspace_policy, + session_configuration.cwd.as_path(), + ); + let permission_profile = codex_protocol::models::PermissionProfile::from_runtime_permissions( + &requested_file_system_policy, + NetworkSandboxPolicy::Restricted, + ); + let updated = session_configuration + .apply(&SessionSettingsUpdate { + permission_profile: Some(permission_profile), + ..Default::default() + }) + .expect("permission profile update should succeed"); + + let mut expected_file_system_policy = requested_file_system_policy; + expected_file_system_policy.glob_scan_max_depth = Some(2); + expected_file_system_policy.entries.push(deny_entry); + assert_eq!( + updated.file_system_sandbox_policy, + expected_file_system_policy + ); +} + #[cfg_attr(windows, ignore)] #[tokio::test] async fn new_default_turn_uses_config_aware_skills_for_role_overrides() { @@ -3715,6 +3764,7 @@ fn op_kind_distinguishes_turn_ops() { approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -3736,6 +3786,28 @@ fn op_kind_distinguishes_turn_ops() { .kind(), "user_input" ); + assert_eq!( + Op::UserInputWithTurnContext { + environments: None, + items: vec![], + final_output_json_schema: None, + responsesapi_client_metadata: None, + cwd: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_policy: None, + permission_profile: None, + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + } + .kind(), + "user_input_with_turn_context" + ); } #[tokio::test] diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 3f8d2378ad..3cc8bd8298 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -128,6 +128,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -229,6 +230,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -283,6 +285,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -355,6 +358,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -384,6 +388,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -442,6 +447,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -471,6 +477,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -528,6 +535,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -560,6 +568,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -621,6 +630,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -653,6 +663,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -720,6 +731,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -787,6 +799,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index c37326ffc9..da4c09345c 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -3083,6 +3083,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 58ed42c288..a8a2e44f83 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -2103,6 +2103,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -2218,6 +2219,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: Some(next_model.to_string()), effort: None, diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 48d359529b..1252298738 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -575,6 +575,7 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index f7fe7cc322..9f8835f19f 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -30,6 +30,7 @@ async fn override_turn_context_does_not_persist_when_config_exists() { approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::High)), @@ -68,6 +69,7 @@ async fn override_turn_context_does_not_create_config_file() { approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::Medium)), diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 54c2d1035f..db760d8f89 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -147,6 +147,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: Some(next_model.to_string()), effort: None, @@ -246,6 +247,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: Some(next_model.to_string()), effort: None, @@ -1038,6 +1040,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: Some(smaller_model_slug.to_string()), effort: None, diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index c93294121c..6cbbb7389c 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -458,6 +458,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: Some("gpt-5.2".to_string()), effort: None, diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index 13ace1fe6d..63d2b6ed43 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -118,6 +118,7 @@ async fn override_turn_context_without_user_turn_does_not_record_permissions_upd approval_policy: Some(AskForApproval::Never), approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -160,6 +161,7 @@ async fn override_turn_context_without_user_turn_does_not_record_environment_upd approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -199,6 +201,7 @@ async fn override_turn_context_without_user_turn_does_not_record_collaboration_u approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index fb0acf7519..838a20f00a 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -105,6 +105,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { approval_policy: Some(AskForApproval::Never), approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -237,6 +238,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { approval_policy: Some(AskForApproval::Never), approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -325,6 +327,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { approval_policy: Some(AskForApproval::Never), approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -430,6 +433,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { approval_policy: Some(AskForApproval::Never), approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 7c72478ace..9232dc71cd 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -352,6 +352,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -459,6 +460,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -579,6 +581,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -860,6 +863,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index cee43e9820..834bab73fb 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -444,6 +444,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an approval_policy: Some(AskForApproval::Never), approvals_reviewer: None, sandbox_policy: Some(new_policy.clone()), + permission_profile: None, windows_sandbox_level: None, model: None, effort: Some(Some(ReasoningEffort::High)), @@ -529,6 +530,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul approval_policy: Some(AskForApproval::Never), approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: Some("gpt-5.4".to_string()), effort: Some(Some(ReasoningEffort::Low)), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index e62fa0c323..f2b9d16ba2 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -595,6 +595,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: Some(REMOTE_MODEL_SLUG.to_string()), effort: None, @@ -840,6 +841,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: Some(model.to_string()), effort: None, diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index febeb926b1..ab0fcba9b6 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -428,6 +428,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: Some("gpt-5.4".to_string()), effort: None, diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index faf469c981..c375dbbd0a 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -839,6 +839,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { approval_policy: None, approvals_reviewer: None, sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index ca3b650778..43c5e45250 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -391,6 +391,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result approval_policy: Some(AskForApproval::Never), approvals_reviewer: None, sandbox_mode, + permission_profile: None, cwd: resolved_cwd, model_provider: model_provider.clone(), service_tier: None, @@ -750,6 +751,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { approval_policy: Some(default_approval_policy.into()), approvals_reviewer: None, sandbox_policy: Some(default_sandbox_policy.clone().into()), + permission_profile: None, model: None, service_tier: None, effort: default_effort, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index a97fc70702..551723c773 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -318,6 +318,13 @@ impl PermissionProfile { self.file_system_sandbox_policy() .to_legacy_sandbox_policy(self.network_sandbox_policy(), cwd) } + + pub fn to_runtime_permissions(&self) -> (FileSystemSandboxPolicy, NetworkSandboxPolicy) { + ( + self.file_system_sandbox_policy(), + self.network_sandbox_policy(), + ) + } } impl From for NetworkPermissions { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index dfbe085a8a..9ef9b9f84a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -38,6 +38,7 @@ use crate::message_history::HistoryEntry; use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::MessagePhase; +use crate::models::PermissionProfile; use crate::models::ResponseInputItem; use crate::models::ResponseItem; use crate::models::WebSearchAction; @@ -442,6 +443,79 @@ pub enum Op { responsesapi_client_metadata: Option>, }, + /// Similar to [`Op::UserInput`], but first applies persistent turn-context + /// overrides in the same queued operation. This preserves submission order + /// and prevents the input from starting if the overrides are rejected. + UserInputWithTurnContext { + /// User input items, see `InputItem` + items: Vec, + /// Optional turn-scoped environment selections. + #[serde(default, skip_serializing_if = "Option::is_none")] + environments: Option>, + /// Optional JSON Schema used to constrain the final assistant message for this turn. + #[serde(skip_serializing_if = "Option::is_none")] + final_output_json_schema: Option, + /// Optional turn-scoped Responses API `client_metadata`. + #[serde(default, skip_serializing_if = "Option::is_none")] + responsesapi_client_metadata: Option>, + + /// Updated `cwd` for sandbox/tool calls. + #[serde(skip_serializing_if = "Option::is_none")] + cwd: Option, + + /// Updated command approval policy. + #[serde(skip_serializing_if = "Option::is_none")] + approval_policy: Option, + + /// Updated approval reviewer for future approval prompts. + #[serde(skip_serializing_if = "Option::is_none")] + approvals_reviewer: Option, + + /// Updated sandbox policy for tool calls. + #[serde(skip_serializing_if = "Option::is_none")] + sandbox_policy: Option, + + /// Updated permissions profile for tool calls. + #[serde(skip_serializing_if = "Option::is_none")] + permission_profile: Option, + + /// Updated Windows sandbox mode for tool execution. + #[serde(skip_serializing_if = "Option::is_none")] + windows_sandbox_level: Option, + + /// Updated model slug. When set, the model info is derived + /// automatically. + #[serde(skip_serializing_if = "Option::is_none")] + model: Option, + + /// Updated reasoning effort (honored only for reasoning-capable models). + /// + /// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear + /// the effort, or `None` to leave the existing value unchanged. + #[serde(skip_serializing_if = "Option::is_none")] + effort: Option>, + + /// Updated reasoning summary preference (honored only for reasoning-capable models). + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option, + + /// Updated service tier preference for future turns. + /// + /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the + /// preference, or `None` to leave the existing value unchanged. + #[serde(skip_serializing_if = "Option::is_none")] + service_tier: Option>, + + /// EXPERIMENTAL - set a pre-set collaboration mode. + /// Takes precedence over model, effort, and developer instructions if set. + #[serde(skip_serializing_if = "Option::is_none")] + collaboration_mode: Option, + + /// Updated personality preference. + #[serde(skip_serializing_if = "Option::is_none")] + personality: Option, + }, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a turn of a [`crate::codex_thread::CodexThread`]. UserTurn { @@ -532,6 +606,10 @@ pub enum Op { #[serde(skip_serializing_if = "Option::is_none")] sandbox_policy: Option, + /// Updated permissions profile for tool calls. + #[serde(skip_serializing_if = "Option::is_none")] + permission_profile: Option, + /// Updated Windows sandbox mode for tool execution. #[serde(skip_serializing_if = "Option::is_none")] windows_sandbox_level: Option, @@ -800,6 +878,7 @@ impl Op { Self::RealtimeConversationClose => "realtime_conversation_close", Self::RealtimeConversationListVoices => "realtime_conversation_list_voices", Self::UserInput { .. } => "user_input", + Self::UserInputWithTurnContext { .. } => "user_input_with_turn_context", Self::UserTurn { .. } => "user_turn", Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index c498d3d41d..a1a658a24d 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1651,6 +1651,7 @@ async fn update_feature_flags_enabling_guardian_selects_guardian_approvals() -> approval_policy: Some(guardian_approvals.approval_policy), approvals_reviewer: Some(guardian_approvals.approvals_reviewer), sandbox_policy: Some(guardian_approvals.sandbox_policy.clone()), + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -1742,6 +1743,7 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -1821,6 +1823,7 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review approval_policy: Some(guardian_approvals.approval_policy), approvals_reviewer: Some(guardian_approvals.approvals_reviewer), sandbox_policy: Some(guardian_approvals.sandbox_policy.clone()), + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -1878,6 +1881,7 @@ async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_wit approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -1937,6 +1941,7 @@ async fn update_feature_flags_enabling_guardian_in_profile_sets_profile_auto_rev approval_policy: Some(guardian_approvals.approval_policy), approvals_reviewer: Some(guardian_approvals.approvals_reviewer), sandbox_policy: Some(guardian_approvals.sandbox_policy.clone()), + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -2024,6 +2029,7 @@ guardian_approval = true approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: None, + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 45425c3494..9bd024679a 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -184,6 +184,7 @@ impl AppCommand { approval_policy, approvals_reviewer, sandbox_policy, + permission_profile: None, windows_sandbox_level, model, effort, @@ -317,6 +318,7 @@ impl AppCommand { approval_policy, approvals_reviewer, sandbox_policy, + permission_profile: _, windows_sandbox_level, model, effort, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 26746b8b19..8470c575dd 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -540,6 +540,7 @@ impl AppServerSession { approval_policy: Some(approval_policy.into()), approvals_reviewer: Some(approvals_reviewer.into()), sandbox_policy: Some(sandbox_policy.into()), + permission_profile: None, model: Some(model), service_tier, effort, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index c1e142c4e1..92ca170605 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -632,6 +632,7 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent), sandbox_policy: Some(SandboxPolicy::new_workspace_write_policy()), + permission_profile: None, windows_sandbox_level: None, model: None, effort: None, From 9360f267f329d80fd9e55e75b3110a38e59a8f9b Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 22 Apr 2026 13:36:34 -0700 Subject: [PATCH 03/12] [2/4] Implement executor HTTP request runner (#18582) ### Why Remote streamable HTTP MCP needs the executor to perform ordinary HTTP requests on the executor side. This keeps network placement aligned with `experimental_environment = "remote"` without adding MCP-specific executor APIs. ### What - Add an executor-side `http/request` runner backed by `reqwest`. - Validate request method and URL scheme, preserving the transport boundary at plain HTTP. - Return buffered responses for ordinary calls and emit ordered `http/request/bodyDelta` notifications for streaming responses. - Register the request handler in the exec-server router. - Document the runner entrypoint, conversion helpers, body-stream bridge, notification sender, timeout behavior, and new integration-test helpers. - Add exec-server integration tests with the existing websocket harness and a local TCP HTTP peer for buffered and streamed responses, with comments spelling out what each test proves and its setup/exercise/assert phases. ### Stack 1. #18581 protocol 2. #18582 runner 3. #18583 RMCP client 4. #18584 manager wiring and local/remote coverage ### Verification - `just fmt` - `cargo check -p codex-exec-server -p codex-rmcp-client --tests` - `cargo check -p codex-core --test all` compile-only - `git diff --check` - Online full CI is running from the `full-ci` branch, including the remote Rust test job. Co-authored-by: Codex --------- Co-authored-by: Codex --- codex-rs/Cargo.lock | 2 + codex-rs/exec-server/Cargo.toml | 2 + .../exec-server/src/client/http_client.rs | 342 ++++++++--- codex-rs/exec-server/src/protocol.rs | 60 +- codex-rs/exec-server/src/rpc.rs | 48 +- codex-rs/exec-server/src/server/handler.rs | 99 +++ codex-rs/exec-server/src/server/processor.rs | 4 +- codex-rs/exec-server/src/server/registry.rs | 8 + codex-rs/exec-server/tests/http_client.rs | 141 ++++- codex-rs/exec-server/tests/http_request.rs | 568 ++++++++++++++++++ 10 files changed, 1169 insertions(+), 105 deletions(-) create mode 100644 codex-rs/exec-server/tests/http_request.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c3b607eb66..6bc53a49fb 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2589,6 +2589,7 @@ dependencies = [ "ctor 0.6.3", "futures", "pretty_assertions", + "reqwest", "serde", "serde_json", "serial_test", @@ -2597,6 +2598,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-tungstenite", + "tokio-util", "tracing", "uuid", ] diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 5ca265c6b6..9688063278 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -21,6 +21,7 @@ codex-sandboxing = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-pty = { workspace = true } futures = { workspace = true } +reqwest = { workspace = true, features = ["rustls-tls", "stream"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } @@ -35,6 +36,7 @@ tokio = { workspace = true, features = [ "sync", "time", ] } +tokio-util = { workspace = true, features = ["rt"] } tokio-tungstenite = { workspace = true } tracing = { workspace = true } uuid = { workspace = true, features = ["v4"] } diff --git a/codex-rs/exec-server/src/client/http_client.rs b/codex-rs/exec-server/src/client/http_client.rs index a02a8be9a3..1e91fa4489 100644 --- a/codex-rs/exec-server/src/client/http_client.rs +++ b/codex-rs/exec-server/src/client/http_client.rs @@ -1,7 +1,15 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::Ordering; +use std::time::Duration; +use codex_app_server_protocol::JSONRPCErrorError; +use futures::StreamExt; +use reqwest::Method; +use reqwest::Url; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderName; +use reqwest::header::HeaderValue; use serde_json::Value; use serde_json::from_value; use tokio::runtime::Handle; @@ -12,14 +20,28 @@ use tracing::debug; use super::ExecServerClient; use super::ExecServerError; use super::Inner; +use crate::protocol::HTTP_REQUEST_BODY_DELTA_METHOD; use crate::protocol::HTTP_REQUEST_METHOD; +use crate::protocol::HttpHeader; use crate::protocol::HttpRequestBodyDeltaNotification; use crate::protocol::HttpRequestParams; use crate::protocol::HttpRequestResponse; +use crate::rpc::RpcNotificationSender; +use crate::rpc::internal_error; +use crate::rpc::invalid_params; /// Maximum queued body frames per streamed executor HTTP response. const HTTP_BODY_DELTA_CHANNEL_CAPACITY: usize = 256; +pub(crate) struct ExecutorPendingHttpBodyStream { + pub(crate) request_id: String, + response: reqwest::Response, +} + +pub(crate) struct ExecutorHttpRequestRunner { + client: reqwest::Client, +} + /// Request-scoped stream of body chunks for an executor HTTP response. /// /// The initial `http/request` call returns status and headers. This stream then @@ -35,6 +57,60 @@ pub struct HttpResponseBodyStream { closed: bool, } +impl ExecServerClient { + /// Performs an executor-side HTTP request and buffers the response body. + pub async fn http_request( + &self, + mut params: HttpRequestParams, + ) -> Result { + params.stream_response = false; + self.call(HTTP_REQUEST_METHOD, ¶ms).await + } + + /// Performs an executor-side HTTP request and returns a body stream. + /// + /// The method sets `stream_response` and replaces any caller-supplied + /// `request_id` with a connection-local id, so late deltas from abandoned + /// streams cannot be confused with later requests. + pub async fn http_request_stream( + &self, + mut params: HttpRequestParams, + ) -> Result<(HttpRequestResponse, HttpResponseBodyStream), ExecServerError> { + params.stream_response = true; + let request_id = self.inner.next_http_body_stream_request_id(); + params.request_id = request_id.clone(); + let (tx, rx) = mpsc::channel(HTTP_BODY_DELTA_CHANNEL_CAPACITY); + self.inner + .insert_http_body_stream(request_id.clone(), tx) + .await?; + let mut registration = HttpBodyStreamRegistration { + inner: Arc::clone(&self.inner), + request_id: request_id.clone(), + active: true, + }; + let response = match self.call(HTTP_REQUEST_METHOD, ¶ms).await { + Ok(response) => response, + Err(error) => { + self.inner.remove_http_body_stream(&request_id).await; + registration.active = false; + return Err(error); + } + }; + registration.active = false; + Ok(( + response, + HttpResponseBodyStream { + inner: Arc::clone(&self.inner), + request_id, + next_seq: 1, + rx, + pending_eof: false, + closed: false, + }, + )) + } +} + impl HttpResponseBodyStream { /// Receives the next response-body chunk. /// @@ -109,75 +185,165 @@ impl Drop for HttpResponseBodyStream { } } -/// Active route registration owned while `http_request_stream` awaits headers. -struct HttpBodyStreamRegistration { - inner: Arc, - request_id: String, - active: bool, -} - -impl Drop for HttpBodyStreamRegistration { - /// Removes the route if the stream request future is cancelled before headers return. - fn drop(&mut self) { - if self.active { - spawn_remove_http_body_stream(Arc::clone(&self.inner), self.request_id.clone()); - } - } -} - -impl ExecServerClient { - /// Performs an executor-side HTTP request and buffers the response body. - pub async fn http_request( - &self, - mut params: HttpRequestParams, - ) -> Result { - params.stream_response = false; - params.request_id = None; - self.call(HTTP_REQUEST_METHOD, ¶ms).await - } - - /// Performs an executor-side HTTP request and returns a body stream. - /// - /// The method sets `stream_response` and replaces any caller-supplied - /// `request_id` with a connection-local id, so late deltas from abandoned - /// streams cannot be confused with later requests. - pub async fn http_request_stream( - &self, - mut params: HttpRequestParams, - ) -> Result<(HttpRequestResponse, HttpResponseBodyStream), ExecServerError> { - params.stream_response = true; - let request_id = self.inner.next_http_body_stream_request_id(); - params.request_id = Some(request_id.clone()); - let (tx, rx) = mpsc::channel(HTTP_BODY_DELTA_CHANNEL_CAPACITY); - self.inner - .insert_http_body_stream(request_id.clone(), tx) - .await?; - let mut registration = HttpBodyStreamRegistration { - inner: Arc::clone(&self.inner), - request_id: request_id.clone(), - active: true, - }; - let response = match self.call(HTTP_REQUEST_METHOD, ¶ms).await { - Ok(response) => response, - Err(error) => { - self.inner.remove_http_body_stream(&request_id).await; - registration.active = false; - return Err(error); +impl ExecutorHttpRequestRunner { + pub(crate) fn new(timeout_ms: Option) -> Result { + let client = match timeout_ms { + None => reqwest::Client::builder(), + Some(timeout_ms) => { + reqwest::Client::builder().timeout(Duration::from_millis(timeout_ms)) } - }; - registration.active = false; + } + .build() + .map_err(|err| internal_error(format!("failed to build http/request client: {err}")))?; + Ok(Self { client }) + } + + pub(crate) async fn run( + &self, + params: HttpRequestParams, + ) -> Result<(HttpRequestResponse, Option), JSONRPCErrorError> + { + let method = Method::from_bytes(params.method.as_bytes()) + .map_err(|err| invalid_params(format!("http/request method is invalid: {err}")))?; + let url = Url::parse(¶ms.url) + .map_err(|err| invalid_params(format!("http/request url is invalid: {err}")))?; + match url.scheme() { + "http" | "https" => {} + scheme => { + return Err(invalid_params(format!( + "http/request only supports http and https URLs, got {scheme}" + ))); + } + } + + let headers = Self::build_headers(params.headers)?; + let mut request = self.client.request(method, url).headers(headers); + if let Some(body) = params.body { + request = request.body(body.into_inner()); + } + + let response = request + .send() + .await + .map_err(|err| internal_error(format!("http/request failed: {err}")))?; + let status = response.status().as_u16(); + let headers = Self::response_headers(response.headers()); + + if params.stream_response { + return Ok(( + HttpRequestResponse { + status, + headers, + body: Vec::new().into(), + }, + Some(ExecutorPendingHttpBodyStream { + request_id: params.request_id, + response, + }), + )); + } + + let body = response.bytes().await.map_err(|err| { + internal_error(format!("failed to read http/request response body: {err}")) + })?; + Ok(( - response, - HttpResponseBodyStream { - inner: Arc::clone(&self.inner), - request_id, - next_seq: 1, - rx, - pending_eof: false, - closed: false, + HttpRequestResponse { + status, + headers, + body: body.to_vec().into(), }, + None, )) } + + fn build_headers(headers: Vec) -> Result { + let mut header_map = HeaderMap::new(); + for header in headers { + let name = HeaderName::from_bytes(header.name.as_bytes()).map_err(|err| { + invalid_params(format!("http/request header name is invalid: {err}")) + })?; + let value = HeaderValue::from_str(&header.value).map_err(|err| { + invalid_params(format!( + "http/request header value is invalid for {}: {err}", + header.name + )) + })?; + header_map.append(name, value); + } + Ok(header_map) + } + + fn response_headers(headers: &HeaderMap) -> Vec { + headers + .iter() + .filter_map(|(name, value)| { + Some(HttpHeader { + name: name.as_str().to_string(), + value: value.to_str().ok()?.to_string(), + }) + }) + .collect() + } + + pub(crate) async fn stream_body( + pending_stream: ExecutorPendingHttpBodyStream, + notifications: RpcNotificationSender, + ) { + let ExecutorPendingHttpBodyStream { + request_id, + response, + } = pending_stream; + let mut seq = 1; + let mut body = response.bytes_stream(); + while let Some(chunk) = body.next().await { + match chunk { + Ok(bytes) => { + if !send_executor_body_delta( + ¬ifications, + HttpRequestBodyDeltaNotification { + request_id: request_id.clone(), + seq, + delta: bytes.to_vec().into(), + done: false, + error: None, + }, + ) + .await + { + return; + } + seq += 1; + } + Err(err) => { + let _ = send_executor_body_delta( + ¬ifications, + HttpRequestBodyDeltaNotification { + request_id, + seq, + delta: Vec::new().into(), + done: true, + error: Some(err.to_string()), + }, + ) + .await; + return; + } + } + } + + let _ = send_executor_body_delta( + ¬ifications, + HttpRequestBodyDeltaNotification { + request_id, + seq, + delta: Vec::new().into(), + done: true, + error: None, + }, + ) + .await; + } } impl Inner { @@ -231,13 +397,21 @@ impl Inner { let streams = streams.as_ref().clone(); self.http_body_streams.store(Arc::new(HashMap::new())); for (request_id, tx) in streams { - let _ = tx.try_send(HttpRequestBodyDeltaNotification { - request_id, - seq: 1, - delta: Vec::new().into(), - done: true, - error: Some(message.clone()), - }); + if tx + .try_send(HttpRequestBodyDeltaNotification { + request_id: request_id.clone(), + seq: 1, + delta: Vec::new().into(), + done: true, + error: Some(message.clone()), + }) + .is_err() + { + let mut next_failures = self.http_body_stream_failures.load().as_ref().clone(); + next_failures.insert(request_id, message.clone()); + self.http_body_stream_failures + .store(Arc::new(next_failures)); + } } } @@ -312,6 +486,22 @@ impl Inner { } } +/// Active route registration owned while `http_request_stream` awaits headers. +struct HttpBodyStreamRegistration { + inner: Arc, + request_id: String, + active: bool, +} + +impl Drop for HttpBodyStreamRegistration { + /// Removes the route if the stream request future is cancelled before headers return. + fn drop(&mut self) { + if self.active { + spawn_remove_http_body_stream(Arc::clone(&self.inner), self.request_id.clone()); + } + } +} + /// Schedules HTTP body route removal from synchronous drop paths. fn spawn_remove_http_body_stream(inner: Arc, request_id: String) { if let Ok(handle) = Handle::try_current() { @@ -320,3 +510,13 @@ fn spawn_remove_http_body_stream(inner: Arc, request_id: String) { }); } } + +async fn send_executor_body_delta( + notifications: &RpcNotificationSender, + delta: HttpRequestBodyDeltaNotification, +) -> bool { + notifications + .notify(HTTP_REQUEST_BODY_DELTA_METHOD, &delta) + .await + .is_ok() +} diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 24d1ee1c1e..435187d05a 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -286,15 +286,19 @@ pub struct HttpRequestParams { /// Optional request body bytes. #[serde(default, rename = "bodyBase64")] pub body: Option, - /// Optional request timeout in milliseconds. - #[serde(default)] + /// Request timeout in milliseconds. + /// + /// Omitted or `null` disables the timeout. A number applies that exact + /// millisecond deadline. + #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout_ms: Option, /// Caller-chosen stream id for `http/request/bodyDelta` notifications. /// /// The id must remain unique on a connection until the terminal body delta - /// arrives, even if the caller stops reading the stream earlier. - #[serde(default)] - pub request_id: Option, + /// arrives, even if the caller stops reading the stream earlier. Buffered + /// requests still send an id so callers can keep one consistent request + /// envelope shape. + pub request_id: String, /// Return after response headers and stream the response body as deltas. #[serde(default)] pub stream_response: bool, @@ -391,3 +395,49 @@ mod base64_bytes { .map_err(serde::de::Error::custom) } } + +#[cfg(test)] +mod tests { + use super::HttpRequestParams; + use pretty_assertions::assert_eq; + + #[test] + fn http_request_timeout_treats_omitted_and_null_as_no_timeout() { + let omitted: HttpRequestParams = serde_json::from_value(serde_json::json!({ + "method": "GET", + "url": "https://example.test", + "requestId": "req-omitted-timeout", + })) + .expect("omitted timeout should deserialize"); + let null_timeout: HttpRequestParams = serde_json::from_value(serde_json::json!({ + "method": "GET", + "url": "https://example.test", + "requestId": "req-null-timeout", + "timeoutMs": null, + })) + .expect("null timeout should deserialize"); + let explicit_timeout: HttpRequestParams = serde_json::from_value(serde_json::json!({ + "method": "GET", + "url": "https://example.test", + "requestId": "req-explicit-timeout", + "timeoutMs": 1234, + })) + .expect("numeric timeout should deserialize"); + + assert_eq!( + (omitted.request_id.as_str(), omitted.timeout_ms), + ("req-omitted-timeout", None) + ); + assert_eq!( + (null_timeout.request_id.as_str(), null_timeout.timeout_ms), + ("req-null-timeout", None) + ); + assert_eq!( + ( + explicit_timeout.request_id.as_str(), + explicit_timeout.timeout_ms + ), + ("req-explicit-timeout", Some(1234)) + ); + } +} diff --git a/codex-rs/exec-server/src/rpc.rs b/codex-rs/exec-server/src/rpc.rs index e82b4a0ea2..723b99f502 100644 --- a/codex-rs/exec-server/src/rpc.rs +++ b/codex-rs/exec-server/src/rpc.rs @@ -36,8 +36,9 @@ pub(crate) enum RpcCallError { type PendingRequest = oneshot::Sender>; type BoxFuture = Pin + Send + 'static>>; -type RequestRoute = - Box, JSONRPCRequest) -> BoxFuture + Send + Sync>; +type RequestRoute = Box< + dyn Fn(Arc, JSONRPCRequest) -> BoxFuture> + Send + Sync, +>; type NotificationRoute = Box, JSONRPCNotification) -> BoxFuture> + Send + Sync>; @@ -72,6 +73,17 @@ impl RpcNotificationSender { Self { outgoing_tx } } + pub(crate) async fn response( + &self, + request_id: RequestId, + result: Value, + ) -> Result<(), JSONRPCErrorError> { + self.outgoing_tx + .send(RpcServerOutboundMessage::Response { request_id, result }) + .await + .map_err(|_| internal_error("RPC connection closed while sending response".into())) + } + #[allow(dead_code)] pub(crate) async fn notify( &self, @@ -131,10 +143,10 @@ where let response = match response { Ok(response) => response.await, Err(error) => { - return RpcServerOutboundMessage::Error { request_id, error }; + return Some(RpcServerOutboundMessage::Error { request_id, error }); } }; - match response { + Some(match response { Ok(result) => match serde_json::to_value(result) { Ok(result) => RpcServerOutboundMessage::Response { request_id, result }, Err(err) => RpcServerOutboundMessage::Error { @@ -143,6 +155,34 @@ where }, }, Err(error) => RpcServerOutboundMessage::Error { request_id, error }, + }) + }) + }), + ); + } + + pub(crate) fn request_with_id(&mut self, method: &'static str, handler: F) + where + P: DeserializeOwned + Send + 'static, + F: Fn(Arc, RequestId, P) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + self.request_routes.insert( + method, + Box::new(move |state, request| { + let request_id = request.id; + let params = decode_request_params::

(request.params) + .map(|params| handler(state, request_id.clone(), params)); + Box::pin(async move { + let response = match params { + Ok(response) => response.await, + Err(error) => { + return Some(RpcServerOutboundMessage::Error { request_id, error }); + } + }; + match response { + Ok(()) => None, + Err(error) => Some(RpcServerOutboundMessage::Error { request_id, error }), } }) }), diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index 46f7af90a1..15035335ea 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -4,8 +4,16 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RequestId; +use serde_json::to_value; +use std::collections::HashSet; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; use crate::ExecServerRuntimePaths; +use crate::client::http_client::ExecutorHttpRequestRunner; +use crate::client::http_client::ExecutorPendingHttpBodyStream; use crate::protocol::ExecParams; use crate::protocol::ExecResponse; use crate::protocol::FsCopyParams; @@ -22,6 +30,7 @@ use crate::protocol::FsRemoveParams; use crate::protocol::FsRemoveResponse; use crate::protocol::FsWriteFileParams; use crate::protocol::FsWriteFileResponse; +use crate::protocol::HttpRequestParams; use crate::protocol::InitializeParams; use crate::protocol::InitializeResponse; use crate::protocol::ReadParams; @@ -31,6 +40,8 @@ use crate::protocol::TerminateResponse; use crate::protocol::WriteParams; use crate::protocol::WriteResponse; use crate::rpc::RpcNotificationSender; +use crate::rpc::internal_error; +use crate::rpc::invalid_params; use crate::rpc::invalid_request; use crate::server::file_system_handler::FileSystemHandler; use crate::server::session_registry::SessionHandle; @@ -40,6 +51,9 @@ pub(crate) struct ExecServerHandler { session_registry: Arc, notifications: RpcNotificationSender, session: StdMutex>, + active_body_stream_ids: Mutex>, + background_task_shutdown: CancellationToken, + background_tasks: TaskTracker, file_system: FileSystemHandler, initialize_requested: AtomicBool, initialized: AtomicBool, @@ -55,6 +69,9 @@ impl ExecServerHandler { session_registry, notifications, session: StdMutex::new(None), + active_body_stream_ids: Mutex::new(HashSet::new()), + background_task_shutdown: CancellationToken::new(), + background_tasks: TaskTracker::new(), file_system: FileSystemHandler::new(runtime_paths), initialize_requested: AtomicBool::new(false), initialized: AtomicBool::new(false), @@ -62,6 +79,9 @@ impl ExecServerHandler { } pub(crate) async fn shutdown(&self) { + self.background_task_shutdown.cancel(); + self.background_tasks.close(); + self.background_tasks.wait().await; if let Some(session) = self.session() { session.detach().await; } @@ -147,6 +167,47 @@ impl ExecServerHandler { session.process().terminate(params).await } + pub(crate) async fn http_request( + self: &Arc, + request_id: RequestId, + params: HttpRequestParams, + ) -> Result<(), JSONRPCErrorError> { + self.require_initialized_for("http")?; + let stream_response = params.stream_response; + let http_request_id = params.request_id.clone(); + if stream_response { + self.reserve_http_body_stream(&http_request_id).await?; + } + let response = ExecutorHttpRequestRunner::new(params.timeout_ms)? + .run(params) + .await; + if response.is_err() && stream_response { + self.release_http_body_stream(&http_request_id).await; + } + let (response, mut pending_stream) = response?; + let result = match to_value(response) { + Ok(result) => result, + Err(err) => { + if let Some(pending_stream) = pending_stream.take() { + self.release_http_body_stream(&pending_stream.request_id) + .await; + } + return Err(internal_error(err.to_string())); + } + }; + if let Err(error) = self.notifications.response(request_id, result).await { + if let Some(pending_stream) = pending_stream.take() { + self.release_http_body_stream(&pending_stream.request_id) + .await; + } + return Err(error); + } + if let Some(pending_stream) = pending_stream { + self.start_http_body_stream(pending_stream).await; + } + Ok(()) + } + pub(crate) async fn fs_read_file( &self, params: FsReadFileParams, @@ -242,6 +303,44 @@ impl ExecServerHandler { .unwrap_or_else(std::sync::PoisonError::into_inner) .clone() } + + async fn start_http_body_stream( + self: &Arc, + pending_stream: ExecutorPendingHttpBodyStream, + ) { + let request_id = pending_stream.request_id.clone(); + if self.background_task_shutdown.is_cancelled() { + self.release_http_body_stream(&request_id).await; + return; + } + let finished_request_id = request_id.clone(); + let handler = Arc::clone(self); + let notifications = self.notifications.clone(); + let shutdown = self.background_task_shutdown.clone(); + self.background_tasks.spawn(async move { + tokio::select! { + _ = shutdown.cancelled() => {} + _ = ExecutorHttpRequestRunner::stream_body(pending_stream, notifications) => {} + } + handler.release_http_body_stream(&finished_request_id).await; + }); + } + + async fn release_http_body_stream(&self, request_id: &str) { + let mut active_body_stream_ids = self.active_body_stream_ids.lock().await; + active_body_stream_ids.remove(request_id); + } + + async fn reserve_http_body_stream(&self, request_id: &str) -> Result<(), JSONRPCErrorError> { + let mut active_body_stream_ids = self.active_body_stream_ids.lock().await; + if active_body_stream_ids.contains(request_id) { + return Err(invalid_params(format!( + "http/request streamResponse requestId `{request_id}` is already active" + ))); + } + active_body_stream_ids.insert(request_id.to_string()); + Ok(()) + } } #[cfg(test)] diff --git a/codex-rs/exec-server/src/server/processor.rs b/codex-rs/exec-server/src/server/processor.rs index 1153bc83e2..dc1a9b9ffe 100644 --- a/codex-rs/exec-server/src/server/processor.rs +++ b/codex-rs/exec-server/src/server/processor.rs @@ -103,7 +103,9 @@ async fn run_connection( break; } }; - if outgoing_tx.send(message).await.is_err() { + if let Some(message) = message + && outgoing_tx.send(message).await.is_err() + { break; } } else if outgoing_tx diff --git a/codex-rs/exec-server/src/server/registry.rs b/codex-rs/exec-server/src/server/registry.rs index a57704c505..87dee6aa58 100644 --- a/codex-rs/exec-server/src/server/registry.rs +++ b/codex-rs/exec-server/src/server/registry.rs @@ -19,6 +19,8 @@ use crate::protocol::FsReadDirectoryParams; use crate::protocol::FsReadFileParams; use crate::protocol::FsRemoveParams; use crate::protocol::FsWriteFileParams; +use crate::protocol::HTTP_REQUEST_METHOD; +use crate::protocol::HttpRequestParams; use crate::protocol::INITIALIZE_METHOD; use crate::protocol::INITIALIZED_METHOD; use crate::protocol::InitializeParams; @@ -42,6 +44,12 @@ pub(crate) fn build_router() -> RpcRouter { handler.initialize(params).await }, ); + router.request_with_id( + HTTP_REQUEST_METHOD, + |handler: Arc, request_id, params: HttpRequestParams| async move { + handler.http_request(request_id, params).await + }, + ); router.request( EXEC_METHOD, |handler: Arc, params: ExecParams| async move { handler.exec(params).await }, diff --git a/codex-rs/exec-server/tests/http_client.rs b/codex-rs/exec-server/tests/http_client.rs index 1462c80168..6a087dd115 100644 --- a/codex-rs/exec-server/tests/http_client.rs +++ b/codex-rs/exec-server/tests/http_client.rs @@ -42,6 +42,7 @@ const HTTP_REQUEST_BODY_DELTA_METHOD: &str = "http/request/bodyDelta"; const INITIALIZE_METHOD: &str = "initialize"; const INITIALIZED_METHOD: &str = "initialized"; const TEST_TIMEOUT: Duration = Duration::from_secs(5); +const HTTP_BODY_DELTA_CHANNEL_CAPACITY: u64 = 256; const OVERFLOWING_BODY_DELTA_FRAMES: u64 = 1_024; /// What this tests: the buffered HTTP helper always sends a buffered @@ -51,8 +52,8 @@ async fn http_request_forces_buffered_request_params() -> Result<()> { // Phase 1: start a fake WebSocket exec-server so the test covers the // public client connection path without depending on the HTTP runner. let server = spawn_scripted_exec_server(|mut peer| async move { - // Phase 2: verify the buffered helper strips streaming-only fields - // before it sends the JSON-RPC call. + // Phase 2: verify the buffered helper forces buffered mode before it + // sends the JSON-RPC call. let (request_id, params) = peer.read_http_request().await?; assert_eq!( params, @@ -62,7 +63,7 @@ async fn http_request_forces_buffered_request_params() -> Result<()> { headers: Vec::new(), body: None, timeout_ms: None, - request_id: None, + request_id: "ignored-stream-id".to_string(), stream_response: false, } ); @@ -90,7 +91,7 @@ async fn http_request_forces_buffered_request_params() -> Result<()> { headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("ignored-stream-id".to_string()), + request_id: "ignored-stream-id".to_string(), stream_response: true, }), ) @@ -130,7 +131,7 @@ async fn http_response_body_stream_uses_generated_ids_and_receives_ordered_delta }], body: None, timeout_ms: None, - request_id: Some("http-1".to_string()), + request_id: "http-1".to_string(), stream_response: true, } ); @@ -185,7 +186,7 @@ async fn http_response_body_stream_uses_generated_ids_and_receives_ordered_delta headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("http-2".to_string()), + request_id: "http-2".to_string(), stream_response: true, } ); @@ -214,7 +215,7 @@ async fn http_response_body_stream_uses_generated_ids_and_receives_ordered_delta }], body: None, timeout_ms: None, - request_id: Some("caller-stream-id".to_string()), + request_id: "caller-stream-id".to_string(), stream_response: false, }), ) @@ -252,7 +253,7 @@ async fn http_response_body_stream_uses_generated_ids_and_receives_ordered_delta headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("caller-stream-id".to_string()), + request_id: "caller-stream-id".to_string(), stream_response: false, }), ) @@ -288,7 +289,7 @@ async fn http_response_body_stream_drops_queued_terminal_before_next_generated_i headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("http-1".to_string()), + request_id: "http-1".to_string(), stream_response: true, } ); @@ -321,7 +322,7 @@ async fn http_response_body_stream_drops_queued_terminal_before_next_generated_i headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("http-2".to_string()), + request_id: "http-2".to_string(), stream_response: true, } ); @@ -347,7 +348,7 @@ async fn http_response_body_stream_drops_queued_terminal_before_next_generated_i headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("caller-stream-id".to_string()), + request_id: "caller-stream-id".to_string(), stream_response: false, }), ) @@ -371,7 +372,7 @@ async fn http_response_body_stream_drops_queued_terminal_before_next_generated_i headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("caller-stream-id".to_string()), + request_id: "caller-stream-id".to_string(), stream_response: false, }; let (reuse_response, _reuse_body_stream) = @@ -410,7 +411,7 @@ async fn http_response_body_stream_ignores_late_deltas_after_cancelled_request() headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("http-1".to_string()), + request_id: "http-1".to_string(), stream_response: true, } ); @@ -429,7 +430,7 @@ async fn http_response_body_stream_ignores_late_deltas_after_cancelled_request() headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("http-2".to_string()), + request_id: "http-2".to_string(), stream_response: true, } ); @@ -473,7 +474,7 @@ async fn http_response_body_stream_ignores_late_deltas_after_cancelled_request() headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("caller-stream-id".to_string()), + request_id: "caller-stream-id".to_string(), stream_response: false, }) .await; @@ -494,7 +495,7 @@ async fn http_response_body_stream_ignores_late_deltas_after_cancelled_request() headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("caller-stream-id".to_string()), + request_id: "caller-stream-id".to_string(), stream_response: false, }), ) @@ -540,7 +541,7 @@ async fn http_response_body_stream_ignores_late_deltas_after_drop() -> Result<() headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("http-1".to_string()), + request_id: "http-1".to_string(), stream_response: true, } ); @@ -579,7 +580,7 @@ async fn http_response_body_stream_ignores_late_deltas_after_drop() -> Result<() headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("http-2".to_string()), + request_id: "http-2".to_string(), stream_response: true, } ); @@ -614,7 +615,7 @@ async fn http_response_body_stream_ignores_late_deltas_after_drop() -> Result<() headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("caller-stream-id".to_string()), + request_id: "caller-stream-id".to_string(), stream_response: false, }), ) @@ -646,7 +647,7 @@ async fn http_response_body_stream_ignores_late_deltas_after_drop() -> Result<() headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("caller-stream-id".to_string()), + request_id: "caller-stream-id".to_string(), stream_response: false, }), ) @@ -690,7 +691,7 @@ async fn http_response_body_stream_fails_when_transport_disconnects() -> Result< headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("http-1".to_string()), + request_id: "http-1".to_string(), stream_response: true, } ); @@ -716,7 +717,7 @@ async fn http_response_body_stream_fails_when_transport_disconnects() -> Result< headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("caller-stream-id".to_string()), + request_id: "caller-stream-id".to_string(), stream_response: false, }), ) @@ -742,6 +743,98 @@ async fn http_response_body_stream_fails_when_transport_disconnects() -> Result< Ok(()) } +/// What this tests: transport disconnect still records a terminal stream +/// failure even when the client-side body-delta queue is already full. +#[tokio::test] +async fn http_response_body_stream_reports_disconnect_when_queue_is_full() -> Result<()> { + // Phase 1: fill the queued body-delta route exactly to capacity before the + // response headers arrive, then drop the transport without sending EOF. + let server = spawn_scripted_exec_server(|mut peer| async move { + let (request_id, params) = peer.read_http_request().await?; + assert_eq!( + params, + HttpRequestParams { + method: "GET".to_string(), + url: "https://example.test/mcp/disconnect-full-queue".to_string(), + headers: Vec::new(), + body: None, + timeout_ms: None, + request_id: "http-1".to_string(), + stream_response: true, + } + ); + for seq in 1..=HTTP_BODY_DELTA_CHANNEL_CAPACITY { + peer.write_body_delta(HttpRequestBodyDeltaNotification { + request_id: "http-1".to_string(), + seq, + delta: b"x".to_vec().into(), + done: false, + error: None, + }) + .await?; + } + peer.write_response( + request_id, + HttpRequestResponse { + status: 200, + headers: Vec::new(), + body: Vec::new().into(), + }, + ) + .await + }) + .await?; + let client = server.connect_client().await?; + + // Phase 2: start the streaming request and receive headers while the + // queue is already full. + let (_response, mut body_stream) = timeout( + TEST_TIMEOUT, + client.http_request_stream(HttpRequestParams { + method: "GET".to_string(), + url: "https://example.test/mcp/disconnect-full-queue".to_string(), + headers: Vec::new(), + body: None, + timeout_ms: None, + request_id: "caller-stream-id".to_string(), + stream_response: false, + }), + ) + .await + .context("streamed http/request should return headers")??; + + // Phase 3: drain the queued chunks and assert the transport disconnect is + // still reported as an error rather than a clean EOF. + let mut chunks = 0; + let error = loop { + match timeout(TEST_TIMEOUT, body_stream.recv()) + .await + .context("disconnect should wake the full queued body stream")? + { + Ok(Some(_chunk)) => { + chunks += 1; + } + Ok(None) => bail!("disconnect with a full queue should not look like clean EOF"), + Err(error) => break error, + } + }; + assert_eq!( + ( + chunks, + error + .to_string() + .starts_with( + "exec-server protocol error: http response stream `http-1` failed: exec-server transport disconnected", + ), + ), + (HTTP_BODY_DELTA_CHANNEL_CAPACITY as usize, true) + ); + + drop(client); + server.finish().await?; + Ok(()) +} + /// What this tests: body-delta backpressure closes the public body stream as /// an error rather than letting callers accept a truncated body as clean EOF. #[tokio::test] @@ -759,7 +852,7 @@ async fn http_response_body_stream_reports_backpressure_truncation() -> Result<( headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("http-1".to_string()), + request_id: "http-1".to_string(), stream_response: true, } ); @@ -801,7 +894,7 @@ async fn http_response_body_stream_reports_backpressure_truncation() -> Result<( headers: Vec::new(), body: None, timeout_ms: None, - request_id: Some("caller-stream-id".to_string()), + request_id: "caller-stream-id".to_string(), stream_response: false, }), ) diff --git a/codex-rs/exec-server/tests/http_request.rs b/codex-rs/exec-server/tests/http_request.rs new file mode 100644 index 0000000000..f45261dad7 --- /dev/null +++ b/codex-rs/exec-server/tests/http_request.rs @@ -0,0 +1,568 @@ +#![cfg(unix)] + +mod common; + +use std::collections::BTreeMap; +use std::io::ErrorKind; +use std::time::Duration; + +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_exec_server::HttpHeader; +use codex_exec_server::HttpRequestBodyDeltaNotification; +use codex_exec_server::HttpRequestParams; +use codex_exec_server::HttpRequestResponse; +use codex_exec_server::InitializeParams; +use common::exec_server::ExecServerHarness; +use common::exec_server::exec_server; +use pretty_assertions::assert_eq; +use serde::de::DeserializeOwned; +use serde_json::Value; +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::BufReader; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio::sync::oneshot; +use tokio::time::timeout; + +/// HTTP request captured by the ad-hoc TCP server in these integration tests. +#[derive(Debug)] +struct CapturedHttpRequest { + stream: TcpStream, + request_line: String, + headers: BTreeMap, + body: Vec, +} + +/// What this tests: a real exec-server websocket `http/request` performs one +/// HTTP request through the runner and returns the complete response body in +/// the JSON-RPC response. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_server_http_request_buffers_response_body() -> anyhow::Result<()> { + // Phase 1: start exec-server and complete the JSON-RPC handshake. + let mut server = exec_server().await?; + initialize_exec_server(&mut server).await?; + + // Phase 2: start a local HTTP peer and ask exec-server to POST to it. + let listener = TcpListener::bind("127.0.0.1:0").await?; + let url = format!("http://{}/mcp?case=buffered", listener.local_addr()?); + let http_request_id = server + .send_request( + "http/request", + serde_json::to_value(HttpRequestParams { + method: "POST".to_string(), + url, + headers: vec![HttpHeader { + name: "x-codex-test".to_string(), + value: "buffered".to_string(), + }], + body: Some(b"request-body".to_vec().into()), + timeout_ms: Some(5_000), + request_id: "buffered-request".to_string(), + stream_response: false, + })?, + ) + .await?; + + // Phase 3: assert the HTTP peer observes the expected method, path, + // headers, and body before returning a fixed-length response. + let captured = accept_http_request(&listener).await?; + assert_eq!( + ( + captured.request_line.as_str(), + captured.headers.get("x-codex-test").map(String::as_str), + captured.body.as_slice(), + ), + ( + "POST /mcp?case=buffered HTTP/1.1", + Some("buffered"), + b"request-body".as_slice(), + ) + ); + respond_with_status_and_headers( + captured.stream, + "201 Created", + &[("x-mcp-test", "buffered")], + b"response-body", + ) + .await?; + + // Phase 4: assert exec-server returns status, response headers, and the + // full response body in the JSON-RPC result. + let response: HttpRequestResponse = wait_for_response(&mut server, http_request_id).await?; + assert_eq!( + ( + response.status, + response_header(&response.headers, "x-mcp-test"), + response.body.into_inner(), + ), + (201, Some("buffered".to_string()), b"response-body".to_vec(),) + ); + + server.shutdown().await?; + Ok(()) +} + +/// What this tests: a real exec-server websocket `http/request` can return +/// response headers immediately and stream the response body as ordered +/// `http/request/bodyDelta` notifications. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_server_http_request_streams_response_body_notifications() -> anyhow::Result<()> { + // Phase 1: start exec-server and complete the JSON-RPC handshake. + let mut server = exec_server().await?; + initialize_exec_server(&mut server).await?; + + // Phase 2: start a local HTTP peer and ask exec-server for a streamed GET. + let listener = TcpListener::bind("127.0.0.1:0").await?; + let url = format!("http://{}/mcp?case=streaming", listener.local_addr()?); + let http_request_id = server + .send_request( + "http/request", + serde_json::to_value(HttpRequestParams { + method: "GET".to_string(), + url, + headers: vec![HttpHeader { + name: "accept".to_string(), + value: "text/event-stream".to_string(), + }], + body: None, + timeout_ms: Some(5_000), + request_id: "stream-1".to_string(), + stream_response: true, + })?, + ) + .await?; + + // Phase 3: assert the HTTP peer observes the expected request and then + // respond with chunked transfer encoding to exercise streaming. + let captured = accept_http_request(&listener).await?; + assert_eq!( + ( + captured.request_line.as_str(), + captured.headers.get("accept").map(String::as_str), + captured.body, + ), + ( + "GET /mcp?case=streaming HTTP/1.1", + Some("text/event-stream"), + Vec::new(), + ) + ); + respond_with_chunked_body( + captured.stream, + &[("x-mcp-test", "streaming")], + &[b"hello ".as_slice(), b"world".as_slice()], + ) + .await?; + + // Phase 4: assert the JSON-RPC response reaches the wire before any body + // delta notifications, and that it contains status and headers but no + // buffered body when streaming is requested. + let first_event = server.next_event().await?; + let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = first_event else { + anyhow::bail!("expected http/request response before body deltas, got {first_event:?}"); + }; + assert_eq!(id, http_request_id); + let response: HttpRequestResponse = serde_json::from_value(result)?; + assert_eq!( + ( + response.status, + response_header(&response.headers, "x-mcp-test"), + response.body.into_inner(), + ), + (200, Some("streaming".to_string()), Vec::new()) + ); + + // Phase 5: assert the body notifications are contiguous, ordered, and end + // with a clean terminal frame. + let deltas = collect_response_body_deltas(&mut server, "stream-1").await?; + let seqs = deltas.iter().map(|delta| delta.seq).collect::>(); + let body = deltas + .iter() + .flat_map(|delta| delta.delta.clone().into_inner()) + .collect::>(); + let terminal = deltas.last().map(|delta| (delta.done, delta.error.clone())); + let expected_seqs = (1..=deltas.len() as u64).collect::>(); + assert_eq!( + (seqs, body, terminal), + (expected_seqs, b"hello world".to_vec(), Some((true, None))) + ); + + server.shutdown().await?; + Ok(()) +} + +/// What this tests: streamed `requestId`s stay reserved until the body stream +/// finishes, so a second in-flight request cannot reuse the same id. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_server_http_request_rejects_duplicate_stream_request_ids() -> anyhow::Result<()> { + let mut server = exec_server().await?; + initialize_exec_server(&mut server).await?; + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let url = format!( + "http://{}/mcp?case=duplicate-stream-id", + listener.local_addr()? + ); + let first_request_id = server + .send_request( + "http/request", + serde_json::to_value(HttpRequestParams { + method: "GET".to_string(), + url: url.clone(), + headers: Vec::new(), + body: None, + timeout_ms: None, + request_id: "stream-dup".to_string(), + stream_response: true, + })?, + ) + .await?; + + let captured = accept_http_request(&listener).await?; + let (finish_tx, finish_rx) = oneshot::channel(); + let response_task = tokio::spawn(async move { + respond_with_chunked_body_until_finish(captured.stream, &[], &[b"hello"], finish_rx).await + }); + + let _: HttpRequestResponse = wait_for_response(&mut server, first_request_id).await?; + + let duplicate_request_id = server + .send_request( + "http/request", + serde_json::to_value(HttpRequestParams { + method: "GET".to_string(), + url, + headers: Vec::new(), + body: None, + timeout_ms: None, + request_id: "stream-dup".to_string(), + stream_response: true, + })?, + ) + .await?; + + let duplicate_response = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Error(JSONRPCError { id, .. }) if id == &duplicate_request_id + ) + }) + .await?; + let JSONRPCMessage::Error(JSONRPCError { error, .. }) = duplicate_response else { + anyhow::bail!("expected duplicate requestId error response"); + }; + assert_eq!(error.code, -32602); + assert_eq!( + error.message, + "http/request streamResponse requestId `stream-dup` is already active" + ); + + finish_tx + .send(()) + .expect("response task should still be waiting"); + response_task.await??; + let _ = collect_response_body_deltas(&mut server, "stream-dup").await?; + + server.shutdown().await?; + Ok(()) +} + +/// What this tests: omitting `timeoutMs` leaves the request unbounded, while +/// an explicit short timeout still fails the same delayed response. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_server_http_request_honors_optional_timeout() -> anyhow::Result<()> { + let mut server = exec_server().await?; + initialize_exec_server(&mut server).await?; + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let delayed_url = format!( + "http://{}/mcp?case=optional-timeout", + listener.local_addr()? + ); + let no_timeout_request_id = server + .send_request( + "http/request", + serde_json::to_value(HttpRequestParams { + method: "GET".to_string(), + url: delayed_url.clone(), + headers: Vec::new(), + body: None, + timeout_ms: None, + request_id: "buffered-request".to_string(), + stream_response: false, + })?, + ) + .await?; + + let captured = accept_http_request(&listener).await?; + let delayed_response = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + respond_with_status_and_headers(captured.stream, "200 OK", &[], b"slow-success").await + }); + let response: HttpRequestResponse = + wait_for_response(&mut server, no_timeout_request_id).await?; + assert_eq!(response.body.into_inner(), b"slow-success".to_vec()); + delayed_response.await??; + + let timeout_request_id = server + .send_request( + "http/request", + serde_json::to_value(HttpRequestParams { + method: "GET".to_string(), + url: delayed_url, + headers: Vec::new(), + body: None, + timeout_ms: Some(10), + request_id: "buffered-request".to_string(), + stream_response: false, + })?, + ) + .await?; + + let captured = accept_http_request(&listener).await?; + let delayed_timeout_response = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + respond_with_status_and_headers(captured.stream, "200 OK", &[], b"too-late").await + }); + let error = wait_for_error_response(&mut server, timeout_request_id).await?; + assert_eq!(error.code, -32603); + assert!( + error.message.starts_with("http/request failed: "), + "unexpected timeout error: {}", + error.message + ); + match delayed_timeout_response.await? { + Ok(()) => {} + Err(err) if is_expected_peer_disconnect(&err) => {} + Err(err) => return Err(err), + } + + server.shutdown().await?; + Ok(()) +} + +/// Performs the JSON-RPC initialize handshake required before executor methods. +async fn initialize_exec_server(server: &mut ExecServerHarness) -> anyhow::Result<()> { + let initialize_id = server + .send_request( + "initialize", + serde_json::to_value(InitializeParams { + client_name: "exec-server-http-test".to_string(), + resume_session_id: None, + })?, + ) + .await?; + let _: Value = wait_for_response(server, initialize_id).await?; + server + .send_notification("initialized", serde_json::json!({})) + .await?; + Ok(()) +} + +/// Waits for a typed JSON-RPC response with the requested id. +async fn wait_for_response( + server: &mut ExecServerHarness, + request_id: RequestId, +) -> anyhow::Result +where + T: DeserializeOwned, +{ + let response = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &request_id + ) + }) + .await?; + let JSONRPCMessage::Response(JSONRPCResponse { result, .. }) = response else { + anyhow::bail!("expected JSON-RPC response for {request_id:?}"); + }; + Ok(serde_json::from_value(result)?) +} + +/// Waits for a JSON-RPC error with the requested id. +async fn wait_for_error_response( + server: &mut ExecServerHarness, + request_id: RequestId, +) -> anyhow::Result { + let response = server + .wait_for_event(|event| { + matches!( + event, + JSONRPCMessage::Error(JSONRPCError { id, .. }) if id == &request_id + ) + }) + .await?; + let JSONRPCMessage::Error(JSONRPCError { error, .. }) = response else { + anyhow::bail!("expected JSON-RPC error for {request_id:?}"); + }; + Ok(error) +} + +/// Accepts one HTTP/1.1 request and captures its wire-visible fields. +async fn accept_http_request(listener: &TcpListener) -> anyhow::Result { + let (stream, _) = timeout(Duration::from_secs(5), listener.accept()).await??; + let mut reader = BufReader::new(stream); + + let mut request_line = String::new(); + reader.read_line(&mut request_line).await?; + let request_line = request_line.trim_end_matches("\r\n").to_string(); + + let mut headers = BTreeMap::new(); + loop { + let mut line = String::new(); + reader.read_line(&mut line).await?; + if line == "\r\n" { + break; + } + let line = line.trim_end_matches("\r\n"); + let (name, value) = line + .split_once(':') + .ok_or_else(|| anyhow::anyhow!("HTTP header should contain colon: {line}"))?; + headers.insert(name.to_ascii_lowercase(), value.trim().to_string()); + } + + let content_length = headers + .get("content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let mut body = vec![0; content_length]; + reader.read_exact(&mut body).await?; + + Ok(CapturedHttpRequest { + stream: reader.into_inner(), + request_line, + headers, + body, + }) +} + +/// Writes a fixed-length HTTP response to the captured request stream. +async fn respond_with_status_and_headers( + mut stream: TcpStream, + status: &str, + headers: &[(&str, &str)], + body: &[u8], +) -> anyhow::Result<()> { + let extra_headers = headers + .iter() + .map(|(name, value)| format!("{name}: {value}\r\n")) + .collect::(); + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n{extra_headers}\r\n", + body.len(), + ); + stream.write_all(response.as_bytes()).await?; + stream.write_all(body).await?; + stream.flush().await?; + Ok(()) +} + +fn is_expected_peer_disconnect(err: &anyhow::Error) -> bool { + err.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|io_err| { + matches!( + io_err.kind(), + ErrorKind::BrokenPipe | ErrorKind::ConnectionReset | ErrorKind::UnexpectedEof + ) + }) + }) +} + +/// Writes a chunked HTTP response so reqwest must drive the streaming path. +async fn respond_with_chunked_body( + mut stream: TcpStream, + headers: &[(&str, &str)], + chunks: &[&[u8]], +) -> anyhow::Result<()> { + let extra_headers = headers + .iter() + .map(|(name, value)| format!("{name}: {value}\r\n")) + .collect::(); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ntransfer-encoding: chunked\r\nconnection: close\r\n{extra_headers}\r\n", + ); + stream.write_all(response.as_bytes()).await?; + for chunk in chunks { + stream + .write_all(format!("{:x}\r\n", chunk.len()).as_bytes()) + .await?; + stream.write_all(chunk).await?; + stream.write_all(b"\r\n").await?; + stream.flush().await?; + } + stream.write_all(b"0\r\n\r\n").await?; + stream.flush().await?; + Ok(()) +} + +/// Writes a chunked response and keeps the stream open until the test allows EOF. +async fn respond_with_chunked_body_until_finish( + mut stream: TcpStream, + headers: &[(&str, &str)], + chunks: &[&[u8]], + finish_rx: oneshot::Receiver<()>, +) -> anyhow::Result<()> { + let extra_headers = headers + .iter() + .map(|(name, value)| format!("{name}: {value}\r\n")) + .collect::(); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ntransfer-encoding: chunked\r\nconnection: close\r\n{extra_headers}\r\n", + ); + stream.write_all(response.as_bytes()).await?; + for chunk in chunks { + stream + .write_all(format!("{:x}\r\n", chunk.len()).as_bytes()) + .await?; + stream.write_all(chunk).await?; + stream.write_all(b"\r\n").await?; + stream.flush().await?; + } + finish_rx.await?; + stream.write_all(b"0\r\n\r\n").await?; + stream.flush().await?; + Ok(()) +} + +/// Collects streamed response-body notifications until the terminal frame. +async fn collect_response_body_deltas( + server: &mut ExecServerHarness, + request_id: &str, +) -> anyhow::Result> { + let mut deltas = Vec::new(); + loop { + let event = server.next_event().await?; + let JSONRPCMessage::Notification(JSONRPCNotification { method, params }) = event else { + anyhow::bail!("expected http/request body delta notification, got {event:?}"); + }; + assert_eq!(method, "http/request/bodyDelta"); + let delta: HttpRequestBodyDeltaNotification = + serde_json::from_value(params.unwrap_or(Value::Null))?; + assert_eq!(delta.request_id, request_id); + + let done = delta.done; + deltas.push(delta); + if done { + return Ok(deltas); + } + } +} + +/// Returns a response header value without depending on header-name casing. +fn response_header(headers: &[HttpHeader], name: &str) -> Option { + headers + .iter() + .find(|header| header.name.eq_ignore_ascii_case(name)) + .map(|header| header.value.clone()) +} From 28dfb16e58c0e509da4883f9da63b752dffa3962 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 13:56:36 -0700 Subject: [PATCH 04/12] clients: send permission profiles to app-server --- codex-rs/exec/src/lib.rs | 37 ++++++++- codex-rs/exec/src/lib_tests.rs | 5 ++ codex-rs/tui/src/app/tests.rs | 24 ++++++ codex-rs/tui/src/app/thread_events.rs | 5 ++ codex-rs/tui/src/app/thread_session_state.rs | 13 ++- codex-rs/tui/src/app_server_session.rs | 85 +++++++++++++++++++- 6 files changed, 161 insertions(+), 8 deletions(-) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 43c5e45250..c8059ad5fe 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -738,6 +738,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { items, output_schema, } => { + let permission_profile = permission_profile_override_from_config(&config); + let sandbox_policy = permission_profile + .is_none() + .then(|| default_sandbox_policy.clone().into()); let response: TurnStartResponse = send_request_with_response( &client, ClientRequest::TurnStart { @@ -750,8 +754,8 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { cwd: Some(default_cwd), approval_policy: Some(default_approval_policy.into()), approvals_reviewer: None, - sandbox_policy: Some(default_sandbox_policy.clone().into()), - permission_profile: None, + sandbox_policy, + permission_profile, model: None, service_tier: None, effort: default_effort, @@ -925,13 +929,19 @@ fn sandbox_mode_from_policy( } fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { + let permission_profile = permission_profile_override_from_config(config); + let sandbox = permission_profile + .is_none() + .then(|| sandbox_mode_from_policy(config.permissions.sandbox_policy.get())) + .flatten(); ThreadStartParams { model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), - sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get()), + sandbox, + permission_profile, config: config_request_overrides_from_config(config), ephemeral: Some(config.ephemeral), ..ThreadStartParams::default() @@ -939,6 +949,11 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { } fn thread_resume_params_from_config(config: &Config, thread_id: String) -> ThreadResumeParams { + let permission_profile = permission_profile_override_from_config(config); + let sandbox = permission_profile + .is_none() + .then(|| sandbox_mode_from_policy(config.permissions.sandbox_policy.get())) + .flatten(); ThreadResumeParams { thread_id, model: config.model.clone(), @@ -946,12 +961,26 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa cwd: Some(config.cwd.to_string_lossy().to_string()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), - sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get()), + sandbox, + permission_profile, config: config_request_overrides_from_config(config), ..ThreadResumeParams::default() } } +fn permission_profile_override_from_config( + config: &Config, +) -> Option { + if matches!( + config.permissions.sandbox_policy.get(), + SandboxPolicy::ExternalSandbox { .. } + ) { + None + } else { + Some(config.permissions.permission_profile().into()) + } +} + fn config_request_overrides_from_config(config: &Config) -> Option> { config .active_profile diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 708cc9d0db..50fe8552ba 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -361,6 +361,11 @@ async fn thread_start_params_include_review_policy_when_review_policy_is_manual_ params.approvals_reviewer, Some(codex_app_server_protocol::ApprovalsReviewer::User) ); + assert_eq!(params.sandbox, None); + assert_eq!( + params.permission_profile, + Some(config.permissions.permission_profile().into()) + ); } #[tokio::test] diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index a1a658a24d..47fe0f9272 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2215,6 +2215,10 @@ async fn inactive_thread_approval_bubbles_into_active_view() -> Result<()> { ThreadSessionState { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy(), + std::path::Path::new("/tmp/agent"), + )), rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")), ..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent")) }, @@ -2374,6 +2378,10 @@ async fn side_defers_subagent_approval_overlay_until_side_exits() -> Result<()> ThreadSessionState { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy(), + std::path::Path::new("/tmp/agent"), + )), rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")), ..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent")) }, @@ -2596,6 +2604,10 @@ async fn inactive_thread_approval_badge_clears_after_turn_completion_notificatio ThreadSessionState { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy(), + std::path::Path::new("/tmp/agent"), + )), rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")), ..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent")) }, @@ -2649,6 +2661,10 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re let primary_session = ThreadSessionState { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy(), + std::path::Path::new("/tmp/main"), + )), ..test_thread_session(main_thread_id, test_path_buf("/tmp/main")) }; @@ -2760,6 +2776,10 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ let primary_session = ThreadSessionState { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy(), + std::path::Path::new("/tmp/main"), + )), ..test_thread_session(main_thread_id, test_path_buf("/tmp/main")) }; @@ -3675,6 +3695,10 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + cwd.as_path(), + )), cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 89cef2332c..10415c9f46 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -285,6 +285,7 @@ mod tests { use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStartedNotification; use codex_config::types::ApprovalsReviewer; + use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; @@ -302,6 +303,10 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + cwd.as_path(), + )), cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index a8fd34769e..ac992a446c 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -3,6 +3,7 @@ use crate::app_server_session::ThreadSessionState; use crate::read_session_model; use codex_app_server_protocol::Thread; use codex_protocol::ThreadId; +use codex_protocol::protocol::SandboxPolicy; impl App { pub(super) async fn session_state_for_thread_read( @@ -10,6 +11,15 @@ impl App { thread_id: ThreadId, thread: &Thread, ) -> ThreadSessionState { + let sandbox_policy = self.config.permissions.sandbox_policy.get().clone(); + let permission_profile = match &sandbox_policy { + SandboxPolicy::ExternalSandbox { .. } => None, + SandboxPolicy::DangerFullAccess + | SandboxPolicy::ReadOnly { .. } + | SandboxPolicy::WorkspaceWrite { .. } => { + Some(self.config.permissions.permission_profile()) + } + }; let mut session = self .primary_session_configured .clone() @@ -23,7 +33,8 @@ impl App { service_tier: self.chat_widget.current_service_tier(), approval_policy: self.config.permissions.approval_policy.value(), approvals_reviewer: self.config.approvals_reviewer, - sandbox_policy: self.config.permissions.sandbox_policy.get().clone(), + sandbox_policy, + permission_profile, cwd: thread.cwd.clone(), instruction_source_paths: Vec::new(), reasoning_effort: self.chat_widget.current_reasoning_effort(), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 8470c575dd..81e11c9637 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -85,6 +85,7 @@ use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; +use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::openai_models::ModelPreset; @@ -149,6 +150,7 @@ pub(crate) struct ThreadSessionState { pub(crate) approval_policy: AskForApproval, pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, pub(crate) sandbox_policy: SandboxPolicy, + pub(crate) permission_profile: Option, pub(crate) cwd: AbsolutePathBuf, pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, @@ -539,6 +541,9 @@ impl AppServerSession { cwd: Some(cwd), approval_policy: Some(approval_policy.into()), approvals_reviewer: Some(approvals_reviewer.into()), + // This path only receives the legacy projection today; do + // not synthesize a lossy profile until the active profile + // is plumbed through user turns. sandbox_policy: Some(sandbox_policy.into()), permission_profile: None, model: Some(model), @@ -1029,19 +1034,43 @@ fn sandbox_mode_from_policy( } } +fn permission_profile_override_from_config( + config: &Config, + thread_params_mode: ThreadParamsMode, +) -> Option { + if matches!(thread_params_mode, ThreadParamsMode::Remote) { + return None; + } + + if matches!( + config.permissions.sandbox_policy.get(), + SandboxPolicy::ExternalSandbox { .. } + ) { + None + } else { + Some(config.permissions.permission_profile().into()) + } +} + fn thread_start_params_from_config( config: &Config, thread_params_mode: ThreadParamsMode, remote_cwd_override: Option<&std::path::Path>, session_start_source: Option, ) -> ThreadStartParams { + let permission_profile = permission_profile_override_from_config(config, thread_params_mode); + let sandbox = permission_profile + .is_none() + .then(|| sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone())) + .flatten(); ThreadStartParams { model: config.model.clone(), model_provider: thread_params_mode.model_provider_from_config(config), cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), - sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()), + sandbox, + permission_profile, config: config_request_overrides_from_config(config), ephemeral: Some(config.ephemeral), session_start_source, @@ -1056,6 +1085,11 @@ fn thread_resume_params_from_config( thread_params_mode: ThreadParamsMode, remote_cwd_override: Option<&std::path::Path>, ) -> ThreadResumeParams { + let permission_profile = permission_profile_override_from_config(&config, thread_params_mode); + let sandbox = permission_profile + .is_none() + .then(|| sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone())) + .flatten(); ThreadResumeParams { thread_id: thread_id.to_string(), model: config.model.clone(), @@ -1063,7 +1097,8 @@ fn thread_resume_params_from_config( cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), - sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()), + sandbox, + permission_profile, config: config_request_overrides_from_config(&config), persist_extended_history: true, ..ThreadResumeParams::default() @@ -1076,6 +1111,11 @@ fn thread_fork_params_from_config( thread_params_mode: ThreadParamsMode, remote_cwd_override: Option<&std::path::Path>, ) -> ThreadForkParams { + let permission_profile = permission_profile_override_from_config(&config, thread_params_mode); + let sandbox = permission_profile + .is_none() + .then(|| sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone())) + .flatten(); ThreadForkParams { thread_id: thread_id.to_string(), model: config.model.clone(), @@ -1083,7 +1123,8 @@ fn thread_fork_params_from_config( cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), - sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()), + sandbox, + permission_profile, config: config_request_overrides_from_config(&config), base_instructions: config.base_instructions.clone(), developer_instructions: config.developer_instructions.clone(), @@ -1160,6 +1201,7 @@ async fn thread_session_state_from_thread_start_response( response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response.sandbox.to_core(), + response.permission_profile.clone().map(Into::into), response.cwd.clone(), response.instruction_sources.clone(), response.reasoning_effort, @@ -1183,6 +1225,7 @@ async fn thread_session_state_from_thread_resume_response( response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response.sandbox.to_core(), + response.permission_profile.clone().map(Into::into), response.cwd.clone(), response.instruction_sources.clone(), response.reasoning_effort, @@ -1206,6 +1249,7 @@ async fn thread_session_state_from_thread_fork_response( response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response.sandbox.to_core(), + response.permission_profile.clone().map(Into::into), response.cwd.clone(), response.instruction_sources.clone(), response.reasoning_effort, @@ -1248,6 +1292,7 @@ async fn thread_session_state_from_thread_response( approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, sandbox_policy: SandboxPolicy, + permission_profile: Option, cwd: AbsolutePathBuf, instruction_source_paths: Vec, reasoning_effort: Option, @@ -1274,6 +1319,7 @@ async fn thread_session_state_from_thread_response( approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, cwd, instruction_source_paths, reasoning_effort, @@ -1366,6 +1412,11 @@ mod tests { ); assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string())); + assert_eq!(params.sandbox, None); + assert_eq!( + params.permission_profile, + Some(config.permissions.permission_profile().into()) + ); assert_eq!(params.model_provider, Some(config.model_provider_id)); } @@ -1389,6 +1440,8 @@ mod tests { let temp_dir = tempfile::tempdir().expect("tempdir"); let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); + let expected_sandbox = + sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()); let start = thread_start_params_from_config( &config, @@ -1415,6 +1468,12 @@ mod tests { assert_eq!(start.model_provider, None); assert_eq!(resume.model_provider, None); assert_eq!(fork.model_provider, None); + assert_eq!(start.sandbox, expected_sandbox); + assert_eq!(resume.sandbox, expected_sandbox); + assert_eq!(fork.sandbox, expected_sandbox); + assert_eq!(start.permission_profile, None); + assert_eq!(resume.permission_profile, None); + assert_eq!(fork.permission_profile, None); } #[tokio::test] @@ -1423,6 +1482,8 @@ mod tests { let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); let remote_cwd = PathBuf::from("repo/on/server"); + let expected_sandbox = + sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()); let start = thread_start_params_from_config( &config, @@ -1449,6 +1510,12 @@ mod tests { assert_eq!(start.model_provider, None); assert_eq!(resume.model_provider, None); assert_eq!(fork.model_provider, None); + assert_eq!(start.sandbox, expected_sandbox); + assert_eq!(resume.sandbox, expected_sandbox); + assert_eq!(fork.sandbox, expected_sandbox); + assert_eq!(start.permission_profile, None); + assert_eq!(resume.permission_profile, None); + assert_eq!(fork.permission_profile, None); } #[tokio::test] @@ -1547,6 +1614,10 @@ mod tests { started.session.instruction_source_paths, response.instruction_sources ); + assert_eq!( + started.session.permission_profile, + response.permission_profile.clone().map(Into::into) + ); assert_eq!(started.turns.len(), 1); assert_eq!(started.turns[0], response.thread.turns[0]); } @@ -1575,6 +1646,10 @@ mod tests { AskForApproval::Never, codex_protocol::config_types::ApprovalsReviewer::User, SandboxPolicy::new_read_only_policy(), + Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + std::path::Path::new("/tmp/project"), + )), test_path_buf("/tmp/project").abs(), Vec::new(), /*reasoning_effort*/ None, @@ -1605,6 +1680,10 @@ mod tests { AskForApproval::Never, codex_protocol::config_types::ApprovalsReviewer::User, SandboxPolicy::new_read_only_policy(), + Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + std::path::Path::new("/tmp/project"), + )), test_path_buf("/tmp/project").abs(), Vec::new(), /*reasoning_effort*/ None, From 6cc7a76650a430c26a5ce6174d7cf1d2d4776ea4 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 13:56:36 -0700 Subject: [PATCH 05/12] rollout: persist turn permission profiles --- .../core/src/context_manager/history_tests.rs | 1 + codex-rs/core/src/context_manager/updates.rs | 2 +- .../session/rollout_reconstruction_tests.rs | 8 +++++++ codex-rs/core/src/session/tests.rs | 9 ++++++++ codex-rs/core/src/session/turn_context.rs | 1 + codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 21 +++++++++++++++++++ codex-rs/rollout/src/recorder_tests.rs | 1 + codex-rs/state/src/extract.rs | 3 +++ codex-rs/tui/src/app/tests.rs | 1 + codex-rs/tui/src/lib.rs | 1 + 11 files changed, 48 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 1df14ca8bc..bd8e77fd24 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -133,6 +133,7 @@ fn reference_context_item() -> TurnContextItem { timezone: Some("America/Los_Angeles".to_string()), approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-test".to_string(), diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 0b8b7a5887..862b2698d1 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -49,7 +49,7 @@ fn build_permissions_update_item( } let prev = previous?; - if prev.sandbox_policy == *next.sandbox_policy.get() + if prev.permission_profile() == next.permission_profile() && prev.approval_policy == next.approval_policy.value() { return None; diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index a3cb9a7353..a865fa4976 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -68,6 +68,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -108,6 +109,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -917,6 +919,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -994,6 +997,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -1024,6 +1028,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -1138,6 +1143,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: current_model.to_string(), @@ -1251,6 +1257,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -1402,6 +1409,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b88b8a4083..4c5526869d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1606,6 +1606,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -5207,6 +5208,10 @@ async fn turn_context_item_omits_legacy_equivalent_file_system_sandbox_policy() let item = turn_context.to_turn_context_item(); assert_eq!(item.file_system_sandbox_policy, None); + assert_eq!( + item.permission_profile, + Some(turn_context.permission_profile()) + ); } #[tokio::test] @@ -5221,6 +5226,10 @@ async fn turn_context_item_stores_split_file_system_sandbox_policy_when_differen item.file_system_sandbox_policy, Some(file_system_sandbox_policy) ); + assert_eq!( + item.permission_profile, + Some(turn_context.permission_profile()) + ); } #[tokio::test] diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 80c00e75cd..a925d42067 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -272,6 +272,7 @@ impl TurnContext { timezone: self.timezone.clone(), approval_policy: self.approval_policy.value(), sandbox_policy: self.sandbox_policy.get().clone(), + permission_profile: Some(self.permission_profile()), network: self.turn_context_network_item(), file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(), model: self.model_info.slug.clone(), diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0368d9baf6..e1e9220fa3 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -33,6 +33,7 @@ fn resume_history( timezone: None, approval_policy: config.permissions.approval_policy.value(), sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 9ef9b9f84a..c50671df17 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3002,6 +3002,8 @@ pub struct TurnContextItem { pub timezone: Option, pub approval_policy: AskForApproval, pub sandbox_policy: SandboxPolicy, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_profile: Option, #[serde(skip_serializing_if = "Option::is_none")] pub network: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -3026,6 +3028,24 @@ pub struct TurnContextItem { pub truncation_policy: Option, } +impl TurnContextItem { + pub fn permission_profile(&self) -> PermissionProfile { + self.permission_profile.clone().unwrap_or_else(|| { + let file_system_sandbox_policy = + self.file_system_sandbox_policy.clone().unwrap_or_else(|| { + FileSystemSandboxPolicy::from_legacy_sandbox_policy( + &self.sandbox_policy, + &self.cwd, + ) + }); + PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::from(&self.sandbox_policy), + ) + }) + } +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(tag = "mode", content = "limit", rename_all = "snake_case")] pub enum TruncationPolicy { @@ -5162,6 +5182,7 @@ mod tests { timezone: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, network: Some(TurnContextNetworkItem { allowed_domains: vec!["api.example.com".to_string()], denied_domains: vec!["blocked.example.com".to_string()], diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 852bf6c6df..a1889f34d3 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -819,6 +819,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re timezone: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "test-model".to_string(), diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 6d36f211a4..f0fe3c693c 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -301,6 +301,7 @@ mod tests { timezone: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), @@ -340,6 +341,7 @@ mod tests { timezone: None, approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), @@ -373,6 +375,7 @@ mod tests { timezone: None, approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 47fe0f9272..3b6b615bd8 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2689,6 +2689,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re timezone: None, approval_policy: primary_session.approval_policy, sandbox_policy: primary_session.sandbox_policy.clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-agent".to_string(), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 8814cddda3..ee50d0a178 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -2197,6 +2197,7 @@ mod tests { timezone: None, approval_policy: config.permissions.approval_policy.value(), sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model, From 9a247e57beac75ec309c7541962584a68e5f131e Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 13:56:36 -0700 Subject: [PATCH 06/12] protocol: report session permission profiles --- codex-rs/core/src/session/session.rs | 1 + codex-rs/exec/src/lib.rs | 4 + .../tests/event_processor_with_json_output.rs | 1 + codex-rs/mcp-server/src/outgoing_message.rs | 3 + codex-rs/protocol/src/protocol.rs | 5 + codex-rs/tui/src/app/tests.rs | 167 ++++++++++++++++++ codex-rs/tui/src/chatwidget.rs | 1 + .../chatwidget/tests/composer_submission.rs | 7 + .../tui/src/chatwidget/tests/exec_flow.rs | 1 + .../src/chatwidget/tests/history_replay.rs | 8 + .../tui/src/chatwidget/tests/permissions.rs | 2 + .../tui/src/chatwidget/tests/plan_mode.rs | 2 + codex-rs/tui/src/history_cell.rs | 1 + 13 files changed, 203 insertions(+) diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index c64b1c30ac..e3f7d1b6c2 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -806,6 +806,7 @@ impl Session { approval_policy: session_configuration.approval_policy.value(), approvals_reviewer: session_configuration.approvals_reviewer, sandbox_policy: session_configuration.sandbox_policy.get().clone(), + permission_profile: Some(session_configuration.permission_profile()), cwd: session_configuration.cwd.clone(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), history_log_id, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index c8059ad5fe..97d741988a 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1024,6 +1024,7 @@ fn session_configured_from_thread_start_response( response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response.sandbox.to_core(), + response.permission_profile.clone().into(), response.cwd.clone(), response.reasoning_effort, ) @@ -1042,6 +1043,7 @@ fn session_configured_from_thread_resume_response( response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response.sandbox.to_core(), + response.permission_profile.clone().into(), response.cwd.clone(), response.reasoning_effort, ) @@ -1070,6 +1072,7 @@ fn session_configured_from_thread_response( approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, sandbox_policy: SandboxPolicy, + permission_profile: PermissionProfile, cwd: AbsolutePathBuf, reasoning_effort: Option, ) -> Result { @@ -1086,6 +1089,7 @@ fn session_configured_from_thread_response( approval_policy, approvals_reviewer, sandbox_policy, + permission_profile: Some(permission_profile), cwd, reasoning_effort, history_log_id: 0, diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 02e52988cb..e894b8f4eb 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -115,6 +115,7 @@ fn session_configured_produces_thread_started_event() { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index e94de16587..67ceb91dd8 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -305,6 +305,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, @@ -349,6 +350,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, @@ -418,6 +420,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c50671df17..6281792be4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3675,6 +3675,10 @@ pub struct SessionConfiguredEvent { /// How to sandbox commands executed in the system pub sandbox_policy: SandboxPolicy, + /// Effective permissions for commands executed in the session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_profile: Option, + /// Working directory that should be treated as the *root* of the /// session. pub cwd: AbsolutePathBuf, @@ -5249,6 +5253,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 3b6b615bd8..e872724ed5 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3463,6 +3463,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::High), history_log_id: 0, @@ -4023,6 +4024,167 @@ fn active_turn_steer_race_extracts_actual_turn_id_from_mismatch() { ); } +#[tokio::test] +async fn update_reasoning_effort_updates_collaboration_mode() { + let mut app = make_test_app().await; + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); + + app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); + + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + app.config.model_reasoning_effort, + Some(ReasoningEffortConfig::High) + ); +} + +#[tokio::test] +async fn refresh_in_memory_config_from_disk_loads_latest_apps_state() -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + let app_id = "unit_test_refresh_in_memory_config_connector".to_string(); + + assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None); + + ConfigEditsBuilder::new(&app.config.codex_home) + .with_edits([ + ConfigEdit::SetPath { + segments: vec!["apps".to_string(), app_id.clone(), "enabled".to_string()], + value: false.into(), + }, + ConfigEdit::SetPath { + segments: vec![ + "apps".to_string(), + app_id.clone(), + "disabled_reason".to_string(), + ], + value: "user".into(), + }, + ]) + .apply() + .await + .expect("persist app toggle"); + + assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None); + + app.refresh_in_memory_config_from_disk().await?; + + assert_eq!( + app_enabled_in_effective_config(&app.config, &app_id), + Some(false) + ); + Ok(()) +} + +#[tokio::test] +async fn refresh_in_memory_config_from_disk_best_effort_keeps_current_config_on_error() -> Result<()> +{ + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + std::fs::write(codex_home.path().join("config.toml"), "[broken")?; + let original_config = app.config.clone(); + + app.refresh_in_memory_config_from_disk_best_effort("starting a new thread") + .await; + + assert_eq!(app.config, original_config); + Ok(()) +} + +#[tokio::test] +async fn refresh_in_memory_config_from_disk_uses_active_chat_widget_cwd() -> Result<()> { + let mut app = make_test_app().await; + let original_cwd = app.config.cwd.clone(); + let next_cwd_tmp = tempdir()?; + let next_cwd = next_cwd_tmp.path().to_path_buf(); + + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, + cwd: next_cwd.clone().abs(), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }); + + assert_eq!(app.chat_widget.config_ref().cwd.to_path_buf(), next_cwd); + assert_eq!(app.config.cwd, original_cwd); + + app.refresh_in_memory_config_from_disk().await?; + + assert_eq!(app.config.cwd, app.chat_widget.config_ref().cwd); + Ok(()) +} + +#[tokio::test] +async fn rebuild_config_for_resume_or_fallback_uses_current_config_on_same_cwd_error() -> Result<()> +{ + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + std::fs::write(codex_home.path().join("config.toml"), "[broken")?; + let current_config = app.config.clone(); + let current_cwd = current_config.cwd.clone(); + + let resume_config = app + .rebuild_config_for_resume_or_fallback(¤t_cwd, current_cwd.to_path_buf()) + .await?; + + assert_eq!(resume_config, current_config); + Ok(()) +} + +#[tokio::test] +async fn rebuild_config_for_resume_or_fallback_errors_when_cwd_changes() -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + std::fs::write(codex_home.path().join("config.toml"), "[broken")?; + let current_cwd = app.config.cwd.clone(); + let next_cwd_tmp = tempdir()?; + let next_cwd = next_cwd_tmp.path().to_path_buf(); + + let result = app + .rebuild_config_for_resume_or_fallback(¤t_cwd, next_cwd) + .await; + + assert!(result.is_err()); + Ok(()) +} + +#[tokio::test] +async fn sync_tui_theme_selection_updates_chat_widget_config_copy() { + let mut app = make_test_app().await; + + app.sync_tui_theme_selection("dracula".to_string()); + + assert_eq!(app.config.tui_theme.as_deref(), Some("dracula")); + assert_eq!( + app.chat_widget.config_ref().tui_theme.as_deref(), + Some("dracula") + ); +} + #[tokio::test] async fn fresh_session_config_uses_current_service_tier() { let mut app = make_test_app().await; @@ -4071,6 +4233,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4134,6 +4297,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4227,6 +4391,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4610,6 +4775,7 @@ async fn new_session_requests_shutdown_for_previous_conversation() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4722,6 +4888,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 093ff91aae..b78ada6bca 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1395,6 +1395,7 @@ fn thread_session_state_to_legacy_event( approval_policy: session.approval_policy, approvals_reviewer: session.approvals_reviewer, sandbox_policy: session.sandbox_policy, + permission_profile: Some(session.permission_profile), cwd: session.cwd, reasoning_effort: session.reasoning_effort, history_log_id: session.history_log_id, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 2f8c6a9b2b..d35c7e9ded 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -17,6 +17,7 @@ async fn submission_preserves_text_elements_and_local_images() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -101,6 +102,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -196,6 +198,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -261,6 +264,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -301,6 +305,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -341,6 +346,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -384,6 +390,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index cb6bea9cc6..206564d06a 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1005,6 +1005,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 084743f98d..3e2f5b4d74 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -17,6 +17,7 @@ async fn resumed_initial_messages_render_history() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -130,6 +131,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -191,6 +193,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -259,6 +262,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: expected_sandbox.clone(), + permission_profile: None, cwd: expected_cwd.clone(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -302,6 +306,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -355,6 +360,7 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -676,6 +682,7 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, @@ -723,6 +730,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 92ca170605..6b7dc2ed8e 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -463,6 +463,7 @@ async fn permissions_selection_marks_guardian_approvals_current_after_session_co approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::GuardianSubagent, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, @@ -517,6 +518,7 @@ async fn permissions_selection_marks_guardian_approvals_current_with_custom_work exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }, + permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 1f91a8e1e7..f19a55d22e 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1061,6 +1061,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -1306,6 +1307,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index a09b313e26..33a05776b9 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -3073,6 +3073,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, From 939a7f0b79ac834e57fc391e52f529b75b2bb2c4 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 13:56:36 -0700 Subject: [PATCH 07/12] app-server: accept command permission profiles --- .../schema/json/ClientRequest.json | 13 +- .../codex_app_server_protocol.schemas.json | 13 +- .../codex_app_server_protocol.v2.schemas.json | 13 +- .../schema/json/v2/CommandExecParams.json | 282 +++++++++++++++++- .../schema/typescript/v2/CommandExecParams.ts | 13 +- .../src/protocol/mappers.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 18 +- codex-rs/app-server/README.md | 12 +- .../app-server/src/codex_message_processor.rs | 61 +++- .../app-server/tests/suite/v2/command_exec.rs | 122 ++++++++ 10 files changed, 532 insertions(+), 16 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index ada860ae27..6649ea10bf 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -217,6 +217,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -233,7 +244,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ 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 51fb744077..4064e83115 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 @@ -6424,6 +6424,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -6440,7 +6451,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ 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 6291b5b987..7c4e7d99f3 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 @@ -3011,6 +3011,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -3027,7 +3038,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index 986ec4cc1e..2198aad5bc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -5,6 +5,17 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "CommandExecTerminalSize": { "description": "PTY size in character cells for `command/exec` PTY sessions.", "properties": { @@ -27,6 +38,217 @@ ], "type": "object" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "current_working_directory" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "CurrentWorkingDirectoryFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "NetworkAccess": { "enum": [ "restricted", @@ -34,6 +256,53 @@ ], "type": "string" }, + "PermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "PermissionProfileFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "entries" + ], + "type": "object" + }, "ReadOnlyAccess": { "oneOf": [ { @@ -247,6 +516,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -263,7 +543,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts index 097cfdfecc..659974feaf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; +import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxPolicy } from "./SandboxPolicy"; /** @@ -92,6 +93,14 @@ size?: CommandExecTerminalSize | null, * Optional sandbox policy for this command. * * Uses the same shape as thread/turn execution sandbox configuration and - * defaults to the user's configured policy when omitted. + * defaults to the user's configured policy when omitted. Cannot be + * combined with `permissionProfile`. */ -sandboxPolicy?: SandboxPolicy | null, }; +sandboxPolicy?: SandboxPolicy | null, +/** + * Optional full permissions profile for this command. + * + * Defaults to the user's configured permissions when omitted. Cannot be + * combined with `sandboxPolicy`. + */ +permissionProfile?: PermissionProfile | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/mappers.rs b/codex-rs/app-server-protocol/src/protocol/mappers.rs index 93f12691be..dae91e650f 100644 --- a/codex-rs/app-server-protocol/src/protocol/mappers.rs +++ b/codex-rs/app-server-protocol/src/protocol/mappers.rs @@ -18,6 +18,7 @@ impl From for v2::CommandExecParams { env: None, size: None, sandbox_policy: value.sandbox_policy.map(std::convert::Into::into), + permission_profile: None, } } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2e492e3797..76825877e0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3026,9 +3026,16 @@ pub struct CommandExecParams { /// Optional sandbox policy for this command. /// /// Uses the same shape as thread/turn execution sandbox configuration and - /// defaults to the user's configured policy when omitted. + /// defaults to the user's configured policy when omitted. Cannot be + /// combined with `permissionProfile`. #[ts(optional = nullable)] pub sandbox_policy: Option, + /// Optional full permissions profile for this command. + /// + /// Defaults to the user's configured permissions when omitted. Cannot be + /// combined with `sandboxPolicy`. + #[ts(optional = nullable)] + pub permission_profile: Option, } /// Final buffered result for `command/exec`. @@ -8130,6 +8137,7 @@ mod tests { env: None, size: None, sandbox_policy: None, + permission_profile: None, } ); } @@ -8150,6 +8158,7 @@ mod tests { env: None, size: None, sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8164,6 +8173,7 @@ mod tests { "env": null, "size": null, "sandboxPolicy": null, + "permissionProfile": null, "outputBytesCap": null, }) ); @@ -8189,6 +8199,7 @@ mod tests { env: None, size: None, sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8205,6 +8216,7 @@ mod tests { "env": null, "size": null, "sandboxPolicy": null, + "permissionProfile": null, }) ); @@ -8233,6 +8245,7 @@ mod tests { ])), size: None, sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8251,6 +8264,7 @@ mod tests { }, "size": null, "sandboxPolicy": null, + "permissionProfile": null, }) ); @@ -8320,6 +8334,7 @@ mod tests { cols: 120, }), sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8338,6 +8353,7 @@ mod tests { "cols": 120, }, "sandboxPolicy": null, + "permissionProfile": null, }) ); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 62e490dee3..a0c096743f 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -819,7 +819,13 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin "cwd": "/Users/me/project", // optional; defaults to server cwd "env": { "FOO": "override" }, // optional; merges into the server env and overrides matching names "size": { "rows": 40, "cols": 120 }, // optional; PTY size in character cells, only valid with tty=true - "sandboxPolicy": { "type": "workspaceWrite" }, // optional; defaults to user config + "permissionProfile": { // optional; defaults to user config + "fileSystem": { "entries": [ + { "path": { "type": "special", "value": { "kind": "root" } }, "access": "read" }, + { "path": { "type": "special", "value": { "kind": "current_working_directory" } }, "access": "write" } + ] }, + "network": { "enabled": false } + }, "outputBytesCap": 1048576, // optional; per-stream capture cap "disableOutputCap": false, // optional; cannot be combined with outputBytesCap "timeoutMs": 10000, // optional; ms timeout; defaults to server timeout @@ -832,12 +838,12 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin } } ``` -- For clients that are already sandboxed externally, set `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`. +- For clients that are already sandboxed externally, set the legacy `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`. Notes: - Empty `command` arrays are rejected. -- `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`). +- Prefer `permissionProfile` for command permission overrides. The legacy `sandboxPolicy` field accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`), but cannot be combined with `permissionProfile`. - `env` merges into the environment produced by the server's shell environment policy. Matching names are overridden; unspecified variables are left intact. - When omitted, `timeoutMs` falls back to the server default. - When omitted, `outputBytesCap` falls back to the server default of 1 MiB per stream. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1ab367d078..b38cef6c88 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -2073,7 +2073,16 @@ impl CodexMessageProcessor { env: env_overrides, size, sandbox_policy, + permission_profile, } = params; + if sandbox_policy.is_some() && permission_profile.is_some() { + self.send_invalid_request_error( + request_id, + "`permissionProfile` cannot be combined with `sandboxPolicy`".to_string(), + ) + .await; + return; + } if size.is_some() && !tty { let error = JSONRPCErrorError { @@ -2205,13 +2214,52 @@ impl CodexMessageProcessor { arg0: None, }; - let requested_policy = sandbox_policy.map(|policy| policy.to_core()); let ( effective_policy, effective_file_system_sandbox_policy, effective_network_sandbox_policy, - ) = match requested_policy { - Some(policy) => match self.config.permissions.sandbox_policy.can_set(&policy) { + ) = if let Some(permission_profile) = permission_profile { + let permission_profile = + codex_protocol::models::PermissionProfile::from(permission_profile); + let sandbox_policy = match permission_profile.to_legacy_sandbox_policy(&sandbox_cwd) { + Ok(sandbox_policy) => sandbox_policy, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid permission profile: {err}"), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + }; + match self + .config + .permissions + .sandbox_policy + .can_set(&sandbox_policy) + { + Ok(()) => { + let (file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + ( + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + ) + } + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid permission profile: {err}"), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + } + } else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) { + match self.config.permissions.sandbox_policy.can_set(&policy) { Ok(()) => { let file_system_sandbox_policy = codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, &sandbox_cwd); @@ -2228,12 +2276,13 @@ impl CodexMessageProcessor { self.outgoing.send_error(request, error).await; return; } - }, - None => ( + } + } else { + ( self.config.permissions.sandbox_policy.get().clone(), self.config.permissions.file_system_sandbox_policy.clone(), self.config.permissions.network_sandbox_policy, - ), + ) }; let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index d7645f13e3..66f879176f 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -13,9 +13,17 @@ use codex_app_server_protocol::CommandExecResponse; use codex_app_server_protocol::CommandExecTerminalSize; use codex_app_server_protocol::CommandExecTerminateParams; use codex_app_server_protocol::CommandExecWriteParams; +use codex_app_server_protocol::FileSystemAccessMode; +use codex_app_server_protocol::FileSystemPath; +use codex_app_server_protocol::FileSystemSandboxEntry; +use codex_app_server_protocol::FileSystemSpecialPath; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::PermissionProfile; +use codex_app_server_protocol::PermissionProfileFileSystemPermissions; +use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::collections::HashMap; use tempfile::TempDir; @@ -57,6 +65,7 @@ async fn command_exec_without_streams_can_be_terminated() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; let terminate_request_id = mcp @@ -109,6 +118,7 @@ async fn command_exec_without_process_id_keeps_buffered_compatibility() -> Resul env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -167,6 +177,7 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un ])), size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -186,6 +197,91 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un Ok(()) } +#[tokio::test] +async fn command_exec_accepts_permission_profile() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'profile'".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: Some(root_read_only_permission_profile()), + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "profile".to_string(), + stderr: String::new(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "true".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: Some(SandboxPolicy::DangerFullAccess), + permission_profile: Some(root_read_only_permission_profile()), + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "`permissionProfile` cannot be combined with `sandboxPolicy`" + ); + + Ok(()) +} + #[tokio::test] async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; @@ -209,6 +305,7 @@ async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -246,6 +343,7 @@ async fn command_exec_rejects_disable_output_cap_with_output_bytes_cap() -> Resu env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -283,6 +381,7 @@ async fn command_exec_rejects_negative_timeout_ms() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -320,6 +419,7 @@ async fn command_exec_without_process_id_rejects_streaming() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -361,6 +461,7 @@ async fn command_exec_non_streaming_respects_output_cap() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -408,6 +509,7 @@ async fn command_exec_streaming_does_not_buffer_output() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -471,6 +573,7 @@ async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -546,6 +649,7 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<( env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -619,6 +723,7 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> { cols: 101, }), sandbox_policy: None, + permission_profile: None, }) .await?; @@ -888,6 +993,23 @@ fn decode_delta_notification( serde_json::from_value(params).context("deserialize command/exec/outputDelta notification") } +fn root_read_only_permission_profile() -> PermissionProfile { + PermissionProfile { + network: Some(PermissionProfileNetworkPermissions { + enabled: Some(false), + }), + file_system: Some(PermissionProfileFileSystemPermissions { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }], + glob_scan_max_depth: None, + }), + } +} + async fn read_initialize_response( stream: &mut super::connection_handling_websocket::WsClient, request_id: i64, From 17cc33eac8a4c558164073c61e32b212c96f0508 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 13:56:36 -0700 Subject: [PATCH 08/12] tui: sync session permission profiles --- codex-rs/tui/src/chatwidget.rs | 9 +++++++ .../src/chatwidget/tests/history_replay.rs | 26 ++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b78ada6bca..656735f38c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -138,6 +138,7 @@ use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; use codex_protocol::items::UserMessageItem; use codex_protocol::models::MessagePhase; +use codex_protocol::models::PermissionProfile; use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg; @@ -2104,6 +2105,12 @@ impl ChatWidget { display: SessionConfiguredDisplay, fork_parent_title: Option, ) { + let permission_profile = event.permission_profile.clone().unwrap_or_else(|| { + PermissionProfile::from_legacy_sandbox_policy(&event.sandbox_policy, &event.cwd) + }); + let (file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + self.last_agent_markdown = None; self.agent_turn_markdowns.clear(); self.visible_user_turn_count = 0; @@ -2140,6 +2147,8 @@ impl ChatWidget { self.config.permissions.sandbox_policy = Constrained::allow_only(event.sandbox_policy.clone()); } + self.config.permissions.file_system_sandbox_policy = file_system_sandbox_policy; + self.config.permissions.network_sandbox_policy = network_sandbox_policy; self.config.approvals_reviewer = event.approvals_reviewer; self.status_line_project_root_name_cache = None; let forked_from_id = event.forked_from_id; diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 3e2f5b4d74..583d35c201 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -252,6 +252,26 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { let expected_sandbox = SandboxPolicy::new_read_only_policy(); let expected_cwd = test_path_buf("/home/user/sub-agent").abs(); + let expected_file_system_policy = + codex_protocol::permissions::FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::GlobPattern { + pattern: "**/.secret".to_string(), + }, + access: codex_protocol::permissions::FileSystemAccessMode::None, + }, + ]); + let expected_permission_profile = + codex_protocol::models::PermissionProfile::from_runtime_permissions( + &expected_file_system_policy, + codex_protocol::permissions::NetworkSandboxPolicy::Restricted, + ); let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: ThreadId::new(), forked_from_id: None, @@ -262,7 +282,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: expected_sandbox.clone(), - permission_profile: None, + permission_profile: Some(expected_permission_profile.clone()), cwd: expected_cwd.clone(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -285,6 +305,10 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { chat.config_ref().permissions.sandbox_policy.get(), &expected_sandbox ); + assert_eq!( + chat.config_ref().permissions.permission_profile(), + expected_permission_profile + ); assert_eq!(&chat.config_ref().cwd, &expected_cwd); } From 85881133803bf53ccaccbe01c6a42ae1c59b70c2 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 13:56:36 -0700 Subject: [PATCH 09/12] tui: carry permission profiles on user turns --- codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/src/session/handlers.rs | 3 +- codex-rs/core/src/session/tests.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 8 +++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 ++ codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 ++++ .../core/tests/suite/model_visible_layout.rs | 5 ++ codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/pending_input.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 ++++ codex-rs/core/tests/suite/prompt_caching.rs | 5 ++ codex-rs/core/tests/suite/remote_models.rs | 7 ++ .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + .../suite/responses_api_proxy_headers.rs | 1 + codex-rs/core/tests/suite/rmcp_client.rs | 12 ++++ .../tests/suite/safety_check_downgrade.rs | 4 ++ codex-rs/core/tests/suite/shell_snapshot.rs | 4 ++ codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 ++ codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 7 ++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 ++++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/protocol/src/protocol.rs | 7 ++ codex-rs/tui/src/app/thread_routing.rs | 2 + codex-rs/tui/src/app_command.rs | 6 ++ codex-rs/tui/src/app_server_session.rs | 16 +++-- codex-rs/tui/src/chatwidget.rs | 9 +++ .../chatwidget/tests/composer_submission.rs | 69 +++++++++++++++++++ 46 files changed, 247 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 50123ef503..b2aed54a80 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -691,6 +691,7 @@ async fn run_review_on_session( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: params.model.clone(), effort: params.reasoning_effort, summary: Some(params.reasoning_summary), diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 9c91f78188..fd4e8cd6bd 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -135,6 +135,7 @@ pub(super) async fn user_input_or_turn_inner( approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, model, effort, summary, @@ -162,7 +163,7 @@ pub(super) async fn user_input_or_turn_inner( approval_policy: Some(approval_policy), approvals_reviewer, sandbox_policy: Some(sandbox_policy), - permission_profile: None, + permission_profile, windows_sandbox_level: None, collaboration_mode, reasoning_summary: summary, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4c5526869d..075b851a68 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3829,6 +3829,7 @@ async fn user_turn_updates_approvals_reviewer() { approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: Some(codex_config::types::ApprovalsReviewer::GuardianSubagent), sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: turn_context.model_info.slug.clone(), effort: config.model_reasoning_effort, summary: config.model_reasoning_summary, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 74b05f6e17..9f5fb0284d 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -663,6 +663,7 @@ impl TestCodex { approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a789940b0e..588dd98d50 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -367,6 +367,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1005,6 +1006,7 @@ async fn apply_patch_custom_tool_streaming_emits_updated_changes() -> Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, @@ -1103,6 +1105,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1188,6 +1191,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1344,6 +1348,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1412,6 +1417,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1489,6 +1495,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1566,6 +1573,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 1a29d201b4..145579d194 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -595,6 +595,7 @@ async fn submit_turn( approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 95fba68a02..cde1794a1c 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1660,6 +1660,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: session_configured.model.clone(), effort: Some(ReasoningEffort::Low), summary: Some( @@ -1781,6 +1782,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: session_configured.model, effort: None, summary: Some(ReasoningSummary::Concise), diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index d52a1454e0..413fd90bbb 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2619,6 +2619,7 @@ text( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 3cc8bd8298..26d8d6aacc 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -186,6 +186,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: Some( @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: Some( diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index da4c09345c..60c962a4db 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1687,6 +1687,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: resumed.session_configured.model.clone(), effort: None, summary: None, @@ -1779,6 +1780,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: previous_model.to_string(), effort: None, summary: None, @@ -1805,6 +1807,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: next_model.to_string(), effort: None, summary: None, @@ -1917,6 +1920,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: previous_model.to_string(), effort: None, summary: None, @@ -1967,6 +1971,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: next_model.to_string(), effort: None, summary: None, @@ -3204,6 +3209,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: previous_model.to_string(), effort: None, summary: None, @@ -3230,6 +3236,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: next_model.to_string(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 8b2654a720..4fae6aafdf 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -54,6 +54,7 @@ async fn submit_user_turn( approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -136,6 +137,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 0e2d88237d..eb9751720a 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -126,6 +126,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 6cf42b5eed..db4f7d7584 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -540,6 +540,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, @@ -618,6 +619,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, @@ -728,6 +730,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, @@ -916,6 +919,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, @@ -1031,6 +1035,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index d6728deb0c..01783a365d 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -83,6 +83,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index cfafdea3f7..25206a6fd3 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -64,6 +64,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index db760d8f89..b3e9215635 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -131,6 +131,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -170,6 +171,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: next_model.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -270,6 +273,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: next_model.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -413,6 +417,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -435,6 +440,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: text_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -544,6 +550,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -566,6 +573,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -678,6 +686,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -700,6 +709,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: text_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -814,6 +824,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -844,6 +855,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -1002,6 +1014,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: large_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -1063,6 +1076,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: smaller_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 6cbbb7389c..2f06d5af21 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -124,6 +124,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -149,6 +150,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { approval_policy: AskForApproval::OnRequest, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -229,6 +231,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -254,6 +257,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -367,6 +371,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: resumed.session_configured.model.clone(), effort: resumed.config.model_reasoning_effort, summary: None, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 5728205b6c..8eb8fca8ac 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -100,6 +100,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index d46214d1f2..0551dcf8e1 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -111,6 +111,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 10907f0263..25048d5c81 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -120,6 +120,7 @@ async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 9232dc71cd..0a1c76295a 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -106,6 +106,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -158,6 +159,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -217,6 +219,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -282,6 +285,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -335,6 +339,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -375,6 +380,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -443,6 +449,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -483,6 +490,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -564,6 +572,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -604,6 +613,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -724,6 +734,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -846,6 +857,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -886,6 +898,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 834bab73fb..ca0b3e281a 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -728,6 +728,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: new_policy.clone(), + permission_profile: None, model: "o3".to_string(), effort: Some(ReasoningEffort::High), summary: Some(ReasoningSummary::Detailed), @@ -842,6 +843,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a approval_policy: default_approval_policy, approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), + permission_profile: None, model: default_model.clone(), effort: default_effort, summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -864,6 +866,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a approval_policy: default_approval_policy, approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), + permission_profile: None, model: default_model.clone(), effort: default_effort, summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -970,6 +973,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu approval_policy: default_approval_policy, approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), + permission_profile: None, model: default_model, effort: default_effort, summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -992,6 +996,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: "o3".to_string(), effort: Some(ReasoningEffort::High), summary: Some(ReasoningSummary::Detailed), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index f2b9d16ba2..790d9ca4f7 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -171,6 +171,7 @@ async fn remote_models_config_context_window_override_clamps_to_max_context_wind summary: None, service_tier: None, collaboration_mode: None, + permission_profile: None, personality: None, environments: None, }) @@ -248,6 +249,7 @@ async fn remote_models_config_override_above_max_uses_max_context_window() -> Re summary: None, service_tier: None, collaboration_mode: None, + permission_profile: None, personality: None, environments: None, }) @@ -324,6 +326,7 @@ async fn remote_models_use_context_window_when_config_override_is_absent() -> Re summary: None, service_tier: None, collaboration_mode: None, + permission_profile: None, personality: None, environments: None, }) @@ -408,6 +411,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: requested_model.to_string(), effort: None, summary: None, @@ -468,6 +472,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: requested_model.to_string(), effort: None, summary: Some( @@ -636,6 +641,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: REMOTE_MODEL_SLUG.to_string(), effort: None, summary: Some(ReasoningSummary::Auto), @@ -863,6 +869,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: model.to_string(), effort: None, summary: Some(ReasoningSummary::Auto), diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 866df13863..06c1e4c5c3 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -197,6 +197,7 @@ async fn submit_turn( approval_policy, approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index d31b060c38..90dd7c02b0 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -148,6 +148,7 @@ async fn submit_turn( approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 4c59c8e7f3..7814f4936c 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -141,6 +141,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -260,6 +261,7 @@ where approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index de35b12a2b..5df7e516a8 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -143,6 +143,7 @@ async fn submit_turn_with_timeout(test: &TestCodex, prompt: &str) -> Result<()> exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index cb6553343f..4fa920db9f 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -290,6 +290,7 @@ async fn call_cwd_tool( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -424,6 +425,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -793,6 +795,7 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow:: approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -926,6 +929,7 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1026,6 +1030,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1178,6 +1183,7 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1416,6 +1422,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: text_only_model_slug.to_string(), effort: None, summary: None, @@ -1525,6 +1532,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1662,6 +1670,7 @@ async fn stdio_server_propagates_explicit_local_env_var_source() -> anyhow::Resu approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1772,6 +1781,7 @@ async fn remote_stdio_env_var_source_does_not_copy_local_env() -> anyhow::Result approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1897,6 +1907,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -2114,6 +2125,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index f3211e00d6..ce16922201 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -48,6 +48,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -148,6 +149,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -235,6 +237,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -286,6 +289,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, summary: None, diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index b3a53fde11..1204f2b078 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -167,6 +167,7 @@ async fn run_snapshot_command_with_options( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -259,6 +260,7 @@ async fn run_shell_command_snapshot_with_options( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -331,6 +333,7 @@ async fn run_tool_turn_on_harness( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -567,6 +570,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index de4827d6be..44c2fb7b68 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -54,6 +54,7 @@ async fn submit_turn_with_policies( approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 015f4ef0f2..1edaefb783 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -115,6 +115,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 6a3f9b7927..8ad9ede5b9 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -409,6 +409,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index ca86d3e9fe..db93c0b66e 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -88,6 +88,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -160,6 +161,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -242,6 +244,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -339,6 +342,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -444,6 +448,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 3158804fad..a0ee7b1140 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -45,6 +45,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -363,6 +364,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 861ec1eed5..5e522c0aa0 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -510,6 +510,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 58dbad7b1d..d531710773 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -176,6 +176,7 @@ async fn submit_unified_exec_turn( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -261,6 +262,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1752,6 +1754,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1846,6 +1849,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -2320,6 +2324,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { approvals_reviewer: None, // Important! sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -2429,6 +2434,7 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: read_only_policy, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -2558,6 +2564,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 0aa52b7de8..01521dab01 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -180,6 +180,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: fixture.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 4567403449..695acd0dd7 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -169,6 +169,7 @@ async fn assert_user_turn_local_image_resizes_to( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -290,6 +291,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -422,6 +424,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -522,6 +525,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -613,6 +617,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -714,6 +719,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -819,6 +825,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_only approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -922,6 +929,7 @@ await codex.emitImage(out); approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1043,6 +1051,7 @@ console.log(out.type); approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -1137,6 +1146,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1219,6 +1229,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1306,6 +1317,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1444,6 +1456,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: model_slug.to_string(), effort: None, summary: None, @@ -1525,6 +1538,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index 6611ebdf5d..dbe2e3c6d7 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -160,6 +160,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6281792be4..da4071c291 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -537,6 +537,13 @@ pub enum Op { /// Policy to use for tool calls such as `local_shell`. sandbox_policy: SandboxPolicy, + /// Full permissions profile to use for tool calls such as `local_shell`. + /// + /// When omitted, `sandbox_policy` is used as a legacy compatibility + /// projection. + #[serde(default, skip_serializing_if = "Option::is_none")] + permission_profile: Option, + /// Must be a valid model slug for the configured client session /// associated with this conversation. model: String, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 241695cc27..bf1f95555a 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -497,6 +497,7 @@ impl App { approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, model, effort, summary, @@ -580,6 +581,7 @@ impl App { approvals_reviewer .unwrap_or(self.chat_widget.config_ref().approvals_reviewer), sandbox_policy.clone(), + permission_profile.clone(), model.to_string(), effort, *summary, diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 9bd024679a..1febbfa1e7 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -8,6 +8,7 @@ use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::mcp::RequestId as McpRequestId; +use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ConversationAudioParams; @@ -44,6 +45,7 @@ pub(crate) enum AppCommandView<'a> { approval_policy: AskForApproval, approvals_reviewer: &'a Option, sandbox_policy: &'a SandboxPolicy, + permission_profile: &'a Option, model: &'a str, effort: Option, summary: &'a Option, @@ -140,6 +142,7 @@ impl AppCommand { cwd: PathBuf, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, + permission_profile: Option, model: String, effort: Option, summary: Option, @@ -155,6 +158,7 @@ impl AppCommand { approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile, model, effort, summary, @@ -291,6 +295,7 @@ impl AppCommand { approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, model, effort, summary, @@ -305,6 +310,7 @@ impl AppCommand { approval_policy: *approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, model, effort: *effort, summary, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 81e11c9637..5a6e9d4e59 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -521,6 +521,7 @@ impl AppServerSession { approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, sandbox_policy: SandboxPolicy, + permission_profile: Option, model: String, effort: Option, summary: Option, @@ -530,6 +531,14 @@ impl AppServerSession { output_schema: Option, ) -> Result { let request_id = self.next_request_id(); + let should_send_permission_profile = !self.is_remote() + && !matches!(sandbox_policy, SandboxPolicy::ExternalSandbox { .. }) + && permission_profile.is_some(); + let (sandbox_policy, permission_profile) = if should_send_permission_profile { + (None, permission_profile.map(Into::into)) + } else { + (Some(sandbox_policy.into()), None) + }; self.client .request_typed(ClientRequest::TurnStart { request_id, @@ -541,11 +550,8 @@ impl AppServerSession { cwd: Some(cwd), approval_policy: Some(approval_policy.into()), approvals_reviewer: Some(approvals_reviewer.into()), - // This path only receives the legacy projection today; do - // not synthesize a lossy profile until the active profile - // is plumbed through user turns. - sandbox_policy: Some(sandbox_policy.into()), - permission_profile: None, + sandbox_policy, + permission_profile, model: Some(model), service_tier, effort, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 656735f38c..79f3bbc903 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5952,11 +5952,20 @@ impl ChatWidget { .filter(|_| self.config.features.enabled(Feature::Personality)) .filter(|_| self.current_model_supports_personality()); let service_tier = Some(self.config.service_tier); + let permission_profile = if matches!( + self.config.permissions.sandbox_policy.get(), + SandboxPolicy::ExternalSandbox { .. } + ) { + None + } else { + Some(self.config.permissions.permission_profile()) + }; let op = AppCommand::user_turn( items, self.config.cwd.to_path_buf(), self.config.permissions.approval_policy.value(), self.config.permissions.sandbox_policy.get().clone(), + permission_profile, effective_mode.model().to_string(), effective_mode.reasoning_effort(), /*summary*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index d35c7e9ded..a23f137b40 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -86,6 +86,75 @@ async fn submission_preserves_text_elements_and_local_images() { assert!(stored_remote_image_urls.is_empty()); } +#[tokio::test] +async fn submission_includes_configured_permission_profile() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let expected_permission_profile = PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(false), + }), + file_system: Some(FileSystemPermissions { + entries: vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::GlobPattern { + pattern: "/home/user/project/secrets/**".to_string(), + }, + access: codex_protocol::permissions::FileSystemAccessMode::None, + }, + ], + glob_scan_max_depth: None, + }), + }; + let configured = codex_protocol::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: Some(expected_permission_profile.clone()), + cwd: test_path_buf("/home/user/project").abs(), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(rollout_file.path().to_path_buf()), + }; + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + drain_insert_history(&mut rx); + + chat.bottom_pane.set_composer_text( + "submit with configured permissions".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let permission_profile = match next_submit_op(&mut op_rx) { + Op::UserTurn { + permission_profile, .. + } => permission_profile, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!(permission_profile, Some(expected_permission_profile)); +} + #[tokio::test] async fn submission_with_remote_and_local_images_keeps_local_placeholder_numbering() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 504cddcd0b1563b21ed95f7b3b682da2d71cd4f7 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 13:56:36 -0700 Subject: [PATCH 10/12] mcp: include permission profiles in sandbox state --- codex-rs/codex-mcp/src/mcp_connection_manager.rs | 3 +++ codex-rs/core/src/mcp_tool_call.rs | 1 + 2 files changed, 4 insertions(+) diff --git a/codex-rs/codex-mcp/src/mcp_connection_manager.rs b/codex-rs/codex-mcp/src/mcp_connection_manager.rs index 10c48e040e..bcc7685a31 100644 --- a/codex-rs/codex-mcp/src/mcp_connection_manager.rs +++ b/codex-rs/codex-mcp/src/mcp_connection_manager.rs @@ -42,6 +42,7 @@ use codex_protocol::approvals::ElicitationRequest; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::mcp::CallToolResult; use codex_protocol::mcp::RequestId as ProtocolRequestId; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -646,6 +647,8 @@ pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SandboxState { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_profile: Option, pub sandbox_policy: SandboxPolicy, pub codex_linux_sandbox_exe: Option, pub sandbox_cwd: PathBuf, diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 82bbc7ca4a..f94a9eecc8 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -519,6 +519,7 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( } let sandbox_state = serde_json::to_value(SandboxState { + permission_profile: Some(turn_context.permission_profile()), sandbox_policy: turn_context.sandbox_policy.get().clone(), codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(), sandbox_cwd: turn_context.cwd.to_path_buf(), From c6806029382aab6cf74f0f552838de2129f90686 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 13:56:36 -0700 Subject: [PATCH 11/12] shell-escalation: carry resolved permission profiles --- .../tools/runtimes/shell/unix_escalation.rs | 38 +++++++++++-------- .../runtimes/shell/unix_escalation_tests.rs | 12 +++--- codex-rs/protocol/src/approvals.rs | 17 +++++---- codex-rs/shell-escalation/src/lib.rs | 4 +- .../src/unix/escalate_server.rs | 16 ++++---- codex-rs/shell-escalation/src/unix/mod.rs | 2 +- 6 files changed, 52 insertions(+), 37 deletions(-) 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 361de9a96b..f4a6ac3cf7 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -54,8 +54,8 @@ use codex_shell_escalation::EscalationPolicy; use codex_shell_escalation::EscalationSession; use codex_shell_escalation::ExecParams; use codex_shell_escalation::ExecResult; -use codex_shell_escalation::Permissions as EscalatedPermissions; use codex_shell_escalation::PreparedExec; +use codex_shell_escalation::ResolvedPermissionProfile; use codex_shell_escalation::ShellCommandExecutor; use codex_shell_escalation::Stopwatch; use codex_utils_absolute_path::AbsolutePathBuf; @@ -371,13 +371,17 @@ impl CoreShellActionProvider { .map(|_| { // Shell request additional permissions were already normalized and // merged into the first-attempt sandbox policy. - EscalationExecution::Permissions(EscalationPermissions::Permissions( - EscalatedPermissions { - sandbox_policy: sandbox_policy.clone(), - file_system_sandbox_policy: file_system_sandbox_policy.clone(), - network_sandbox_policy, - }, - )) + EscalationExecution::Permissions( + EscalationPermissions::ResolvedPermissionProfile( + ResolvedPermissionProfile { + permission_profile: PermissionProfile::from_runtime_permissions( + file_system_sandbox_policy, + network_sandbox_policy, + ), + sandbox_policy: sandbox_policy.clone(), + }, + ), + ) }) .unwrap_or(EscalationExecution::TurnDefault), } @@ -844,9 +848,9 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { additional_permissions: None, })? } - EscalationExecution::Permissions(EscalationPermissions::PermissionProfile( - permission_profile, - )) => { + EscalationExecution::Permissions( + EscalationPermissions::AdditionalPermissionProfile(permission_profile), + ) => { // Merge additive permissions into the existing turn/request sandbox policy. self.prepare_sandboxed_exec(PrepareSandboxedExecParams { command, @@ -858,15 +862,19 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { additional_permissions: Some(permission_profile), })? } - EscalationExecution::Permissions(EscalationPermissions::Permissions(permissions)) => { - // Use a fully specified sandbox policy instead of merging into the turn policy. + EscalationExecution::Permissions(EscalationPermissions::ResolvedPermissionProfile( + permissions, + )) => { + // Use a fully specified permission profile instead of merging into the turn policy. + let (file_system_sandbox_policy, network_sandbox_policy) = + permissions.permission_profile.to_runtime_permissions(); self.prepare_sandboxed_exec(PrepareSandboxedExecParams { command, workdir, env, sandbox_policy: &permissions.sandbox_policy, - file_system_sandbox_policy: &permissions.file_system_sandbox_policy, - network_sandbox_policy: permissions.network_sandbox_policy, + file_system_sandbox_policy: &file_system_sandbox_policy, + network_sandbox_policy, additional_permissions: None, })? } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index deaaf067ea..a2b6ca145e 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -33,7 +33,7 @@ use codex_sandboxing::SandboxType; use codex_shell_escalation::EscalationExecution; use codex_shell_escalation::EscalationPermissions; use codex_shell_escalation::ExecResult; -use codex_shell_escalation::Permissions as EscalatedPermissions; +use codex_shell_escalation::ResolvedPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::Value; @@ -317,11 +317,13 @@ fn shell_request_escalation_execution_is_explicit() { network_sandbox_policy, Some(&requested_permissions), ), - EscalationExecution::Permissions(EscalationPermissions::Permissions( - EscalatedPermissions { + EscalationExecution::Permissions(EscalationPermissions::ResolvedPermissionProfile( + ResolvedPermissionProfile { + permission_profile: PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + network_sandbox_policy, + ), sandbox_policy, - file_system_sandbox_policy, - network_sandbox_policy, }, )), ); diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 8799db4a6e..2b9aea2721 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -1,8 +1,6 @@ use crate::mcp::RequestId; use crate::models::PermissionProfile; use crate::parse_command::ParsedCommand; -use crate::permissions::FileSystemSandboxPolicy; -use crate::permissions::NetworkSandboxPolicy; use crate::protocol::FileChange; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; @@ -16,18 +14,23 @@ use std::collections::HashMap; use std::path::PathBuf; use ts_rs::TS; +/// Fully resolved permissions for rerunning an intercepted child process. +/// +/// `permission_profile` is the canonical permission model. `sandbox_policy` +/// remains as the legacy adapter for sandbox backends that still require it. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Permissions { +pub struct ResolvedPermissionProfile { + pub permission_profile: PermissionProfile, pub sandbox_policy: SandboxPolicy, - pub file_system_sandbox_policy: FileSystemSandboxPolicy, - pub network_sandbox_policy: NetworkSandboxPolicy, } #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq, Eq)] pub enum EscalationPermissions { - PermissionProfile(PermissionProfile), - Permissions(Permissions), + /// Permissions to merge with the active turn permissions. + AdditionalPermissionProfile(PermissionProfile), + /// Fully resolved permissions that should replace the active turn permissions. + ResolvedPermissionProfile(ResolvedPermissionProfile), } /// Proposed execpolicy change to allow commands starting with this prefix. diff --git a/codex-rs/shell-escalation/src/lib.rs b/codex-rs/shell-escalation/src/lib.rs index 6c7e1ec605..0f3c0afd30 100644 --- a/codex-rs/shell-escalation/src/lib.rs +++ b/codex-rs/shell-escalation/src/lib.rs @@ -22,10 +22,10 @@ pub use unix::ExecParams; #[cfg(unix)] pub use unix::ExecResult; #[cfg(unix)] -pub use unix::Permissions; -#[cfg(unix)] pub use unix::PreparedExec; #[cfg(unix)] +pub use unix::ResolvedPermissionProfile; +#[cfg(unix)] pub use unix::ShellCommandExecutor; #[cfg(unix)] pub use unix::Stopwatch; diff --git a/codex-rs/shell-escalation/src/unix/escalate_server.rs b/codex-rs/shell-escalation/src/unix/escalate_server.rs index 42e31f6a2a..ce73f262f4 100644 --- a/codex-rs/shell-escalation/src/unix/escalate_server.rs +++ b/codex-rs/shell-escalation/src/unix/escalate_server.rs @@ -922,7 +922,7 @@ mod tests { server, Arc::new(DeterministicEscalationPolicy { decision: EscalationDecision::escalate(EscalationExecution::Permissions( - EscalationPermissions::PermissionProfile(PermissionProfile { + EscalationPermissions::AdditionalPermissionProfile(PermissionProfile { network: Some(NetworkPermissions { enabled: Some(true), }), @@ -931,12 +931,14 @@ mod tests { )), }), Arc::new(PermissionAssertingShellCommandExecutor { - expected_permissions: EscalationPermissions::PermissionProfile(PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - ..Default::default() - }), + expected_permissions: EscalationPermissions::AdditionalPermissionProfile( + PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + ..Default::default() + }, + ), }), CancellationToken::new(), CancellationToken::new(), diff --git a/codex-rs/shell-escalation/src/unix/mod.rs b/codex-rs/shell-escalation/src/unix/mod.rs index b5d999411c..8fb8688443 100644 --- a/codex-rs/shell-escalation/src/unix/mod.rs +++ b/codex-rs/shell-escalation/src/unix/mod.rs @@ -76,4 +76,4 @@ pub use self::escalation_policy::EscalationPolicy; pub use self::execve_wrapper::main_execve_wrapper; pub use self::stopwatch::Stopwatch; pub use codex_protocol::approvals::EscalationPermissions; -pub use codex_protocol::approvals::Permissions; +pub use codex_protocol::approvals::ResolvedPermissionProfile; From 4696169fe9d31687accd23f567f7c935f1758467 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 13:56:36 -0700 Subject: [PATCH 12/12] tests: isolate approval fixtures from host rules --- codex-rs/core/tests/suite/approvals.rs | 2 +- codex-rs/core/tests/suite/otel.rs | 36 +++++++++++++++---- .../core/tests/suite/permissions_messages.rs | 2 ++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 145579d194..f915ec45a3 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -593,7 +593,7 @@ async fn submit_turn( final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy, - approvals_reviewer: None, + approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy, permission_profile: None, model: session_model, diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 0ad23ccf53..6407ec2702 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -1146,7 +1146,11 @@ async fn handle_container_exec_user_approved_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_local_shell_call("user_approved_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "user_approved_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) @@ -1215,7 +1219,11 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() mount_sse_once( &server, sse(vec![ - ev_local_shell_call("user_approved_session_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "user_approved_session_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) @@ -1283,7 +1291,11 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_local_shell_call("sandbox_retry_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "sandbox_retry_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) @@ -1351,7 +1363,11 @@ async fn handle_container_exec_user_denies_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_local_shell_call("user_denied_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "user_denied_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) @@ -1419,7 +1435,11 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() mount_sse_once( &server, sse(vec![ - ev_local_shell_call("sandbox_session_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "sandbox_session_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) @@ -1487,7 +1507,11 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_local_shell_call("sandbox_deny_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "sandbox_deny_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 838a20f00a..1bf2ee121c 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_core::ForkSnapshot; use codex_core::config::Constrained; +use codex_core::config_loader::ConfigLayerStack; use codex_core::context::ContextualUserFragment; use codex_core::context::PermissionsInstructions; use codex_core::load_exec_policy; @@ -551,6 +552,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config.config_layer_stack = ConfigLayerStack::default(); }); let test = builder.build(&server).await?;