File size: 17,581 Bytes
52a9af3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! Restricted updates to a running turn's immutable settings snapshots.

use super::session::Session;
use super::session::SessionConfiguration;
use super::step_settings::ResolvedStepSettings;
use super::step_settings::StepSettingsConstraints;
use super::step_settings::StepSettingsUpdate;
use super::turn_context::TurnContext;
use crate::config::Config;
use crate::config::ConstraintResult;
use crate::context::GuardianNodeReplPolicy;
use crate::exec_policy::AllowPrefixRules;
use crate::guardian::BUNDLED_GUARDIAN_POLICY_TEMPLATE;
use codex_features::Feature;
use codex_protocol::openai_models::GuardianV2ModelConfig;
use codex_protocol::openai_models::GuardianV2TranscriptModelConfig;
use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::TurnSettingsUpdate;
use codex_protocol::protocol::TurnSettingsUpdateOutcome;
use std::sync::Arc;

/// Temporary restrictions while approvals and Guardian still read the admitted
/// `TurnContext`. Ordinary live authorization is validated separately. Remove
/// these restrictions as their consumers migrate to captured step settings.
fn check_legacy_turn_safety(
    turn_context: &TurnContext,
    current: &ResolvedStepSettings,
    destination: &ResolvedStepSettings,
    live_config: &Config,
) -> Result<(), String> {
    let stack = &live_config.config_layer_stack;
    let requirements = stack.requirements();
    let required_review = requirements
        .auto_review_required_for_model(destination.selected_collaboration_mode().model());
    let admitted_required_review = turn_context
        .config
        .config_layer_stack
        .requirements()
        .auto_review_required_for_model(&turn_context.model_info().slug);
    let ignored_models = stack
        .requirements_toml()
        .auto_review
        .as_ref()
        .and_then(|review| review.ignore_rules.as_ref());
    let ignores_prefix_rules = |model: &ModelInfo| {
        model.model_specialty.as_deref() == Some(MODEL_SPECIALTY_CYBER)
            || ignored_models.is_some_and(|models| models.contains(&model.slug))
    };

    // Approval policy and required-model classification still have consumers
    // using the originating turn. The reviewer is captured separately.
    if destination.constrained_approval_policy() != current.constrained_approval_policy()
        || destination.approval_policy() != turn_context.approval_policy()
    {
        return Err("the destination changes the admitted approval policy".to_string());
    }
    if required_review
        != requirements
            .auto_review_required_for_model(current.selected_collaboration_mode().model())
        || required_review != admitted_required_review
    {
        return Err("the destination changes model-required approval authority".to_string());
    }
    // Command approval continues to use TurnContext::allow_prefix_rules.
    if ignores_prefix_rules(&destination.model_info) != ignores_prefix_rules(&current.model_info)
        || ignores_prefix_rules(&destination.model_info)
            != (turn_context.allow_prefix_rules() == AllowPrefixRules::IgnoreForCyberModel)
    {
        return Err("the destination changes the admitted prefix-rule policy".to_string());
    }

    check_legacy_model_safety(
        turn_context.model_info(),
        &current.model_info,
        &destination.model_info,
        &turn_context.config,
        live_config,
    )
}

/// Model-owned portion of the temporary legacy-turn safety check. Ordinary
/// model metadata may differ so diagnostics can expose unmigrated consumers.
fn check_legacy_model_safety(
    admitted: &ModelInfo,
    current: &ModelInfo,
    destination: &ModelInfo,
    admitted_config: &Config,
    live_config: &Config,
) -> Result<(), String> {
    if admitted.used_fallback_model_metadata || current.used_fallback_model_metadata {
        return Err("the active model has only fallback metadata".to_string());
    }
    if destination.used_fallback_model_metadata {
        return Err("the destination model has only fallback metadata".to_string());
    }
    let retained_models = [admitted, current];
    // The Guardian reviewer extension still selects its circuit-breaker
    // policy from the admitted model's Cyber classification.
    let destination_is_cyber =
        destination.model_specialty.as_deref() == Some(MODEL_SPECIALTY_CYBER);
    if retained_models.iter().any(|model| {
        (model.model_specialty.as_deref() == Some(MODEL_SPECIALTY_CYBER)) != destination_is_cyber
    }) {
        return Err("the destination changes the admitted Guardian rejection policy".to_string());
    }
    // TurnMetadataState pins both node REPL flags. Guardian prompt/evidence
    // construction also reads node_repl_auto_review_required from the turn.
    if retained_models.iter().any(|model| {
        model.computer_use_review_required() != destination.computer_use_review_required()
    }) {
        return Err(
            "the destination changes the admitted node REPL review requirement".to_string(),
        );
    }
    if retained_models
        .iter()
        .any(|model| model.guardian != destination.guardian)
    {
        return Err("the destination changes the admitted Guardian coverage".to_string());
    }
    if retained_models
        .iter()
        .any(|model| model.node_repl_disabled != destination.node_repl_disabled)
    {
        return Err(
            "the destination changes the admitted node REPL availability restriction".to_string(),
        );
    }
    // guardian::review::guardian_review_session_config and Guardian V2 still
    // select the reviewer from the retained parent metadata.
    if retained_models
        .iter()
        .any(|model| model.auto_review_model_override != destination.auto_review_model_override)
    {
        return Err("the destination changes the explicit Guardian reviewer model".to_string());
    }

    if (admitted_config.features.enabled(Feature::GuardianV2) || destination.guardian.is_some())
        && admitted_config.features.enabled(Feature::GuardianApproval)
    {
        // GuardianV2Extension::on_tool_start reads the parent ModelInfo from
        // thread_store. Its classifier settings are independent of the reviewer
        // override. Local overrides may mask differences, but resolving those
        // overrides remains the extension's responsibility.
        let classification_settings =
            |model: &ModelInfo| -> GuardianV2ModelConfig {
                let mut settings = model
                    .model_messages
                    .as_ref()
                    .and_then(|messages| messages.guardian_v2.clone())
                    .unwrap_or_default();
                // Missing and empty transcript records supply the same defaults.
                if settings.transcript.as_ref().is_some_and(|transcript| {
                    *transcript == GuardianV2TranscriptModelConfig::default()
                }) {
                    settings.transcript = None;
                }
                settings
            };
        let destination_settings = classification_settings(destination);
        if retained_models
            .iter()
            .any(|model| classification_settings(model) != destination_settings)
        {
            return Err(
                "the destination changes the admitted Guardian V2 classification settings"
                    .to_string(),
            );
        }
    }

    // guardian_review_session_config and Guardian V2 can fall back to parent
    // metadata if their preferred reviewer is unavailable, including after a
    // catalog refresh. V1 uses the admitted config; V2 can use the live config.
    // An unchanged explicit reviewer override prevents both fallback paths.
    if destination.auto_review_model_override.is_none() {
        let destination_node_repl_policy =
            GuardianNodeReplPolicy::from_model_messages(destination.model_messages.as_ref());
        for model in retained_models {
            let policy = GuardianNodeReplPolicy::from_model_messages(model.model_messages.as_ref());
            if policy != destination_node_repl_policy {
                return Err(
                    "the destination changes the Guardian parent-fallback node REPL policy"
                        .to_string(),
                );
            }
        }
        for config in [admitted_config, live_config] {
            let destination_policy =
                config.resolve_guardian_policy(destination.model_messages.as_ref());
            if retained_models.iter().any(|model| {
                config.resolve_guardian_policy(model.model_messages.as_ref()) != destination_policy
            }) {
                return Err(
                    "the destination changes the Guardian parent-fallback policy".to_string(),
                );
            }
        }
        let destination_template = destination
            .model_messages
            .as_ref()
            .and_then(|messages| messages.auto_review.as_ref())
            .and_then(|messages| messages.policy_template.as_deref())
            .unwrap_or(BUNDLED_GUARDIAN_POLICY_TEMPLATE)
            .trim_end();
        if retained_models.iter().any(|model| {
            model
                .model_messages
                .as_ref()
                .and_then(|messages| messages.auto_review.as_ref())
                .and_then(|messages| messages.policy_template.as_deref())
                .unwrap_or(BUNDLED_GUARDIAN_POLICY_TEMPLATE)
                .trim_end()
                != destination_template
        }) {
            return Err(
                "the destination changes the Guardian parent-fallback policy template".to_string(),
            );
        }
    }
    Ok(())
}

impl Session {
    /// Publishes settings to the named, originally captured live task, regardless
    /// of task kind. Publication does not propagate to child sessions or require
    /// the task to sample; consumers using initial settings remain unchanged.
    ///
    /// Callers must serialize updates through completion, including model
    /// resolution, so each sparse patch sees the preceding publication.
    #[expect(
        clippy::await_holding_invalid_type,
        reason = "the final managed-policy check and active settings publication must remain atomic"
    )]
    pub(super) async fn apply_turn_settings(
        &self,
        turn_id: &str,
        update: TurnSettingsUpdate,
    ) -> TurnSettingsUpdateOutcome {
        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 {
                reason: "turn settings updates require the step_model_switching feature"
                    .to_string(),
            };
        }

        // Capture the exact live task and its settings, then release the
        // lock. A task that starts during preparation is never a new target.
        let target = {
            let active = self.active_turn.lock().await;
            active.as_ref().and_then(|active| {
                active.task.as_ref().and_then(|task| {
                    (task.turn_context.sub_id == turn_id && !task.cancellation_token.is_cancelled())
                        .then(|| {
                            (
                                Arc::clone(&task.turn_context),
                                Arc::clone(&task.done),
                                task.turn_context.current_settings.load_full(),
                            )
                        })
                })
            })
        };
        let Some((turn_context, task_done, current)) = target else {
            return TurnSettingsUpdateOutcome::TargetUnavailable;
        };
        let TurnSettingsUpdate {
            approvals_reviewer,
            model,
            effort,
            summary,
            service_tier,
        } = update;
        let update = StepSettingsUpdate {
            approvals_reviewer,
            model,
            effort,
            reasoning_summary: summary,
            service_tier,
            ..Default::default()
        };
        // Apply the sparse patch to the captured active base using the shared
        // settings rules. The task can progress, finish, or be cancelled while
        // preparation awaits; no publication locks are held here.
        let prepared = self
            .prepare_step_settings_activation(&turn_context, &current, &update)
            .await;
        let active = self.active_turn.lock().await;
        let Some(task) = active.as_ref().and_then(|active| active.task.as_ref()) else {
            return TurnSettingsUpdateOutcome::TargetUnavailable;
        };
        // A later task may reuse the same context and turn ID. `done` is
        // allocated per task, so matching only the ID/context is insufficient.
        // A mismatch abandons the update without retrying or retargeting.
        if !Arc::ptr_eq(&task.done, &task_done)
            || !Arc::ptr_eq(&task.turn_context, &turn_context)
            || !Arc::ptr_eq(&task.turn_context.current_settings.load_full(), &current)
            || task.cancellation_token.is_cancelled()
        {
            return TurnSettingsUpdateOutcome::TargetUnavailable;
        }
        let destination = match prepared {
            Ok(destination) => destination,
            Err(reason) => return TurnSettingsUpdateOutcome::Rejected { reason },
        };
        // Managed requirements can change during resolution. Keep the live
        // authorization and safety checks atomic with publication under state
        // and active_turn; no asynchronous preparation runs under these locks.
        let state = self.state.lock().await;
        if let Err(reason) = self
            .validate_active_step_settings(
                &turn_context,
                &destination,
                &state.session_configuration,
            )
            .map_err(|error| error.to_string())
            .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(
                    &turn_context,
                    &current,
                    &destination,
                    &state.session_configuration.original_config_do_not_use,
                )
            })
        {
            return TurnSettingsUpdateOutcome::Rejected { reason };
        }
        // Publish the immutable snapshot. Frozen initial settings, existing step
        // captures, and future thread settings are not changed.
        task.turn_context
            .current_settings
            .store(Arc::new(destination));
        TurnSettingsUpdateOutcome::Applied
    }

    async fn prepare_step_settings_activation(
        &self,
        turn_context: &TurnContext,
        current: &ResolvedStepSettings,
        update: &StepSettingsUpdate,
    ) -> Result<ResolvedStepSettings, String> {
        let (requirements, overrides, trusted_guardian_reviewer) = {
            let state = self.state.lock().await;
            let configuration = &state.session_configuration;
            let stack = &configuration.original_config_do_not_use.config_layer_stack;
            (
                stack.requirements().clone(),
                configuration.model_info_overrides.clone(),
                configuration.trusted_guardian_reviewer,
            )
        };
        let constraints = StepSettingsConstraints {
            requirements: &requirements,
            guardian_approval_enabled: self.features.enabled(Feature::GuardianApproval),
            trusted_guardian_reviewer,
            has_full_disk_write_access: turn_context
                .file_system_sandbox_policy()
                .has_full_disk_write_access(),
        };
        current
            .apply_update(
                update,
                &constraints,
                self.services.models_manager.as_ref(),
                &overrides,
                self.features.enabled(Feature::FastMode),
            )
            .await
            .map_err(|error| error.to_string())
    }

    /// Rechecks ordinary managed authorization after asynchronous resolution.
    /// Unlike the temporary legacy-turn check, these requirements also apply
    /// once all execution consumers read their captured `StepContext`.
    fn validate_active_step_settings(
        &self,
        turn_context: &TurnContext,
        settings: &ResolvedStepSettings,
        configuration: &SessionConfiguration,
    ) -> ConstraintResult<()> {
        let requirements = configuration
            .original_config_do_not_use
            .config_layer_stack
            .requirements();
        settings.revalidate(&StepSettingsConstraints {
            requirements,
            guardian_approval_enabled: self.features.enabled(Feature::GuardianApproval),
            trusted_guardian_reviewer: configuration.trusted_guardian_reviewer,
            has_full_disk_write_access: turn_context
                .file_system_sandbox_policy()
                .has_full_disk_write_access(),
        })
    }
}

#[cfg(test)]
#[path = "step_activation_tests.rs"]
mod tests;