mirror of
https://github.com/openai/codex.git
synced 2026-10-24 18:18:41 +00:00
feat(app-server): gate permission confirmations by capability
Add initialize-time server request capabilities so app-server only sends conversational permission confirmation requests to clients that advertise support. Unsupported clients still fail closed without changing permissions, while capable clients receive the existing request/response flow for narrow grants and preset picker requests.
This commit is contained in:
@@ -500,6 +500,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
supported_server_requests: None,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
},
|
||||
@@ -591,6 +592,7 @@ async fn compaction_event_ingests_custom_fact() {
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
supported_server_requests: None,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -378,6 +378,7 @@ impl InProcessClientStartArgs {
|
||||
pub fn initialize_params(&self) -> InitializeParams {
|
||||
let capabilities = InitializeCapabilities {
|
||||
experimental_api: self.experimental_api,
|
||||
supported_server_requests: None,
|
||||
opt_out_notification_methods: if self.opt_out_notification_methods.is_empty() {
|
||||
None
|
||||
} else {
|
||||
|
||||
@@ -72,6 +72,7 @@ impl RemoteAppServerConnectArgs {
|
||||
fn initialize_params(&self) -> InitializeParams {
|
||||
let capabilities = InitializeCapabilities {
|
||||
experimental_api: self.experimental_api,
|
||||
supported_server_requests: None,
|
||||
opt_out_notification_methods: if self.opt_out_notification_methods.is_empty() {
|
||||
None
|
||||
} else {
|
||||
|
||||
@@ -1022,6 +1022,16 @@
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"supportedServerRequests": {
|
||||
"description": "Server request methods this connection knows how to render and answer.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/SupportedServerRequestMethod"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -2569,6 +2579,14 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"SupportedServerRequestMethod": {
|
||||
"description": "Server-initiated request methods a client can opt into during initialize.",
|
||||
"enum": [
|
||||
"item/permissions/requestApproval",
|
||||
"item/permissionPreset/requestApproval"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"TextElement": {
|
||||
"properties": {
|
||||
"byteRange": {
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"PermissionPresetId": {
|
||||
"description": "A built-in permission-mode preset that app clients can confirm.",
|
||||
"enum": [
|
||||
"auto",
|
||||
"full-access",
|
||||
@@ -246,6 +247,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "Request sent to app clients when a model asks to switch permission modes.\n\nCore resolves the requested preset before emitting this payload, so clients should render the provided settings and label rather than recomputing policy choices from the preset id.",
|
||||
"properties": {
|
||||
"approvalPolicy": {
|
||||
"$ref": "#/definitions/AskForApproval"
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"PermissionPresetId": {
|
||||
"description": "A built-in permission-mode preset that app clients can confirm.",
|
||||
"enum": [
|
||||
"auto",
|
||||
"full-access",
|
||||
@@ -18,6 +19,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Response from an app client after the permission preset picker resolves.",
|
||||
"properties": {
|
||||
"decision": {
|
||||
"$ref": "#/definitions/PermissionPresetApprovalDecision"
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"description": "Request sent to app clients when a model asks for a narrow permission grant.\n\nThe client owns the confirmation UI and may return fewer permissions than requested. Treat `suggested_scope` as the model's requested lifetime, not as an already-approved scope.",
|
||||
"properties": {
|
||||
"itemId": {
|
||||
"type": "string"
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Response from an app client after the narrow permission grant UI resolves.\n\nReturning an empty permission profile is a denial. Returning a non-empty profile grants only those permissions, and core applies them for the specified scope.",
|
||||
"properties": {
|
||||
"permissions": {
|
||||
"$ref": "#/definitions/GrantedPermissionProfile"
|
||||
|
||||
@@ -1417,6 +1417,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"PermissionPresetId": {
|
||||
"description": "A built-in permission-mode preset that app clients can confirm.",
|
||||
"enum": [
|
||||
"auto",
|
||||
"full-access",
|
||||
@@ -1426,6 +1427,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"PermissionPresetRequestApprovalParams": {
|
||||
"description": "Request sent to app clients when a model asks to switch permission modes.\n\nCore resolves the requested preset before emitting this payload, so clients should render the provided settings and label rather than recomputing policy choices from the preset id.",
|
||||
"properties": {
|
||||
"approvalPolicy": {
|
||||
"$ref": "#/definitions/AskForApproval"
|
||||
@@ -1475,6 +1477,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"PermissionsRequestApprovalParams": {
|
||||
"description": "Request sent to app clients when a model asks for a narrow permission grant.\n\nThe client owns the confirmation UI and may return fewer permissions than requested. Treat `suggested_scope` as the model's requested lifetime, not as an already-approved scope.",
|
||||
"properties": {
|
||||
"itemId": {
|
||||
"type": "string"
|
||||
|
||||
@@ -2295,6 +2295,16 @@
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"supportedServerRequests": {
|
||||
"description": "Server request methods this connection knows how to render and answer.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/SupportedServerRequestMethod"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -3268,6 +3278,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"PermissionPresetId": {
|
||||
"description": "A built-in permission-mode preset that app clients can confirm.",
|
||||
"enum": [
|
||||
"auto",
|
||||
"full-access",
|
||||
@@ -3278,6 +3289,7 @@
|
||||
},
|
||||
"PermissionPresetRequestApprovalParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Request sent to app clients when a model asks to switch permission modes.\n\nCore resolves the requested preset before emitting this payload, so clients should render the provided settings and label rather than recomputing policy choices from the preset id.",
|
||||
"properties": {
|
||||
"approvalPolicy": {
|
||||
"$ref": "#/definitions/v2/AskForApproval"
|
||||
@@ -3329,6 +3341,7 @@
|
||||
},
|
||||
"PermissionPresetRequestApprovalResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Response from an app client after the permission preset picker resolves.",
|
||||
"properties": {
|
||||
"decision": {
|
||||
"$ref": "#/definitions/PermissionPresetApprovalDecision"
|
||||
@@ -3346,6 +3359,7 @@
|
||||
},
|
||||
"PermissionsRequestApprovalParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Request sent to app clients when a model asks for a narrow permission grant.\n\nThe client owns the confirmation UI and may return fewer permissions than requested. Treat `suggested_scope` as the model's requested lifetime, not as an already-approved scope.",
|
||||
"properties": {
|
||||
"itemId": {
|
||||
"type": "string"
|
||||
@@ -3385,6 +3399,7 @@
|
||||
},
|
||||
"PermissionsRequestApprovalResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Response from an app client after the narrow permission grant UI resolves.\n\nReturning an empty permission profile is a denial. Returning a non-empty profile grants only those permissions, and core applies them for the specified scope.",
|
||||
"properties": {
|
||||
"permissions": {
|
||||
"$ref": "#/definitions/GrantedPermissionProfile"
|
||||
@@ -4838,6 +4853,14 @@
|
||||
],
|
||||
"title": "ServerRequest"
|
||||
},
|
||||
"SupportedServerRequestMethod": {
|
||||
"description": "Server-initiated request methods a client can opt into during initialize.",
|
||||
"enum": [
|
||||
"item/permissions/requestApproval",
|
||||
"item/permissionPreset/requestApproval"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ToolRequestUserInputAnswer": {
|
||||
"description": "EXPERIMENTAL. Captures a user's answer to a request_user_input question.",
|
||||
"properties": {
|
||||
|
||||
@@ -5347,6 +5347,16 @@
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"supportedServerRequests": {
|
||||
"description": "Server request methods this connection knows how to render and answer.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/SupportedServerRequestMethod"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -10257,6 +10267,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"SupportedServerRequestMethod": {
|
||||
"description": "Server-initiated request methods a client can opt into during initialize.",
|
||||
"enum": [
|
||||
"item/permissions/requestApproval",
|
||||
"item/permissionPreset/requestApproval"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"TerminalInteractionNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
|
||||
@@ -39,9 +39,27 @@
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"supportedServerRequests": {
|
||||
"description": "Server request methods this connection knows how to render and answer.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/SupportedServerRequestMethod"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"SupportedServerRequestMethod": {
|
||||
"description": "Server-initiated request methods a client can opt into during initialize.",
|
||||
"enum": [
|
||||
"item/permissions/requestApproval",
|
||||
"item/permissionPreset/requestApproval"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
|
||||
@@ -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 { SupportedServerRequestMethod } from "./SupportedServerRequestMethod";
|
||||
|
||||
/**
|
||||
* Client-declared capabilities negotiated during initialize.
|
||||
@@ -10,6 +11,10 @@ export type InitializeCapabilities = {
|
||||
* Opt into receiving experimental API methods and fields.
|
||||
*/
|
||||
experimentalApi: boolean,
|
||||
/**
|
||||
* Server request methods this connection knows how to render and answer.
|
||||
*/
|
||||
supportedServerRequests?: Array<SupportedServerRequestMethod> | null,
|
||||
/**
|
||||
* Exact notification method names that should be suppressed for this
|
||||
* connection (for example `thread/started`).
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Server-initiated request methods a client can opt into during initialize.
|
||||
*/
|
||||
export type SupportedServerRequestMethod = "item/permissions/requestApproval" | "item/permissionPreset/requestApproval";
|
||||
@@ -67,6 +67,7 @@ export type { ServiceTier } from "./ServiceTier";
|
||||
export type { SessionSource } from "./SessionSource";
|
||||
export type { Settings } from "./Settings";
|
||||
export type { SubAgentSource } from "./SubAgentSource";
|
||||
export type { SupportedServerRequestMethod } from "./SupportedServerRequestMethod";
|
||||
export type { ThreadId } from "./ThreadId";
|
||||
export type { Tool } from "./Tool";
|
||||
export type { Verbosity } from "./Verbosity";
|
||||
|
||||
@@ -2,4 +2,7 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* A built-in permission-mode preset that app clients can confirm.
|
||||
*/
|
||||
export type PermissionPresetId = "auto" | "full-access" | "read-only" | "guardian-approvals";
|
||||
|
||||
+7
@@ -6,4 +6,11 @@ import type { AskForApproval } from "./AskForApproval";
|
||||
import type { PermissionPresetId } from "./PermissionPresetId";
|
||||
import type { SandboxPolicy } from "./SandboxPolicy";
|
||||
|
||||
/**
|
||||
* Request sent to app clients when a model asks to switch permission modes.
|
||||
*
|
||||
* Core resolves the requested preset before emitting this payload, so clients
|
||||
* should render the provided settings and label rather than recomputing policy
|
||||
* choices from the preset id.
|
||||
*/
|
||||
export type PermissionPresetRequestApprovalParams = { threadId: string, turnId: string, itemId: string, preset: PermissionPresetId, label: string, description: string, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, reason: string | null, };
|
||||
|
||||
+3
@@ -4,4 +4,7 @@
|
||||
import type { PermissionPresetApprovalDecision } from "./PermissionPresetApprovalDecision";
|
||||
import type { PermissionPresetId } from "./PermissionPresetId";
|
||||
|
||||
/**
|
||||
* Response from an app client after the permission preset picker resolves.
|
||||
*/
|
||||
export type PermissionPresetRequestApprovalResponse = { decision: PermissionPresetApprovalDecision, preset: PermissionPresetId, };
|
||||
|
||||
@@ -4,4 +4,11 @@
|
||||
import type { PermissionGrantScope } from "./PermissionGrantScope";
|
||||
import type { RequestPermissionProfile } from "./RequestPermissionProfile";
|
||||
|
||||
/**
|
||||
* Request sent to app clients when a model asks for a narrow permission grant.
|
||||
*
|
||||
* The client owns the confirmation UI and may return fewer permissions than
|
||||
* requested. Treat `suggested_scope` as the model's requested lifetime, not as
|
||||
* an already-approved scope.
|
||||
*/
|
||||
export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, reason: string | null, permissions: RequestPermissionProfile, suggestedScope: PermissionGrantScope, };
|
||||
|
||||
+7
@@ -4,4 +4,11 @@
|
||||
import type { GrantedPermissionProfile } from "./GrantedPermissionProfile";
|
||||
import type { PermissionGrantScope } from "./PermissionGrantScope";
|
||||
|
||||
/**
|
||||
* Response from an app client after the narrow permission grant UI resolves.
|
||||
*
|
||||
* Returning an empty permission profile is a denial. Returning a non-empty
|
||||
* profile grants only those permissions, and core applies them for the
|
||||
* specified scope.
|
||||
*/
|
||||
export type PermissionsRequestApprovalResponse = { permissions: GrantedPermissionProfile, scope: PermissionGrantScope, };
|
||||
|
||||
@@ -37,6 +37,7 @@ pub use protocol::v1::InterruptConversationResponse;
|
||||
pub use protocol::v1::LoginApiKeyParams;
|
||||
pub use protocol::v1::Profile;
|
||||
pub use protocol::v1::SandboxSettings;
|
||||
pub use protocol::v1::SupportedServerRequestMethod;
|
||||
pub use protocol::v1::Tools;
|
||||
pub use protocol::v1::UserSavedConfig;
|
||||
pub use protocol::v2::*;
|
||||
|
||||
@@ -1103,6 +1103,10 @@ mod tests {
|
||||
},
|
||||
capabilities: Some(v1::InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
supported_server_requests: Some(vec![
|
||||
v1::SupportedServerRequestMethod::PermissionsRequestApproval,
|
||||
v1::SupportedServerRequestMethod::PermissionPresetRequestApproval,
|
||||
]),
|
||||
opt_out_notification_methods: Some(vec![
|
||||
"thread/started".to_string(),
|
||||
"item/agentMessage/delta".to_string(),
|
||||
@@ -1123,6 +1127,10 @@ mod tests {
|
||||
},
|
||||
"capabilities": {
|
||||
"experimentalApi": true,
|
||||
"supportedServerRequests": [
|
||||
"item/permissions/requestApproval",
|
||||
"item/permissionPreset/requestApproval"
|
||||
],
|
||||
"optOutNotificationMethods": [
|
||||
"thread/started",
|
||||
"item/agentMessage/delta"
|
||||
@@ -1148,6 +1156,10 @@ mod tests {
|
||||
},
|
||||
"capabilities": {
|
||||
"experimentalApi": true,
|
||||
"supportedServerRequests": [
|
||||
"item/permissions/requestApproval",
|
||||
"item/permissionPreset/requestApproval"
|
||||
],
|
||||
"optOutNotificationMethods": [
|
||||
"thread/started",
|
||||
"item/agentMessage/delta"
|
||||
@@ -1168,6 +1180,10 @@ mod tests {
|
||||
},
|
||||
capabilities: Some(v1::InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
supported_server_requests: Some(vec![
|
||||
v1::SupportedServerRequestMethod::PermissionsRequestApproval,
|
||||
v1::SupportedServerRequestMethod::PermissionPresetRequestApproval,
|
||||
]),
|
||||
opt_out_notification_methods: Some(vec![
|
||||
"thread/started".to_string(),
|
||||
"item/agentMessage/delta".to_string(),
|
||||
@@ -1179,6 +1195,31 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_initialize_without_supported_server_requests() -> Result<()> {
|
||||
let request: ClientRequest = serde_json::from_value(json!({
|
||||
"method": "initialize",
|
||||
"id": 42,
|
||||
"params": {
|
||||
"clientInfo": {
|
||||
"name": "codex_vscode",
|
||||
"title": "Codex VS Code Extension",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"capabilities": {
|
||||
"experimentalApi": true
|
||||
}
|
||||
}
|
||||
}))?;
|
||||
|
||||
let ClientRequest::Initialize { params, .. } = request else {
|
||||
panic!("expected initialize request");
|
||||
};
|
||||
let capabilities = params.capabilities.expect("capabilities");
|
||||
assert_eq!(capabilities.supported_server_requests, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_id_serializes_as_plain_string() -> Result<()> {
|
||||
let id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?;
|
||||
|
||||
@@ -46,12 +46,26 @@ pub struct InitializeCapabilities {
|
||||
/// Opt into receiving experimental API methods and fields.
|
||||
#[serde(default)]
|
||||
pub experimental_api: bool,
|
||||
/// Server request methods this connection knows how to render and answer.
|
||||
#[ts(optional = nullable)]
|
||||
pub supported_server_requests: Option<Vec<SupportedServerRequestMethod>>,
|
||||
/// Exact notification method names that should be suppressed for this
|
||||
/// connection (for example `thread/started`).
|
||||
#[ts(optional = nullable)]
|
||||
pub opt_out_notification_methods: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Server-initiated request methods a client can opt into during initialize.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema, TS)]
|
||||
pub enum SupportedServerRequestMethod {
|
||||
#[serde(rename = "item/permissions/requestApproval")]
|
||||
#[ts(rename = "item/permissions/requestApproval")]
|
||||
PermissionsRequestApproval,
|
||||
#[serde(rename = "item/permissionPreset/requestApproval")]
|
||||
#[ts(rename = "item/permissionPreset/requestApproval")]
|
||||
PermissionPresetRequestApproval,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InitializeResponse {
|
||||
|
||||
@@ -1553,6 +1553,7 @@ impl CodexClient {
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api,
|
||||
supported_server_requests: None,
|
||||
opt_out_notification_methods: Some(
|
||||
NOTIFICATIONS_TO_OPT_OUT
|
||||
.iter()
|
||||
|
||||
@@ -89,6 +89,8 @@ Clients must send a single `initialize` request per transport connection before
|
||||
|
||||
`initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored.
|
||||
|
||||
Clients can also opt into specific server-initiated request methods through `supportedServerRequests`. This is required for conversational permission confirmations such as `item/permissions/requestApproval` and `item/permissionPreset/requestApproval`: if a connection omits the matching method, app-server does not send that request to the client and the permission change is rejected without changing the session.
|
||||
|
||||
Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter.
|
||||
|
||||
**Important**: `clientInfo.name` is used to identify the client for the OpenAI Compliance Logs Platform. If
|
||||
@@ -111,7 +113,7 @@ Example (from OpenAI's official VSCode extension):
|
||||
}
|
||||
```
|
||||
|
||||
Example with notification opt-out:
|
||||
Example with notification opt-out and permission request support:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -125,6 +127,10 @@ Example with notification opt-out:
|
||||
},
|
||||
"capabilities": {
|
||||
"experimentalApi": true,
|
||||
"supportedServerRequests": [
|
||||
"item/permissions/requestApproval",
|
||||
"item/permissionPreset/requestApproval"
|
||||
],
|
||||
"optOutNotificationMethods": ["thread/started", "item/agentMessage/delta"]
|
||||
}
|
||||
}
|
||||
@@ -1071,6 +1077,8 @@ the client can offer session-scoped and/or persistent approval choices.
|
||||
|
||||
### Permission requests
|
||||
|
||||
Clients must opt into each conversational permission request method by listing it in `initialize.params.capabilities.supportedServerRequests`. The opt-in is per connection and per method. Existing clients that do not advertise support keep their current behavior: app-server does not emit an unsupported approval request, no permissions are changed, and the model receives a denial-style result.
|
||||
|
||||
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.
|
||||
|
||||
```json
|
||||
@@ -1113,6 +1121,8 @@ Within the same turn, granted permissions are sticky: later shell-like tool call
|
||||
|
||||
If the session approval policy uses `Granular` with `request_permissions: false`, standalone `request_permissions` tool calls are auto-denied and no `item/permissions/requestApproval` prompt is sent. Inline `with_additional_permissions` command requests remain controlled by `sandbox_approval`, and any previously granted permissions remain sticky for later shell-like calls in the same turn.
|
||||
|
||||
The built-in `request_permission_preset` tool sends an `item/permissionPreset/requestApproval` JSON-RPC request when a model asks the client to open the permission preset picker. The request includes the resolved preset id, label, description, approval policy, approvals reviewer, sandbox policy, and optional reason so clients can show their native picker with the requested option already selected. Clients respond with `{ "decision": "accepted", "preset": "full-access" }` or `{ "decision": "declined", "preset": "full-access" }`. When the client omits `item/permissionPreset/requestApproval` from `supportedServerRequests`, app-server declines the preset request and leaves the active permissions unchanged.
|
||||
|
||||
### Dynamic tool calls (experimental)
|
||||
|
||||
`dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`.
|
||||
|
||||
@@ -172,6 +172,8 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
conversation: Arc<CodexThread>,
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
outgoing: ThreadScopedOutgoingMessageSender,
|
||||
request_permissions_outgoing: ThreadScopedOutgoingMessageSender,
|
||||
request_permission_preset_outgoing: ThreadScopedOutgoingMessageSender,
|
||||
thread_state: Arc<tokio::sync::Mutex<ThreadState>>,
|
||||
thread_watch_manager: ThreadWatchManager,
|
||||
api_version: ApiVersion,
|
||||
@@ -858,7 +860,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
}
|
||||
}
|
||||
EventMsg::RequestPermissions(request) => {
|
||||
if matches!(api_version, ApiVersion::V2) {
|
||||
if matches!(api_version, ApiVersion::V2) && !request_permissions_outgoing.is_empty() {
|
||||
let permission_guard = thread_watch_manager
|
||||
.note_permission_requested(&conversation_id.to_string())
|
||||
.await;
|
||||
@@ -871,7 +873,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
permissions: request.permissions.into(),
|
||||
suggested_scope: request.suggested_scope.into(),
|
||||
};
|
||||
let (pending_request_id, rx) = outgoing
|
||||
let (pending_request_id, rx) = request_permissions_outgoing
|
||||
.send_request(ServerRequestPayload::PermissionsRequestApproval(params))
|
||||
.await;
|
||||
tokio::spawn(async move {
|
||||
@@ -887,8 +889,11 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.await;
|
||||
});
|
||||
} else {
|
||||
error!(
|
||||
"request_permissions is only supported on api v2 (call_id: {})",
|
||||
// App-server clients must advertise support before receiving
|
||||
// permission confirmation requests. Fail closed instead of
|
||||
// emitting a request that existing clients cannot answer.
|
||||
warn!(
|
||||
"request_permissions is not supported by this app-server client (call_id: {})",
|
||||
request.call_id
|
||||
);
|
||||
let empty = CoreRequestPermissionsResponse {
|
||||
@@ -907,7 +912,9 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
}
|
||||
}
|
||||
EventMsg::RequestPermissionPreset(request) => {
|
||||
if matches!(api_version, ApiVersion::V2) {
|
||||
if matches!(api_version, ApiVersion::V2)
|
||||
&& !request_permission_preset_outgoing.is_empty()
|
||||
{
|
||||
let params = PermissionPresetRequestApprovalParams {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: request.turn_id.clone(),
|
||||
@@ -920,7 +927,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
sandbox_policy: request.sandbox_policy.into(),
|
||||
reason: request.reason,
|
||||
};
|
||||
let (pending_request_id, rx) = outgoing
|
||||
let (pending_request_id, rx) = request_permission_preset_outgoing
|
||||
.send_request(ServerRequestPayload::PermissionPresetRequestApproval(
|
||||
params,
|
||||
))
|
||||
@@ -937,14 +944,17 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.await;
|
||||
});
|
||||
} else {
|
||||
error!(
|
||||
"request_permission_preset is only supported on api v2 (call_id: {})",
|
||||
// App-server clients must advertise support before receiving
|
||||
// permission confirmation requests. Fail closed instead of
|
||||
// emitting a request that existing clients cannot answer.
|
||||
warn!(
|
||||
"request_permission_preset is not supported by this app-server client (call_id: {})",
|
||||
request.call_id
|
||||
);
|
||||
let declined = CoreRequestPermissionPresetResponse {
|
||||
decision: CoreRequestPermissionPresetDecision::Declined,
|
||||
preset: request.preset,
|
||||
message: "request_permission_preset is only supported on api v2".to_string(),
|
||||
message: "permission confirmation UI is not available in this client; no permissions were changed".to_string(),
|
||||
};
|
||||
if let Err(err) = conversation
|
||||
.submit(Op::RequestPermissionPresetResponse {
|
||||
@@ -3193,6 +3203,8 @@ mod tests {
|
||||
self.conversation.clone(),
|
||||
self.thread_manager.clone(),
|
||||
self.outgoing.clone(),
|
||||
self.outgoing.clone(),
|
||||
self.outgoing.clone(),
|
||||
self.thread_state.clone(),
|
||||
self.thread_watch_manager.clone(),
|
||||
ApiVersion::V2,
|
||||
|
||||
@@ -117,6 +117,7 @@ use codex_app_server_protocol::SkillsConfigWriteParams;
|
||||
use codex_app_server_protocol::SkillsConfigWriteResponse;
|
||||
use codex_app_server_protocol::SkillsListParams;
|
||||
use codex_app_server_protocol::SkillsListResponse;
|
||||
use codex_app_server_protocol::SupportedServerRequestMethod;
|
||||
use codex_app_server_protocol::Thread;
|
||||
use codex_app_server_protocol::ThreadArchiveParams;
|
||||
use codex_app_server_protocol::ThreadArchiveResponse;
|
||||
@@ -3738,9 +3739,13 @@ impl CodexMessageProcessor {
|
||||
self.thread_manager.subscribe_thread_created()
|
||||
}
|
||||
|
||||
pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) {
|
||||
pub(crate) async fn connection_initialized(
|
||||
&self,
|
||||
connection_id: ConnectionId,
|
||||
supported_server_requests: HashSet<SupportedServerRequestMethod>,
|
||||
) {
|
||||
self.thread_state_manager
|
||||
.connection_initialized(connection_id)
|
||||
.connection_initialized(connection_id, supported_server_requests)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -7534,11 +7539,27 @@ impl CodexMessageProcessor {
|
||||
let subscribed_connection_ids = thread_state_manager
|
||||
.subscribed_connection_ids(conversation_id)
|
||||
.await;
|
||||
let request_permissions_connection_ids = thread_state_manager
|
||||
.subscribed_connection_ids_supporting(
|
||||
conversation_id,
|
||||
SupportedServerRequestMethod::PermissionsRequestApproval,
|
||||
)
|
||||
.await;
|
||||
let request_permission_preset_connection_ids = thread_state_manager
|
||||
.subscribed_connection_ids_supporting(
|
||||
conversation_id,
|
||||
SupportedServerRequestMethod::PermissionPresetRequestApproval,
|
||||
)
|
||||
.await;
|
||||
let thread_outgoing = ThreadScopedOutgoingMessageSender::new(
|
||||
outgoing_for_task.clone(),
|
||||
subscribed_connection_ids,
|
||||
conversation_id,
|
||||
);
|
||||
let request_permissions_outgoing = thread_outgoing
|
||||
.with_connection_ids(request_permissions_connection_ids);
|
||||
let request_permission_preset_outgoing = thread_outgoing
|
||||
.with_connection_ids(request_permission_preset_connection_ids);
|
||||
|
||||
if let EventMsg::RawResponseItem(raw_response_item_event) = &event.msg
|
||||
&& !raw_events_enabled
|
||||
@@ -7560,6 +7581,8 @@ impl CodexMessageProcessor {
|
||||
conversation.clone(),
|
||||
thread_manager.clone(),
|
||||
thread_outgoing,
|
||||
request_permissions_outgoing,
|
||||
request_permission_preset_outgoing,
|
||||
thread_state.clone(),
|
||||
thread_watch_manager.clone(),
|
||||
api_version,
|
||||
@@ -9917,7 +9940,9 @@ mod tests {
|
||||
let connection = ConnectionId(1);
|
||||
let (cancel_tx, cancel_rx) = oneshot::channel();
|
||||
|
||||
manager.connection_initialized(connection).await;
|
||||
manager
|
||||
.connection_initialized(connection, HashSet::new())
|
||||
.await;
|
||||
manager
|
||||
.try_ensure_connection_subscribed(
|
||||
thread_id, connection, /*experimental_raw_events*/ false,
|
||||
@@ -9963,8 +9988,12 @@ mod tests {
|
||||
let connection_b = ConnectionId(2);
|
||||
let (cancel_tx, mut cancel_rx) = oneshot::channel();
|
||||
|
||||
manager.connection_initialized(connection_a).await;
|
||||
manager.connection_initialized(connection_b).await;
|
||||
manager
|
||||
.connection_initialized(connection_a, HashSet::new())
|
||||
.await;
|
||||
manager
|
||||
.connection_initialized(connection_b, HashSet::new())
|
||||
.await;
|
||||
manager
|
||||
.try_ensure_connection_subscribed(
|
||||
thread_id,
|
||||
@@ -10007,7 +10036,9 @@ mod tests {
|
||||
let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?;
|
||||
let connection = ConnectionId(1);
|
||||
|
||||
manager.connection_initialized(connection).await;
|
||||
manager
|
||||
.connection_initialized(connection, HashSet::new())
|
||||
.await;
|
||||
let threads_to_unload = manager.remove_connection(connection).await;
|
||||
assert_eq!(threads_to_unload, Vec::<ThreadId>::new());
|
||||
|
||||
|
||||
@@ -797,7 +797,15 @@ pub async fn run_main_with_transport(
|
||||
connection_id,
|
||||
)
|
||||
.await;
|
||||
processor.connection_initialized(connection_id).await;
|
||||
processor
|
||||
.connection_initialized(
|
||||
connection_id,
|
||||
connection_state
|
||||
.session
|
||||
.supported_server_requests
|
||||
.clone(),
|
||||
)
|
||||
.await;
|
||||
connection_state
|
||||
.outbound_initialized
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
|
||||
@@ -56,6 +56,7 @@ use codex_app_server_protocol::JSONRPCRequest;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequestPayload;
|
||||
use codex_app_server_protocol::SupportedServerRequestMethod;
|
||||
use codex_app_server_protocol::experimental_required_message;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_chatgpt::connectors;
|
||||
@@ -179,6 +180,7 @@ pub(crate) struct ConnectionSessionState {
|
||||
pub(crate) initialized: bool,
|
||||
pub(crate) experimental_api_enabled: bool,
|
||||
pub(crate) opted_out_notification_methods: HashSet<String>,
|
||||
pub(crate) supported_server_requests: HashSet<SupportedServerRequestMethod>,
|
||||
pub(crate) app_server_client_name: Option<String>,
|
||||
pub(crate) client_version: Option<String>,
|
||||
}
|
||||
@@ -456,9 +458,13 @@ impl MessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) {
|
||||
pub(crate) async fn connection_initialized(
|
||||
&self,
|
||||
connection_id: ConnectionId,
|
||||
supported_server_requests: HashSet<SupportedServerRequestMethod>,
|
||||
) {
|
||||
self.codex_message_processor
|
||||
.connection_initialized(connection_id)
|
||||
.connection_initialized(connection_id, supported_server_requests)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -561,19 +567,28 @@ impl MessageProcessor {
|
||||
// experimental API). Proposed direction is instance-global first-write-wins
|
||||
// with initialize-time mismatch rejection.
|
||||
let analytics_initialize_params = params.clone();
|
||||
let (experimental_api_enabled, opt_out_notification_methods) =
|
||||
match params.capabilities {
|
||||
Some(capabilities) => (
|
||||
capabilities.experimental_api,
|
||||
capabilities
|
||||
.opt_out_notification_methods
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
None => (false, Vec::new()),
|
||||
};
|
||||
let (
|
||||
experimental_api_enabled,
|
||||
opt_out_notification_methods,
|
||||
supported_server_requests,
|
||||
) = match params.capabilities {
|
||||
Some(capabilities) => (
|
||||
capabilities.experimental_api,
|
||||
capabilities
|
||||
.opt_out_notification_methods
|
||||
.unwrap_or_default(),
|
||||
capabilities
|
||||
.supported_server_requests
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
None => (false, Vec::new(), HashSet::new()),
|
||||
};
|
||||
session.experimental_api_enabled = experimental_api_enabled;
|
||||
session.opted_out_notification_methods =
|
||||
opt_out_notification_methods.into_iter().collect();
|
||||
session.supported_server_requests = supported_server_requests;
|
||||
let ClientInfo {
|
||||
name,
|
||||
title: _title,
|
||||
@@ -649,7 +664,10 @@ impl MessageProcessor {
|
||||
// initialize handling for the specific connection.
|
||||
outbound_initialized.store(true, Ordering::Release);
|
||||
self.codex_message_processor
|
||||
.connection_initialized(connection_id)
|
||||
.connection_initialized(
|
||||
connection_id,
|
||||
session.supported_server_requests.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -145,6 +145,18 @@ impl ThreadScopedOutgoingMessageSender {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_connection_ids(&self, connection_ids: Vec<ConnectionId>) -> Self {
|
||||
Self {
|
||||
outgoing: Arc::clone(&self.outgoing),
|
||||
connection_ids: Arc::new(connection_ids),
|
||||
thread_id: self.thread_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.connection_ids.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) async fn send_request(
|
||||
&self,
|
||||
payload: ServerRequestPayload,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::outgoing_message::ConnectionId;
|
||||
use crate::outgoing_message::ConnectionRequestId;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SupportedServerRequestMethod;
|
||||
use codex_app_server_protocol::ThreadHistoryBuilder;
|
||||
use codex_app_server_protocol::Turn;
|
||||
use codex_app_server_protocol::TurnError;
|
||||
@@ -173,6 +174,8 @@ impl Default for ThreadEntry {
|
||||
#[derive(Default)]
|
||||
struct ThreadStateManagerInner {
|
||||
live_connections: HashSet<ConnectionId>,
|
||||
supported_server_requests_by_connection:
|
||||
HashMap<ConnectionId, HashSet<SupportedServerRequestMethod>>,
|
||||
threads: HashMap<ThreadId, ThreadEntry>,
|
||||
thread_ids_by_connection: HashMap<ConnectionId, HashSet<ThreadId>>,
|
||||
}
|
||||
@@ -187,21 +190,57 @@ impl ThreadStateManager {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) {
|
||||
self.state
|
||||
.lock()
|
||||
.await
|
||||
.live_connections
|
||||
.insert(connection_id);
|
||||
pub(crate) async fn connection_initialized(
|
||||
&self,
|
||||
connection_id: ConnectionId,
|
||||
supported_server_requests: HashSet<SupportedServerRequestMethod>,
|
||||
) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.live_connections.insert(connection_id);
|
||||
state
|
||||
.supported_server_requests_by_connection
|
||||
.insert(connection_id, supported_server_requests);
|
||||
}
|
||||
|
||||
pub(crate) async fn subscribed_connection_ids(&self, thread_id: ThreadId) -> Vec<ConnectionId> {
|
||||
let state = self.state.lock().await;
|
||||
state
|
||||
let mut connection_ids = state
|
||||
.threads
|
||||
.get(&thread_id)
|
||||
.map(|thread_entry| thread_entry.connection_ids.iter().copied().collect())
|
||||
.unwrap_or_default()
|
||||
.map(|thread_entry| {
|
||||
thread_entry
|
||||
.connection_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
sort_connection_ids(&mut connection_ids);
|
||||
connection_ids
|
||||
}
|
||||
|
||||
pub(crate) async fn subscribed_connection_ids_supporting(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
method: SupportedServerRequestMethod,
|
||||
) -> Vec<ConnectionId> {
|
||||
let state = self.state.lock().await;
|
||||
let Some(thread_entry) = state.threads.get(&thread_id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut connection_ids = thread_entry
|
||||
.connection_ids
|
||||
.iter()
|
||||
.filter(|connection_id| {
|
||||
state
|
||||
.supported_server_requests_by_connection
|
||||
.get(connection_id)
|
||||
.is_some_and(|supported| supported.contains(&method))
|
||||
})
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
sort_connection_ids(&mut connection_ids);
|
||||
connection_ids
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_state(&self, thread_id: ThreadId) -> Arc<Mutex<ThreadState>> {
|
||||
@@ -357,6 +396,9 @@ impl ThreadStateManager {
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.live_connections.remove(&connection_id);
|
||||
state
|
||||
.supported_server_requests_by_connection
|
||||
.remove(&connection_id);
|
||||
let thread_ids = state
|
||||
.thread_ids_by_connection
|
||||
.remove(&connection_id)
|
||||
@@ -378,3 +420,7 @@ impl ThreadStateManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_connection_ids(connection_ids: &mut [ConnectionId]) {
|
||||
connection_ids.sort_by_key(|connection_id| connection_id.0);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ pub use models_cache::write_models_cache_with_models;
|
||||
pub use responses::create_apply_patch_sse_response;
|
||||
pub use responses::create_exec_command_sse_response;
|
||||
pub use responses::create_final_assistant_message_sse_response;
|
||||
pub use responses::create_request_permission_preset_sse_response;
|
||||
pub use responses::create_request_permissions_sse_response;
|
||||
pub use responses::create_request_user_input_sse_response;
|
||||
pub use responses::create_shell_command_sse_response;
|
||||
|
||||
@@ -103,3 +103,16 @@ pub fn create_request_permissions_sse_response(call_id: &str) -> anyhow::Result<
|
||||
responses::ev_completed("resp-1"),
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn create_request_permission_preset_sse_response(call_id: &str) -> anyhow::Result<String> {
|
||||
let tool_call_arguments = serde_json::to_string(&json!({
|
||||
"preset": "full-access",
|
||||
"reason": "User asked for full access"
|
||||
}))?;
|
||||
|
||||
Ok(responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, "request_permission_preset", &tool_call_arguments),
|
||||
responses::ev_completed("resp-1"),
|
||||
]))
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
supported_server_requests: None,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
)
|
||||
@@ -63,6 +64,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
supported_server_requests: None,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
)
|
||||
@@ -99,6 +101,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
supported_server_requests: None,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
)
|
||||
@@ -139,6 +142,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
supported_server_requests: None,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
)
|
||||
@@ -176,6 +180,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
supported_server_requests: None,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
)
|
||||
@@ -212,6 +217,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil
|
||||
default_client_info(),
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: false,
|
||||
supported_server_requests: None,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -158,6 +158,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
supported_server_requests: None,
|
||||
opt_out_notification_methods: Some(vec!["thread/started".to_string()]),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,27 +1,77 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::create_final_assistant_message_sse_response;
|
||||
use app_test_support::create_mock_responses_server_sequence;
|
||||
use app_test_support::create_request_permission_preset_sse_response;
|
||||
use app_test_support::create_request_permissions_sse_response;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::InitializeCapabilities;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::PermissionGrantScope;
|
||||
use codex_app_server_protocol::PermissionsRequestApprovalResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ServerRequestResolvedNotification;
|
||||
use codex_app_server_protocol::SupportedServerRequestMethod;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
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 serde_json::json;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn request_permissions_round_trip() -> 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_final_assistant_message_sse_response("done")?,
|
||||
];
|
||||
let server = create_mock_responses_server_sequence(responses).await;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
initialize_with_supported_server_requests(
|
||||
&mut mcp,
|
||||
vec![SupportedServerRequestMethod::PermissionsRequestApproval],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let thread_id = start_thread_and_turn(&mut mcp, "pick a directory").await?;
|
||||
|
||||
let server_request = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_request_message(),
|
||||
)
|
||||
.await??;
|
||||
let ServerRequest::PermissionsRequestApproval { request_id, params } = server_request else {
|
||||
bail!("expected PermissionsRequestApproval request");
|
||||
};
|
||||
assert_eq!(params.thread_id, thread_id);
|
||||
assert_eq!(params.item_id, "call1");
|
||||
assert_eq!(params.reason.as_deref(), Some("Select a workspace root"));
|
||||
let resolved_request_id = request_id.clone();
|
||||
|
||||
mcp.send_response(
|
||||
request_id,
|
||||
json!({
|
||||
"permissions": {},
|
||||
"scope": "turn",
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
wait_for_server_request_resolved_then_turn_completed(&mut mcp, &thread_id, resolved_request_id)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
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")?,
|
||||
@@ -62,44 +112,243 @@ async fn request_permissions_round_trip() -> Result<()> {
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
|
||||
)
|
||||
.await??;
|
||||
let TurnStartResponse { turn, .. } = to_response(turn_start_resp)?;
|
||||
let TurnStartResponse { .. } = to_response(turn_start_resp)?;
|
||||
|
||||
let server_req = timeout(
|
||||
loop {
|
||||
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
|
||||
match message {
|
||||
JSONRPCMessage::Request(request) => {
|
||||
let server_request: ServerRequest = request.try_into()?;
|
||||
bail!("unexpected app-server request: {server_request:?}");
|
||||
}
|
||||
JSONRPCMessage::Notification(notification)
|
||||
if notification.method == "turn/completed" =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn request_permission_preset_round_trips_when_client_supports_app_request() -> Result<()> {
|
||||
let codex_home = tempfile::TempDir::new()?;
|
||||
let responses = vec![
|
||||
create_request_permission_preset_sse_response("call1")?,
|
||||
create_final_assistant_message_sse_response("done")?,
|
||||
];
|
||||
let server = create_mock_responses_server_sequence(responses).await;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
initialize_with_supported_server_requests(
|
||||
&mut mcp,
|
||||
vec![SupportedServerRequestMethod::PermissionPresetRequestApproval],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let thread_id = start_thread_and_turn(&mut mcp, "make this session full access").await?;
|
||||
|
||||
let server_request = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_request_message(),
|
||||
)
|
||||
.await??;
|
||||
let ServerRequest::PermissionsRequestApproval { request_id, params } = server_req else {
|
||||
panic!("expected PermissionsRequestApproval request, got: {server_req:?}");
|
||||
let ServerRequest::PermissionPresetRequestApproval { request_id, params } = server_request
|
||||
else {
|
||||
bail!("expected PermissionPresetRequestApproval request");
|
||||
};
|
||||
|
||||
assert_eq!(params.thread_id, thread.id);
|
||||
assert_eq!(params.turn_id, turn.id);
|
||||
assert_eq!(params.thread_id, thread_id);
|
||||
assert_eq!(params.item_id, "call1");
|
||||
assert_eq!(params.reason, Some("Select a workspace root".to_string()));
|
||||
let requested_writes = params
|
||||
.permissions
|
||||
.file_system
|
||||
.and_then(|file_system| file_system.write)
|
||||
.expect("request should include write permissions");
|
||||
assert_eq!(requested_writes.len(), 2);
|
||||
assert_eq!(params.label, "Full Access");
|
||||
let resolved_request_id = request_id.clone();
|
||||
|
||||
mcp.send_response(
|
||||
request_id,
|
||||
serde_json::to_value(PermissionsRequestApprovalResponse {
|
||||
permissions: codex_app_server_protocol::GrantedPermissionProfile {
|
||||
network: None,
|
||||
file_system: Some(codex_app_server_protocol::AdditionalFileSystemPermissions {
|
||||
read: None,
|
||||
write: Some(vec![requested_writes[0].clone()]),
|
||||
}),
|
||||
},
|
||||
scope: PermissionGrantScope::Turn,
|
||||
})?,
|
||||
json!({
|
||||
"decision": "accepted",
|
||||
"preset": "full-access",
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
wait_for_server_request_resolved_then_turn_completed(&mut mcp, &thread_id, resolved_request_id)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn request_permission_preset_auto_declines_without_app_request() -> Result<()> {
|
||||
let codex_home = tempfile::TempDir::new()?;
|
||||
let responses = vec![
|
||||
create_request_permission_preset_sse_response("call1")?,
|
||||
create_final_assistant_message_sse_response("done")?,
|
||||
];
|
||||
let server = create_mock_responses_server_sequence(responses).await;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;
|
||||
|
||||
let turn_start_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "make this session full access".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
|
||||
)
|
||||
.await??;
|
||||
let TurnStartResponse { .. } = to_response(turn_start_resp)?;
|
||||
|
||||
loop {
|
||||
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
|
||||
match message {
|
||||
JSONRPCMessage::Request(request) => {
|
||||
let server_request: ServerRequest = request.try_into()?;
|
||||
bail!("unexpected app-server request: {server_request:?}");
|
||||
}
|
||||
JSONRPCMessage::Notification(notification)
|
||||
if notification.method == "turn/completed" =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn request_permission_preset_auto_declines_with_only_permissions_capability() -> Result<()> {
|
||||
let codex_home = tempfile::TempDir::new()?;
|
||||
let responses = vec![
|
||||
create_request_permission_preset_sse_response("call1")?,
|
||||
create_final_assistant_message_sse_response("done")?,
|
||||
];
|
||||
let server = create_mock_responses_server_sequence(responses).await;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
initialize_with_supported_server_requests(
|
||||
&mut mcp,
|
||||
vec![SupportedServerRequestMethod::PermissionsRequestApproval],
|
||||
)
|
||||
.await?;
|
||||
|
||||
start_thread_and_turn(&mut mcp, "make this session full access").await?;
|
||||
wait_for_turn_completed_without_server_request(&mut mcp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn initialize_with_supported_server_requests(
|
||||
mcp: &mut McpProcess,
|
||||
supported_server_requests: Vec<SupportedServerRequestMethod>,
|
||||
) -> Result<()> {
|
||||
let initialized = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.initialize_with_capabilities(
|
||||
ClientInfo {
|
||||
name: "codex_vscode".to_string(),
|
||||
title: Some("Codex VS Code Extension".to_string()),
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
supported_server_requests: Some(supported_server_requests),
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await??;
|
||||
let JSONRPCMessage::Response(_) = initialized else {
|
||||
bail!("expected initialize response, got {initialized:?}");
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_thread_and_turn(mcp: &mut McpProcess, input: &str) -> Result<String> {
|
||||
let thread_start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;
|
||||
|
||||
let turn_start_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: input.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
|
||||
)
|
||||
.await??;
|
||||
let TurnStartResponse { .. } = to_response(turn_start_resp)?;
|
||||
Ok(thread.id)
|
||||
}
|
||||
|
||||
async fn wait_for_turn_completed_without_server_request(mcp: &mut McpProcess) -> Result<()> {
|
||||
loop {
|
||||
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
|
||||
match message {
|
||||
JSONRPCMessage::Request(request) => {
|
||||
let server_request: ServerRequest = request.try_into()?;
|
||||
bail!("unexpected app-server request: {server_request:?}");
|
||||
}
|
||||
JSONRPCMessage::Notification(notification)
|
||||
if notification.method == "turn/completed" =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_server_request_resolved_then_turn_completed(
|
||||
mcp: &mut McpProcess,
|
||||
thread_id: &str,
|
||||
request_id: RequestId,
|
||||
) -> Result<()> {
|
||||
let mut saw_resolved = false;
|
||||
loop {
|
||||
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
|
||||
@@ -108,14 +357,12 @@ async fn request_permissions_round_trip() -> Result<()> {
|
||||
};
|
||||
match notification.method.as_str() {
|
||||
"serverRequest/resolved" => {
|
||||
let resolved: ServerRequestResolvedNotification = serde_json::from_value(
|
||||
notification
|
||||
.params
|
||||
.clone()
|
||||
.expect("serverRequest/resolved params"),
|
||||
)?;
|
||||
assert_eq!(resolved.thread_id, thread.id);
|
||||
assert_eq!(resolved.request_id, resolved_request_id);
|
||||
let Some(params) = notification.params.clone() else {
|
||||
bail!("serverRequest/resolved notification missing params");
|
||||
};
|
||||
let resolved: ServerRequestResolvedNotification = serde_json::from_value(params)?;
|
||||
assert_eq!(resolved.thread_id, thread_id);
|
||||
assert_eq!(resolved.request_id, request_id);
|
||||
saw_resolved = true;
|
||||
}
|
||||
"turn/completed" => {
|
||||
@@ -125,7 +372,6 @@ async fn request_permissions_round_trip() -> Result<()> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -150,6 +396,7 @@ stream_max_retries = 0
|
||||
|
||||
[features]
|
||||
request_permissions_tool = true
|
||||
request_permission_preset_tool = true
|
||||
"#
|
||||
),
|
||||
)
|
||||
|
||||
@@ -145,6 +145,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> {
|
||||
},
|
||||
Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
supported_server_requests: None,
|
||||
opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -103,6 +103,7 @@ impl AppServerClient {
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
supported_server_requests: None,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user