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:
jif authored and copyberry committed 2026-09-01 19:19:52 +00:00
1 parent bc39b0ed59
commit 9112564114
14 files changed
+257 -18

No files matched your search

@@ -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>,
+15 -4
View File
@@ -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 {
+4 -1
View File
@@ -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(),
+4 -1
View File
@@ -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),
+5 -2
View File
@@ -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(
+15 -8
View File
@@ -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,
&current, &current,
@@ -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]
+9 -1
View File
@@ -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.
+22 -1
View File
@@ -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?,
+2
View File
@@ -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>>,