diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index ac8d5c4010..a6018ecce2 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -39,6 +39,13 @@ }, "type": "object" }, + "PermissionGrantScope": { + "enum": [ + "turn", + "session" + ], + "type": "string" + }, "RequestPermissionProfile": { "additionalProperties": false, "properties": { @@ -79,6 +86,14 @@ "null" ] }, + "suggestedScope": { + "allOf": [ + { + "$ref": "#/definitions/PermissionGrantScope" + } + ], + "default": "turn" + }, "threadId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 7c11a4c02b..b87a47549c 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -1344,6 +1344,13 @@ } ] }, + "PermissionGrantScope": { + "enum": [ + "turn", + "session" + ], + "type": "string" + }, "PermissionsRequestApprovalParams": { "properties": { "itemId": { @@ -1358,6 +1365,14 @@ "null" ] }, + "suggestedScope": { + "allOf": [ + { + "$ref": "#/definitions/PermissionGrantScope" + } + ], + "default": "turn" + }, "threadId": { "type": "string" }, 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 267c4fd5f3..7ce48fb520 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 @@ -3285,6 +3285,14 @@ "null" ] }, + "suggestedScope": { + "allOf": [ + { + "$ref": "#/definitions/PermissionGrantScope" + } + ], + "default": "turn" + }, "threadId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts index efdefead79..68fca99bf6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PermissionGrantScope } from "./PermissionGrantScope"; import type { RequestPermissionProfile } from "./RequestPermissionProfile"; -export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, reason: string | null, permissions: RequestPermissionProfile, }; +export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, reason: string | null, permissions: RequestPermissionProfile, suggestedScope: PermissionGrantScope, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 30adc152ea..4e0aa36385 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -6219,6 +6219,8 @@ pub struct PermissionsRequestApprovalParams { pub item_id: String, pub reason: Option, pub permissions: RequestPermissionProfile, + #[serde(default)] + pub suggested_scope: PermissionGrantScope, } v2_enum_from_core!( @@ -6604,6 +6606,25 @@ mod tests { }), } ); + assert_eq!(params.suggested_scope, PermissionGrantScope::Turn); + } + + #[test] + fn permissions_request_approval_preserves_suggested_scope() { + let params = serde_json::from_value::(json!({ + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "reason": "Select a workspace root", + "permissions": { + "network": null, + "fileSystem": null, + }, + "suggestedScope": "session", + })) + .expect("permissions request should deserialize"); + + assert_eq!(params.suggested_scope, PermissionGrantScope::Session); } #[test] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a325e2e458..5073abffdd 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1079,7 +1079,7 @@ the client can offer session-scoped and/or persistent approval choices. Clients must opt into conversational permission confirmation request methods by listing them in `initialize.params.capabilities.supportedServerRequests`. The opt-in is per connection and per method, which lets older clients continue to initialize without promising UI they do not implement. -The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access. +The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the command-execution `additionalPermissions` shape: it can request network access and additional filesystem access, and it includes `suggestedScope` so clients can preselect the model's requested lifetime while still requiring explicit confirmation. ```json { @@ -1090,6 +1090,7 @@ The built-in `request_permissions` tool sends an `item/permissions/requestApprov "turnId": "turn_123", "itemId": "call_123", "reason": "Select a workspace root", + "suggestedScope": "session", "permissions": { "fileSystem": { "write": ["/Users/me/project", "/Users/me/shared"] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 6baf26bc8a..cf83575460 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -866,6 +866,7 @@ pub(crate) async fn apply_bespoke_event_handling( item_id: request.call_id.clone(), reason: request.reason, permissions: request.permissions.into(), + suggested_scope: request.suggested_scope.into(), }; let (pending_request_id, rx) = request_permissions_outgoing .send_request(ServerRequestPayload::PermissionsRequestApproval(params)) diff --git a/codex-rs/app-server/tests/common/responses.rs b/codex-rs/app-server/tests/common/responses.rs index 586d1446c0..fd68a4d756 100644 --- a/codex-rs/app-server/tests/common/responses.rs +++ b/codex-rs/app-server/tests/common/responses.rs @@ -1,3 +1,4 @@ +use codex_protocol::request_permissions::PermissionGrantScope; use core_test_support::responses; use serde_json::json; use std::path::Path; @@ -84,7 +85,10 @@ pub fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result anyhow::Result { +pub fn create_request_permissions_sse_response( + call_id: &str, + scope: PermissionGrantScope, +) -> anyhow::Result { let tool_call_arguments = serde_json::to_string(&json!({ "reason": "Select a workspace root", "permissions": { @@ -94,7 +98,8 @@ pub fn create_request_permissions_sse_response(call_id: &str) -> anyhow::Result< "../shared" ] } - } + }, + "scope": scope, }))?; Ok(responses::sse(vec![ diff --git a/codex-rs/app-server/tests/suite/v2/request_permissions.rs b/codex-rs/app-server/tests/suite/v2/request_permissions.rs index 1d53e9fff0..d38ce8acc2 100644 --- a/codex-rs/app-server/tests/suite/v2/request_permissions.rs +++ b/codex-rs/app-server/tests/suite/v2/request_permissions.rs @@ -18,6 +18,7 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; +use codex_protocol::request_permissions::PermissionGrantScope; use serde_json::Value; use serde_json::json; use tokio::time::timeout; @@ -75,7 +76,7 @@ async fn permission_tools_expose_only_the_narrow_flow_for_now() -> Result<()> { async fn request_permissions_round_trips_when_client_supports_app_request() -> Result<()> { let codex_home = tempfile::TempDir::new()?; let responses = vec![ - create_request_permissions_sse_response("call1")?, + create_request_permissions_sse_response("call1", PermissionGrantScope::Session)?, create_final_assistant_message_sse_response("done")?, ]; let server = create_mock_responses_server_sequence(responses).await; @@ -101,6 +102,10 @@ async fn request_permissions_round_trips_when_client_supports_app_request() -> R assert_eq!(params.thread_id, thread_id); assert_eq!(params.item_id, "call1"); assert_eq!(params.reason.as_deref(), Some("Select a workspace root")); + assert_eq!( + params.suggested_scope, + codex_app_server_protocol::PermissionGrantScope::Session + ); let resolved_request_id = request_id.clone(); mcp.send_response( @@ -121,7 +126,7 @@ async fn request_permissions_round_trips_when_client_supports_app_request() -> R async fn request_permissions_auto_declines_without_app_request() -> Result<()> { let codex_home = tempfile::TempDir::new()?; let responses = vec![ - create_request_permissions_sse_response("call1")?, + create_request_permissions_sse_response("call1", PermissionGrantScope::Turn)?, create_final_assistant_message_sse_response("done")?, ]; let server = create_mock_responses_server_sequence(responses).await; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a8db00dc21..e1413de266 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1840,6 +1840,7 @@ impl App { call_id: params.item_id.clone(), reason: params.reason.clone(), permissions: params.permissions.clone().into(), + suggested_scope: params.suggested_scope.to_core(), }), ), _ => None, @@ -6422,6 +6423,7 @@ mod tests { use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TurnContextItem; + use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; @@ -8681,11 +8683,13 @@ guardian_approval = true write: Some(vec![test_absolute_path("/tmp/write")]), }), }, + suggested_scope: codex_app_server_protocol::PermissionGrantScope::Turn, }, }; let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Permissions { permissions, + suggested_scope, .. })) = app .interactive_request_for_thread_request(thread_id, &request) @@ -8706,6 +8710,7 @@ guardian_approval = true }), } ); + assert_eq!(suggested_scope, PermissionGrantScope::Turn); } #[tokio::test] diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index b1393e95e0..b6a4d3ecb3 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -368,6 +368,7 @@ mod tests { "network": { "enabled": null } })) .expect("valid permissions"), + suggested_scope: codex_app_server_protocol::PermissionGrantScope::Turn, }, }), None diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 96b86aacf6..ba22ff756d 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -60,6 +60,7 @@ pub(crate) enum ApprovalRequest { call_id: String, reason: Option, permissions: RequestPermissionProfile, + suggested_scope: PermissionGrantScope, }, ApplyPatch { thread_id: ThreadId, @@ -144,7 +145,7 @@ impl ApprovalOverlay { header: Box, _features: &Features, ) -> (Vec, SelectionViewParams) { - let (options, title) = match request { + let (options, title, initial_selected_idx) = match request { ApprovalRequest::Exec { available_decisions, network_approval_context, @@ -165,18 +166,24 @@ impl ApprovalOverlay { ) }, ), + None, ), - ApprovalRequest::Permissions { .. } => ( + ApprovalRequest::Permissions { + suggested_scope, .. + } => ( permissions_options(), "Would you like to grant these permissions?".to_string(), + matches!(suggested_scope, PermissionGrantScope::Session).then_some(1), ), ApprovalRequest::ApplyPatch { .. } => ( patch_options(), "Would you like to make the following edits?".to_string(), + None, ), ApprovalRequest::McpElicitation { server_name, .. } => ( elicitation_options(), format!("{server_name} needs your approval."), + None, ), }; @@ -202,6 +209,7 @@ impl ApprovalOverlay { footer_hint: Some(approval_footer_hint(request)), items, header, + initial_selected_idx, ..Default::default() }; @@ -918,6 +926,7 @@ mod tests { write: Some(vec![absolute_path("/tmp/out.txt")]), }), }, + suggested_scope: PermissionGrantScope::Turn, } } @@ -1258,6 +1267,40 @@ mod tests { ); } + #[test] + fn permissions_session_suggestion_preselects_session_scope() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut request = make_permissions_request(); + let ApprovalRequest::Permissions { + suggested_scope, .. + } = &mut request + else { + panic!("expected permissions request"); + }; + *suggested_scope = PermissionGrantScope::Session; + let mut view = ApprovalOverlay::new(request, tx, Features::with_defaults()); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::RequestPermissionsResponse { response, .. }, + .. + } = ev + { + assert_eq!(response.scope, PermissionGrantScope::Session); + saw_op = true; + break; + } + } + assert!( + saw_op, + "expected preselected session approval to submit a session-scoped response" + ); + } + #[test] fn additional_permissions_prompt_shows_permission_rule_line() { let (tx, _rx) = unbounded_channel::(); @@ -1349,6 +1392,25 @@ mod tests { ); } + #[test] + fn permissions_prompt_session_suggestion_snapshot() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut request = make_permissions_request(); + let ApprovalRequest::Permissions { + suggested_scope, .. + } = &mut request + else { + panic!("expected permissions request"); + }; + *suggested_scope = PermissionGrantScope::Session; + let view = ApprovalOverlay::new(request, tx, Features::with_defaults()); + assert_snapshot!( + "approval_overlay_permissions_prompt_session_suggested", + normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120)) + ); + } + #[test] fn network_exec_prompt_title_includes_host() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt_session_suggested.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt_session_suggested.snap new file mode 100644 index 0000000000..478bf4c07f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt_session_suggested.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/bottom_pane/approval_overlay.rs +expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))" +--- + + Would you like to grant these permissions? + + Reason: need workspace access + + Permission rule: network; read `/tmp/readme.txt`; write `/tmp/out.txt` + + 1. Yes, grant these permissions (y) +› 2. Yes, grant these permissions for this session (a) + 3. No, continue without permissions (n) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3cd0dba4b2..aa38d03d63 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1572,7 +1572,7 @@ fn request_permissions_from_params( call_id: params.item_id, reason: params.reason, permissions: params.permissions.into(), - suggested_scope: codex_protocol::request_permissions::PermissionGrantScope::Turn, + suggested_scope: params.suggested_scope.to_core(), } } @@ -4582,6 +4582,7 @@ impl ChatWidget { call_id: ev.call_id, reason: ev.reason, permissions: ev.permissions, + suggested_scope: ev.suggested_scope, }; self.bottom_pane .push_approval_request(request, &self.config.features); diff --git a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs index ee686e004b..1ea02398ea 100644 --- a/codex-rs/tui/src/chatwidget/tests/approval_requests.rs +++ b/codex-rs/tui/src/chatwidget/tests/approval_requests.rs @@ -160,6 +160,7 @@ fn app_server_request_permissions_preserves_file_system_permissions() { write: Some(vec![write_path.clone()]), }), }, + suggested_scope: codex_app_server_protocol::PermissionGrantScope::Turn, }); assert_eq!( @@ -174,6 +175,10 @@ fn app_server_request_permissions_preserves_file_system_permissions() { }), } ); + assert_eq!( + request.suggested_scope, + codex_protocol::request_permissions::PermissionGrantScope::Turn + ); } #[tokio::test]