mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
Allow updating the approval reviewer for active turns (#42121)
## What changed - Add `approvalsReviewer` to the experimental `turn/settings/update` API. - Apply reviewer changes to subsequently captured steps and new background approval requests while preserving pending approvals and future-thread defaults. - Allow reviewer-only updates without `step_model_switching`, while continuing to enforce managed reviewer restrictions and model-required auto review. - Use an explicit live reviewer update for MCP approvals while retaining refreshed thread defaults for clients that have not set one. ## Testing - Cover switching between user and automatic review during a live turn, future-turn isolation, and managed-authority rejection. GitOrigin-RevId: e1216b09c6a7972c2c9eaf4955d8d57d60604ca1
This commit is contained in:
1 parent
bc39b0ed59
commit
9112564114
14 files changed
+257
-18
No files matched your search
BIN
Binary file not shown.
@@ -47,6 +47,10 @@ pub enum TurnStatus {
|
|||||||
pub struct TurnSettingsUpdateParams {
|
pub struct TurnSettingsUpdateParams {
|
||||||
pub thread_id: String,
|
pub thread_id: String,
|
||||||
pub turn_id: String,
|
pub turn_id: String,
|
||||||
|
/// Changes the active turn's reviewer without changing future thread settings.
|
||||||
|
/// Already captured steps and pending approvals retain their original reviewer.
|
||||||
|
#[ts(optional = nullable)]
|
||||||
|
pub approvals_reviewer: Option<ApprovalsReviewer>,
|
||||||
/// Omission or `null` leaves the model unchanged.
|
/// Omission or `null` leaves the model unchanged.
|
||||||
#[ts(optional = nullable)]
|
#[ts(optional = nullable)]
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ Example with notification opt-out:
|
|||||||
- `thread/revert` — replace a loaded paginated thread's durable history with the prefix strictly before `beforeTurnId` while preserving its thread id. The operation interrupts an active turn if needed, leaves older rollout files immutable, reloads the thread, returns updated thread metadata with empty `turns` plus pagination cursors, and emits `thread/reverted`. It does not revert local file changes. Parent-owned Multi-Agent V2 subagents reject direct revert requests.
|
- `thread/revert` — replace a loaded paginated thread's durable history with the prefix strictly before `beforeTurnId` while preserving its thread id. The operation interrupts an active turn if needed, leaves older rollout files immutable, reloads the thread, returns updated thread metadata with empty `turns` plus pagination cursors, and emits `thread/reverted`. It does not revert local file changes. Parent-owned Multi-Agent V2 subagents reject direct revert requests.
|
||||||
- `turn/start` — add user input or a named standalone function-call output to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For standalone outputs, provide `toolOutput` with an empty `input` array. Optional `turnTrigger` classifies who or what started a new turn and is sent as `turn_trigger` in Responses request metadata; it is ignored if the request steers an active turn. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` supplies the default roots for newly resolved environment selections. Explicit `environments[].runtimeWorkspaceRoots` override that fallback with environment-native absolute paths. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Deprecated experimental `multiAgentMode` is ignored; Ultra reasoning effort selects proactive behavior. Parent-owned Multi-Agent V2 subagents reject direct turns.
|
- `turn/start` — add user input or a named standalone function-call output to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For standalone outputs, provide `toolOutput` with an empty `input` array. Optional `turnTrigger` classifies who or what started a new turn and is sent as `turn_trigger` in Responses request metadata; it is ignored if the request steers an active turn. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` supplies the default roots for newly resolved environment selections. Explicit `environments[].runtimeWorkspaceRoots` override that fallback with environment-native absolute paths. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Deprecated experimental `multiAgentMode` is ignored; Ultra reasoning effort selects proactive behavior. Parent-owned Multi-Agent V2 subagents reject direct turns.
|
||||||
- `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a turn; returns `{}` on success. Parent-owned Multi-Agent V2 subagents reject direct item injection.
|
- `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a turn; returns `{}` on success. Parent-owned Multi-Agent V2 subagents reject direct item injection.
|
||||||
- `turn/settings/update` — experimental; publish a narrow model-settings patch to the exact live task identified by `threadId` and `turnId`, regardless of task kind. Requires `step_model_switching`; returns `status: "applied"` or `status: "targetUnavailable"`, or a request error if rejected. Future-thread settings and already captured steps are unchanged. Parent-owned Multi-Agent V2 subagents reject direct settings updates.
|
- `turn/settings/update` — experimental; publish a reviewer or model-settings patch to the exact live task identified by `threadId` and `turnId`, regardless of task kind. Model-settings updates require `step_model_switching`; reviewer-only updates do not. Returns `status: "applied"` or `status: "targetUnavailable"`, or a request error if rejected. Future-thread settings and already captured steps are unchanged. Parent-owned Multi-Agent V2 subagents reject direct settings updates.
|
||||||
- `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. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. Parent-owned Multi-Agent V2 subagents reject direct steering.
|
- `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. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. Parent-owned Multi-Agent V2 subagents reject direct steering.
|
||||||
- `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"`. Also available for parent-owned Multi-Agent V2 subagents.
|
- `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"`. Also available for parent-owned Multi-Agent V2 subagents.
|
||||||
- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, pass `includeStartupContext: false` to omit Codex's generated startup context, and optionally pass `initialItems` to seed V3 with complete role-bearing text messages at session creation. Pass `realtimeStartInstructions` and `realtimeEndInstructions` to control the developer instructions given to the backing Codex model when this session starts and ends. Version `"v1"` uses legacy Bidi `conversation.handoff.*`, `"v2"` uses the Realtime Voice API, and `"v3"` preserves V1 Codex Voice behavior while using Frameless Bidi `delegation.*`. For V3 automatic Codex text, `codexResponseHandoffMode` accepts `"thinking"` (the default; all output uses channel-less thinking appends), `"commentary"` (all output uses the commentary channel), or `"bemTags"` (the raw BEM envelope selects the API channel: BEM `analysis` and `commentary` use `commentary`, while BEM `final` and unparsable output use `speakable`). The BEM envelope remains in the appended text for the frontend model to interpret. V1 and V2 ignore this setting. For V3, pass `delegationAckFiller: false` to suppress the Realtime API's delegation acknowledgement filler or `true` to restore it; omitting the field preserves the Realtime API's default. V1 and V2 ignore `delegationAckFiller`. V3 handoffs do not prepend the legacy `"Agent Final Message"` label. Pass `clientManagedHandoffs: true` to disable automatic Codex response delivery so only the client's explicit append calls produce handoffs. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a Bidi WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. Conversation `version: "v2"` requests remain unsupported for WebRTC. Parent-owned Multi-Agent V2 subagents reject this request.
|
- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, pass `includeStartupContext: false` to omit Codex's generated startup context, and optionally pass `initialItems` to seed V3 with complete role-bearing text messages at session creation. Pass `realtimeStartInstructions` and `realtimeEndInstructions` to control the developer instructions given to the backing Codex model when this session starts and ends. Version `"v1"` uses legacy Bidi `conversation.handoff.*`, `"v2"` uses the Realtime Voice API, and `"v3"` preserves V1 Codex Voice behavior while using Frameless Bidi `delegation.*`. For V3 automatic Codex text, `codexResponseHandoffMode` accepts `"thinking"` (the default; all output uses channel-less thinking appends), `"commentary"` (all output uses the commentary channel), or `"bemTags"` (the raw BEM envelope selects the API channel: BEM `analysis` and `commentary` use `commentary`, while BEM `final` and unparsable output use `speakable`). The BEM envelope remains in the appended text for the frontend model to interpret. V1 and V2 ignore this setting. For V3, pass `delegationAckFiller: false` to suppress the Realtime API's delegation acknowledgement filler or `true` to restore it; omitting the field preserves the Realtime API's default. V1 and V2 ignore `delegationAckFiller`. V3 handoffs do not prepend the legacy `"Agent Final Message"` label. Pass `clientManagedHandoffs: true` to disable automatic Codex response delivery so only the client's explicit append calls produce handoffs. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a Bidi WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. Conversation `version: "v2"` requests remain unsupported for WebRTC. Parent-owned Multi-Agent V2 subagents reject this request.
|
||||||
@@ -1351,8 +1351,8 @@ Use `thread/backgroundTerminals/terminate` to terminate one running background t
|
|||||||
|
|
||||||
### Example: Update a running turn's settings (experimental)
|
### Example: Update a running turn's settings (experimental)
|
||||||
|
|
||||||
Enable `capabilities.experimentalApi` and the disabled-by-default `step_model_switching`
|
Enable `capabilities.experimentalApi`; model-settings updates also require the
|
||||||
feature. Supply the exact turn ID from `turn/start`, `turn/started`, `thread/read` with
|
disabled-by-default `step_model_switching` feature. Supply the exact turn ID from `turn/start`, `turn/started`, `thread/read` with
|
||||||
`includeTurns: true`, or `thread/turns/list`:
|
`includeTurns: true`, or `thread/turns/list`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -1362,10 +1362,21 @@ feature. Supply the exact turn ID from `turn/start`, `turn/started`, `thread/rea
|
|||||||
{ "id": 42, "result": { "status": "applied" } }
|
{ "id": 42, "result": { "status": "applied" } }
|
||||||
```
|
```
|
||||||
|
|
||||||
Only `model`, `effort`, `summary`, and `serviceTier` may change. Unknown fields are
|
Only `approvalsReviewer`, `model`, `effort`, `summary`, and `serviceTier` may change. Unknown fields are
|
||||||
rejected. Omitted fields leave settings unchanged; `serviceTier: null` clears the
|
rejected. Omitted fields leave settings unchanged; `serviceTier: null` clears the
|
||||||
requested tier, while `null` for model, effort, or summary leaves it unchanged.
|
requested tier, while `null` for model, effort, or summary leaves it unchanged.
|
||||||
|
|
||||||
|
A reviewer-only update does not require `step_model_switching`. Set
|
||||||
|
`approvalsReviewer` to `"auto_review"` or `"user"` to change review routing for
|
||||||
|
subsequently captured steps and newly initiated background approval requests.
|
||||||
|
Omission or `null` leaves the reviewer unchanged. Managed reviewer restrictions
|
||||||
|
and model-required auto review still apply. Existing captured steps and pending
|
||||||
|
approvals keep their original reviewer; this does not approve a pending request,
|
||||||
|
change sandbox permissions, or update child sessions. App/account-specific
|
||||||
|
reviewer overrides still take precedence. Future-thread defaults remain separate.
|
||||||
|
For compatibility, MCP keeps following refreshed thread defaults until an explicit
|
||||||
|
live reviewer update overrides them for that turn.
|
||||||
|
|
||||||
The response waits for core: `status: "applied"` means a settings snapshot was published
|
The response waits for core: `status: "applied"` means a settings snapshot was published
|
||||||
for subsequent captures, even if its values were unchanged. Normal defaults and tier
|
for subsequent captures, even if its values were unchanged. Normal defaults and tier
|
||||||
filtering still apply; publication does not guarantee another inference will run or use
|
filtering still apply; publication does not guarantee another inference will run or use
|
||||||
|
|||||||
@@ -216,6 +216,9 @@ impl TurnRequestProcessor {
|
|||||||
Op::TurnSettings {
|
Op::TurnSettings {
|
||||||
turn_id: params.turn_id,
|
turn_id: params.turn_id,
|
||||||
update: TurnSettingsUpdate {
|
update: TurnSettingsUpdate {
|
||||||
|
approvals_reviewer: params
|
||||||
|
.approvals_reviewer
|
||||||
|
.map(codex_app_server_protocol::ApprovalsReviewer::to_core),
|
||||||
model: params.model,
|
model: params.model,
|
||||||
// Match thread/settings/update: public null does not clear effort.
|
// Match thread/settings/update: public null does not clear effort.
|
||||||
effort: params.effort.map(Some),
|
effort: params.effort.map(Some),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ use anyhow::Context;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use app_test_support::MockResponsesConfig;
|
use app_test_support::MockResponsesConfig;
|
||||||
use app_test_support::TestAppServer;
|
use app_test_support::TestAppServer;
|
||||||
|
use codex_app_server_protocol::ApprovalsReviewer;
|
||||||
|
use codex_app_server_protocol::AskForApproval;
|
||||||
use codex_app_server_protocol::ClientRequest;
|
use codex_app_server_protocol::ClientRequest;
|
||||||
use codex_app_server_protocol::JSONRPCErrorError;
|
use codex_app_server_protocol::JSONRPCErrorError;
|
||||||
use codex_app_server_protocol::RequestId;
|
use codex_app_server_protocol::RequestId;
|
||||||
@@ -25,6 +27,7 @@ use codex_protocol::config_types::ReasoningSummary;
|
|||||||
use codex_protocol::openai_models::ModelsResponse;
|
use codex_protocol::openai_models::ModelsResponse;
|
||||||
use codex_protocol::openai_models::ReasoningEffort;
|
use codex_protocol::openai_models::ReasoningEffort;
|
||||||
use core_test_support::responses;
|
use core_test_support::responses;
|
||||||
|
use core_test_support::skip_if_wine_exec;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -132,6 +135,7 @@ async fn settings_updates_report_results_and_preserve_the_target_on_saved_thread
|
|||||||
effort: Some(ReasoningEffort::High),
|
effort: Some(ReasoningEffort::High),
|
||||||
summary: Some(ReasoningSummary::Detailed),
|
summary: Some(ReasoningSummary::Detailed),
|
||||||
service_tier: Some(Some("priority".to_string())),
|
service_tier: Some(Some("priority".to_string())),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
match scenario {
|
match scenario {
|
||||||
UpdateScenario::Future => {
|
UpdateScenario::Future => {
|
||||||
@@ -418,6 +422,121 @@ async fn disabled_turn_settings_update_reports_rejection_without_changing_future
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test_case(ApprovalsReviewer::User, ApprovalsReviewer::AutoReview; "enable auto review")]
|
||||||
|
#[test_case(ApprovalsReviewer::AutoReview, ApprovalsReviewer::User; "disable auto review")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn live_reviewer_updates_route_approvals_without_changing_future_turns(
|
||||||
|
initial: ApprovalsReviewer,
|
||||||
|
updated: ApprovalsReviewer,
|
||||||
|
) -> Result<()> {
|
||||||
|
skip_if_wine_exec!(Ok(()), "command approval requires host-native paths");
|
||||||
|
|
||||||
|
let server = responses::start_mock_server().await;
|
||||||
|
let mut replies = vec![responses::sse(vec![
|
||||||
|
responses::ev_function_call(
|
||||||
|
"pause-turn",
|
||||||
|
"request_user_input",
|
||||||
|
&json!({"questions": [{
|
||||||
|
"id": "continue", "header": "Continue", "question": "Continue?",
|
||||||
|
"options": [
|
||||||
|
{"label": "Yes", "description": "Continue"},
|
||||||
|
{"label": "No", "description": "Stop"}
|
||||||
|
]
|
||||||
|
}]})
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
responses::ev_completed("paused"),
|
||||||
|
])];
|
||||||
|
for (index, reviewer) in [&updated, &initial].into_iter().enumerate() {
|
||||||
|
replies.push(responses::sse(vec![
|
||||||
|
responses::ev_function_call(
|
||||||
|
&format!("command-{index}"),
|
||||||
|
"exec_command",
|
||||||
|
r#"{"cmd":"echo reviewer-test","sandbox_permissions":"require_escalated","justification":"test approval routing"}"#,
|
||||||
|
),
|
||||||
|
responses::ev_completed(&format!("command-response-{index}")),
|
||||||
|
]));
|
||||||
|
if *reviewer == ApprovalsReviewer::AutoReview {
|
||||||
|
replies.push(responses::sse(vec![
|
||||||
|
responses::ev_assistant_message("review", r#"{"outcome":"deny"}"#),
|
||||||
|
responses::ev_completed("review-response"),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
replies.push(responses::sse_completed(&format!("done-{index}")));
|
||||||
|
}
|
||||||
|
let requests = responses::mount_sse_sequence(&server, replies).await;
|
||||||
|
let codex_home = TempDir::new()?;
|
||||||
|
// Reviewer-only updates do not require the experimental model-switching feature.
|
||||||
|
mock_config(codex_home.path(), &server.uri())?
|
||||||
|
.enable_feature(Feature::GuardianApproval)
|
||||||
|
.write(codex_home.path())?;
|
||||||
|
let mut app = TestAppServer::builder()
|
||||||
|
.with_codex_home(codex_home.path())
|
||||||
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
||||||
|
.await?;
|
||||||
|
let id = app
|
||||||
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
||||||
|
approval_policy: Some(AskForApproval::OnRequest),
|
||||||
|
approvals_reviewer: Some(initial),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let ThreadStartResponse { thread, .. } =
|
||||||
|
timeout(DEFAULT_TIMEOUT, app.read_response(id)).await??;
|
||||||
|
let turn_id = start_turn(&mut app, &thread.id).await?;
|
||||||
|
let request = timeout(DEFAULT_TIMEOUT, app.read_stream_until_request_message()).await??;
|
||||||
|
let ServerRequest::ToolRequestUserInput { request_id, .. } = request else {
|
||||||
|
anyhow::bail!("expected paused turn, received {request:?}");
|
||||||
|
};
|
||||||
|
let result: TurnSettingsUpdateResponse = app
|
||||||
|
.request(|request_id| ClientRequest::TurnSettingsUpdate {
|
||||||
|
request_id,
|
||||||
|
params: TurnSettingsUpdateParams {
|
||||||
|
thread_id: thread.id.clone(),
|
||||||
|
turn_id,
|
||||||
|
approvals_reviewer: Some(updated),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
TurnSettingsUpdateResponse {
|
||||||
|
status: TurnSettingsUpdateStatus::Applied,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
app.send_response(
|
||||||
|
request_id,
|
||||||
|
json!({"answers": {"continue": {"answers": ["Yes"]}}}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for (index, reviewer) in [updated, initial].into_iter().enumerate() {
|
||||||
|
if index != 0 {
|
||||||
|
start_turn(&mut app, &thread.id).await?;
|
||||||
|
}
|
||||||
|
if reviewer == ApprovalsReviewer::User {
|
||||||
|
let request =
|
||||||
|
timeout(DEFAULT_TIMEOUT, app.read_stream_until_request_message()).await??;
|
||||||
|
let ServerRequest::CommandExecutionRequestApproval { request_id, .. } = request else {
|
||||||
|
anyhow::bail!("expected manual approval, received {request:?}");
|
||||||
|
};
|
||||||
|
app.send_response(request_id, json!({"decision": "decline"}))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
let completed: TurnCompletedNotification =
|
||||||
|
timeout(DEFAULT_TIMEOUT, app.read_notification("turn/completed")).await??;
|
||||||
|
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
||||||
|
}
|
||||||
|
let guardian_requests = requests
|
||||||
|
.requests()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|request| request.body_json()["client_metadata"]["x-openai-subagent"] == "guardian")
|
||||||
|
.count();
|
||||||
|
assert_eq!(guardian_requests, 1);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn start_turn(app: &mut TestAppServer, thread_id: &str) -> Result<String> {
|
async fn start_turn(app: &mut TestAppServer, thread_id: &str) -> Result<String> {
|
||||||
let request_id = app
|
let request_id = app
|
||||||
.send_turn_start_request(TurnStartParams {
|
.send_turn_start_request(TurnStartParams {
|
||||||
|
|||||||
@@ -1414,7 +1414,10 @@ async fn maybe_request_mcp_tool_approval(
|
|||||||
};
|
};
|
||||||
let approvals_reviewer = connectors::mcp_approvals_reviewer_from_layers(
|
let approvals_reviewer = connectors::mcp_approvals_reviewer_from_layers(
|
||||||
&config.config_layer_stack,
|
&config.config_layer_stack,
|
||||||
config.approvals_reviewer,
|
step_context
|
||||||
|
.settings
|
||||||
|
.mcp_approvals_reviewer_override
|
||||||
|
.unwrap_or(config.approvals_reviewer),
|
||||||
Some(turn_context.model_info().slug.as_str()),
|
Some(turn_context.model_info().slug.as_str()),
|
||||||
&invocation.server,
|
&invocation.server,
|
||||||
metadata.connector_id.as_deref(),
|
metadata.connector_id.as_deref(),
|
||||||
|
|||||||
@@ -724,6 +724,7 @@ async fn review_guardian_mcp_elicitation(
|
|||||||
let Some(mcp_config) = session.services.mcp_runtime.current_config() else {
|
let Some(mcp_config) = session.services.mcp_runtime.current_config() else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
let step_settings = turn_context.current_settings.load_full();
|
||||||
|
|
||||||
// The invocation identifies the tool event, but a nested elicitation can
|
// The invocation identifies the tool event, but a nested elicitation can
|
||||||
// review a different action and connector than the enclosing JavaScript.
|
// review a different action and connector than the enclosing JavaScript.
|
||||||
@@ -893,7 +894,9 @@ async fn review_guardian_mcp_elicitation(
|
|||||||
|
|
||||||
let approvals_reviewer = crate::connectors::mcp_approvals_reviewer_from_layers(
|
let approvals_reviewer = crate::connectors::mcp_approvals_reviewer_from_layers(
|
||||||
&mcp_config.config_layer_stack,
|
&mcp_config.config_layer_stack,
|
||||||
mcp_config.approvals_reviewer,
|
step_settings
|
||||||
|
.mcp_approvals_reviewer_override
|
||||||
|
.unwrap_or(mcp_config.approvals_reviewer),
|
||||||
Some(turn_context.model_info().slug.as_str()),
|
Some(turn_context.model_info().slug.as_str()),
|
||||||
request.server_name.as_str(),
|
request.server_name.as_str(),
|
||||||
elicitation_connector_id(&request.elicitation),
|
elicitation_connector_id(&request.elicitation),
|
||||||
|
|||||||
@@ -3479,11 +3479,14 @@ impl Session {
|
|||||||
if settings.selected().service_tier != root_service_tier {
|
if settings.selected().service_tier != root_service_tier {
|
||||||
let mut selected = settings.selected().clone();
|
let mut selected = settings.selected().clone();
|
||||||
selected.service_tier = root_service_tier;
|
selected.service_tier = root_service_tier;
|
||||||
settings = Arc::new(ResolvedStepSettings::new(
|
let mut inherited_settings = ResolvedStepSettings::new(
|
||||||
Arc::new(selected),
|
Arc::new(selected),
|
||||||
Arc::clone(&settings.model_info),
|
Arc::clone(&settings.model_info),
|
||||||
self.features.enabled(Feature::FastMode),
|
self.features.enabled(Feature::FastMode),
|
||||||
));
|
);
|
||||||
|
inherited_settings.mcp_approvals_reviewer_override =
|
||||||
|
settings.mcp_approvals_reviewer_override;
|
||||||
|
settings = Arc::new(inherited_settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let token_budget = token_budget::resolve_token_budget(
|
let token_budget = token_budget::resolve_token_budget(
|
||||||
|
|||||||
@@ -48,18 +48,13 @@ fn check_legacy_turn_safety(
|
|||||||
|| ignored_models.is_some_and(|models| models.contains(&model.slug))
|
|| ignored_models.is_some_and(|models| models.contains(&model.slug))
|
||||||
};
|
};
|
||||||
|
|
||||||
// tools::approvals and guardian::review still route approvals using the
|
// Approval policy and required-model classification still have consumers
|
||||||
// originating turn's policy, reviewer, and required-model classification.
|
// using the originating turn. The reviewer is captured separately.
|
||||||
if destination.constrained_approval_policy() != current.constrained_approval_policy()
|
if destination.constrained_approval_policy() != current.constrained_approval_policy()
|
||||||
|| destination.approval_policy() != turn_context.approval_policy()
|
|| destination.approval_policy() != turn_context.approval_policy()
|
||||||
{
|
{
|
||||||
return Err("the destination changes the admitted approval policy".to_string());
|
return Err("the destination changes the admitted approval policy".to_string());
|
||||||
}
|
}
|
||||||
if destination.approvals_reviewer() != current.approvals_reviewer()
|
|
||||||
|| destination.approvals_reviewer() != turn_context.config.approvals_reviewer
|
|
||||||
{
|
|
||||||
return Err("the destination changes the admitted approvals reviewer".to_string());
|
|
||||||
}
|
|
||||||
if required_review
|
if required_review
|
||||||
!= requirements
|
!= requirements
|
||||||
.auto_review_required_for_model(current.selected_collaboration_mode().model())
|
.auto_review_required_for_model(current.selected_collaboration_mode().model())
|
||||||
@@ -237,7 +232,12 @@ impl Session {
|
|||||||
turn_id: &str,
|
turn_id: &str,
|
||||||
update: TurnSettingsUpdate,
|
update: TurnSettingsUpdate,
|
||||||
) -> TurnSettingsUpdateOutcome {
|
) -> TurnSettingsUpdateOutcome {
|
||||||
if !self.features.enabled(Feature::StepModelSwitching) {
|
let reviewer_only = update.approvals_reviewer.is_some()
|
||||||
|
&& update.model.is_none()
|
||||||
|
&& update.effort.is_none()
|
||||||
|
&& update.summary.is_none()
|
||||||
|
&& update.service_tier.is_none();
|
||||||
|
if !reviewer_only && !self.features.enabled(Feature::StepModelSwitching) {
|
||||||
return TurnSettingsUpdateOutcome::Rejected {
|
return TurnSettingsUpdateOutcome::Rejected {
|
||||||
reason: "turn settings updates require the step_model_switching feature"
|
reason: "turn settings updates require the step_model_switching feature"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
@@ -265,12 +265,14 @@ impl Session {
|
|||||||
return TurnSettingsUpdateOutcome::TargetUnavailable;
|
return TurnSettingsUpdateOutcome::TargetUnavailable;
|
||||||
};
|
};
|
||||||
let TurnSettingsUpdate {
|
let TurnSettingsUpdate {
|
||||||
|
approvals_reviewer,
|
||||||
model,
|
model,
|
||||||
effort,
|
effort,
|
||||||
summary,
|
summary,
|
||||||
service_tier,
|
service_tier,
|
||||||
} = update;
|
} = update;
|
||||||
let update = StepSettingsUpdate {
|
let update = StepSettingsUpdate {
|
||||||
|
approvals_reviewer,
|
||||||
model,
|
model,
|
||||||
effort,
|
effort,
|
||||||
reasoning_summary: summary,
|
reasoning_summary: summary,
|
||||||
@@ -313,6 +315,11 @@ impl Session {
|
|||||||
)
|
)
|
||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
.and_then(|()| {
|
.and_then(|()| {
|
||||||
|
// A reviewer-only patch cannot change any model-owned authority.
|
||||||
|
// Managed reviewer restrictions were checked above.
|
||||||
|
if reviewer_only {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
check_legacy_turn_safety(
|
check_legacy_turn_safety(
|
||||||
&turn_context,
|
&turn_context,
|
||||||
¤t,
|
¤t,
|
||||||
|
|||||||
@@ -607,6 +607,60 @@ enum ManagedAuthorizationChange {
|
|||||||
ApprovalsReviewer,
|
ApprovalsReviewer,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test_case(false; "reviewer allow-list")]
|
||||||
|
#[test_case(true; "model-required review")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reviewer_only_activation_enforces_managed_authority(required_review: bool) {
|
||||||
|
let ActivationFixture { session, turn, .. } = activation_fixture(activation_models()).await;
|
||||||
|
let original = turn.current_settings.load_full();
|
||||||
|
let desired = desired_step_settings(&session).await;
|
||||||
|
let source = RequirementSource::Unknown;
|
||||||
|
let mut sourced = ConfigRequirementsWithSources::default();
|
||||||
|
let reviewer = if required_review {
|
||||||
|
sourced.auto_review = Some(Sourced::new(
|
||||||
|
AutoReviewRequirementsToml {
|
||||||
|
required_on_models: Some(vec![turn.model_info().slug.clone()]),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
source,
|
||||||
|
));
|
||||||
|
ApprovalsReviewer::User
|
||||||
|
} else {
|
||||||
|
sourced.allowed_approvals_reviewers =
|
||||||
|
Some(Sourced::new(vec![ApprovalsReviewer::User], source));
|
||||||
|
ApprovalsReviewer::AutoReview
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut state = session.state.lock().await;
|
||||||
|
let config = Arc::make_mut(&mut state.session_configuration.original_config_do_not_use);
|
||||||
|
config.config_layer_stack = ConfigLayerStack::new(
|
||||||
|
config
|
||||||
|
.config_layer_stack
|
||||||
|
.all_layers_low_to_high()
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
ConfigRequirements::try_from(sourced.clone()).expect("managed requirements"),
|
||||||
|
sourced.into_toml(),
|
||||||
|
)
|
||||||
|
.expect("managed config stack");
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
session
|
||||||
|
.apply_turn_settings(
|
||||||
|
&turn.sub_id,
|
||||||
|
TurnSettingsUpdate {
|
||||||
|
approvals_reviewer: Some(reviewer),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
TurnSettingsUpdateOutcome::Rejected { .. }
|
||||||
|
));
|
||||||
|
assert!(Arc::ptr_eq(&turn.current_settings.load_full(), &original));
|
||||||
|
assert_eq!(desired_step_settings(&session).await, desired);
|
||||||
|
session.abort_all_tasks(TurnAbortReason::Replaced).await;
|
||||||
|
}
|
||||||
|
|
||||||
#[test_case(ManagedAuthorizationChange::ApprovalPolicy; "approval policy refreshed during lookup")]
|
#[test_case(ManagedAuthorizationChange::ApprovalPolicy; "approval policy refreshed during lookup")]
|
||||||
#[test_case(ManagedAuthorizationChange::ApprovalsReviewer; "approvals reviewer refreshed during lookup")]
|
#[test_case(ManagedAuthorizationChange::ApprovalsReviewer; "approvals reviewer refreshed during lookup")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ pub(crate) struct ResolvedStepSettings {
|
|||||||
pub(crate) reasoning_summary: ReasoningSummary,
|
pub(crate) reasoning_summary: ReasoningSummary,
|
||||||
/// Tier sent on requests, filtered by feature and model support.
|
/// Tier sent on requests, filtered by feature and model support.
|
||||||
pub(crate) service_tier: Option<String>,
|
pub(crate) service_tier: Option<String>,
|
||||||
|
/// MCP historically follows refreshed thread defaults. Preserve that behavior
|
||||||
|
/// for older clients until this turn receives an explicit live reviewer update.
|
||||||
|
pub(crate) mcp_approvals_reviewer_override: Option<ApprovalsReviewer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResolvedStepSettings {
|
impl ResolvedStepSettings {
|
||||||
@@ -69,6 +72,7 @@ impl ResolvedStepSettings {
|
|||||||
model_info,
|
model_info,
|
||||||
reasoning_summary,
|
reasoning_summary,
|
||||||
service_tier,
|
service_tier,
|
||||||
|
mcp_approvals_reviewer_override: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +139,11 @@ impl ResolvedStepSettings {
|
|||||||
.await,
|
.await,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
Ok(Self::new(Arc::new(selected), model_info, fast_mode_enabled))
|
let mut next = Self::new(Arc::new(selected), model_info, fast_mode_enabled);
|
||||||
|
next.mcp_approvals_reviewer_override = update
|
||||||
|
.approvals_reviewer
|
||||||
|
.or(self.mcp_approvals_reviewer_override);
|
||||||
|
Ok(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rechecks the retained selection against current managed constraints.
|
/// Rechecks the retained selection against current managed constraints.
|
||||||
|
|||||||
@@ -9141,8 +9141,12 @@ async fn refreshed_mcp_binding_captures_current_approval_authority() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test_case(false; "legacy thread reviewer")]
|
||||||
|
#[test_case(true; "explicit live reviewer")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn mcp_elicitation_reviewer_uses_latest_runtime_authority() {
|
async fn mcp_elicitation_reviewer_uses_active_reviewer_and_latest_runtime_policy(
|
||||||
|
live_update: bool,
|
||||||
|
) {
|
||||||
let guardian_server = start_mock_server().await;
|
let guardian_server = start_mock_server().await;
|
||||||
mount_sse_once(
|
mount_sse_once(
|
||||||
&guardian_server,
|
&guardian_server,
|
||||||
@@ -9182,6 +9186,22 @@ async fn mcp_elicitation_reviewer_uses_latest_runtime_authority() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
session.mark_mcp_runtime_dirty();
|
||||||
|
session.refresh_mcp_if_dirty().await;
|
||||||
|
if live_update {
|
||||||
|
assert_eq!(
|
||||||
|
session
|
||||||
|
.apply_turn_settings(
|
||||||
|
&old_turn.sub_id,
|
||||||
|
codex_protocol::protocol::TurnSettingsUpdate {
|
||||||
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
codex_protocol::protocol::TurnSettingsUpdateOutcome::Applied
|
||||||
|
);
|
||||||
|
} else {
|
||||||
session
|
session
|
||||||
.update_settings(SessionSettingsUpdate {
|
.update_settings(SessionSettingsUpdate {
|
||||||
step_settings: StepSettingsUpdate {
|
step_settings: StepSettingsUpdate {
|
||||||
@@ -9193,6 +9213,7 @@ async fn mcp_elicitation_reviewer_uses_latest_runtime_authority() {
|
|||||||
.await
|
.await
|
||||||
.expect("reviewer settings should update");
|
.expect("reviewer settings should update");
|
||||||
session.refresh_mcp_if_dirty().await;
|
session.refresh_mcp_if_dirty().await;
|
||||||
|
}
|
||||||
|
|
||||||
let request = codex_mcp::ElicitationReviewRequest {
|
let request = codex_mcp::ElicitationReviewRequest {
|
||||||
server_name: "browser-use".to_string(),
|
server_name: "browser-use".to_string(),
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ async fn settings_updates_preserve_turn_identity_and_target(target: SettingsTarg
|
|||||||
effort: Some(Some(ReasoningEffort::High)),
|
effort: Some(Some(ReasoningEffort::High)),
|
||||||
summary: Some(ReasoningSummary::Detailed),
|
summary: Some(ReasoningSummary::Detailed),
|
||||||
service_tier: Some(Some(ServiceTier::Fast.request_value().to_string())),
|
service_tier: Some(Some(ServiceTier::Fast.request_value().to_string())),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
submit_turn_settings(&test.codex, "different-turn", update.clone()).await?,
|
submit_turn_settings(&test.codex, "different-turn", update.clone()).await?,
|
||||||
|
|||||||
@@ -490,6 +490,8 @@ pub struct ConversationSpeechParams {
|
|||||||
/// task kind. Child sessions and consumers of frozen initial settings are unchanged.
|
/// task kind. Child sessions and consumers of frozen initial settings are unchanged.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct TurnSettingsUpdate {
|
pub struct TurnSettingsUpdate {
|
||||||
|
/// Changes the reviewer for subsequent approval requests, not pending reviews.
|
||||||
|
pub approvals_reviewer: Option<ApprovalsReviewer>,
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
/// `None` preserves the selection; `Some(None)` clears it.
|
/// `None` preserves the selection; `Some(None)` clears it.
|
||||||
pub effort: Option<Option<ReasoningEffortConfig>>,
|
pub effort: Option<Option<ReasoningEffortConfig>>,
|
||||||
|
|||||||
Reference in new issue
Block a user