feat(core) OverrideActiveTurnContext

This commit is contained in:
Dylan Hurd
2026-04-22 11:38:21 -07:00
parent 11e5af53c4
commit bdc88dcea5
35 changed files with 841 additions and 42 deletions
@@ -3772,6 +3772,44 @@
],
"type": "object"
},
"TurnOverrideActiveContextParams": {
"properties": {
"approvalPolicy": {
"anyOf": [
{
"$ref": "#/definitions/AskForApproval"
},
{
"type": "null"
}
],
"description": "Override the active turn approval policy and the default for subsequent turns."
},
"approvalsReviewer": {
"anyOf": [
{
"$ref": "#/definitions/ApprovalsReviewer"
},
{
"type": "null"
}
],
"description": "Override where approval requests are routed for review on the active turn and subsequent turns."
},
"expectedTurnId": {
"description": "Required active turn id precondition. The request fails when it does not match the currently active turn.",
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"expectedTurnId",
"threadId"
],
"type": "object"
},
"TurnStartParams": {
"properties": {
"approvalPolicy": {
@@ -5051,6 +5089,30 @@
"title": "Turn/steerRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"turn/overrideActiveContext"
],
"title": "Turn/overrideActiveContextRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/TurnOverrideActiveContextParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Turn/overrideActiveContextRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1169,6 +1169,30 @@
"title": "Turn/steerRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"turn/overrideActiveContext"
],
"title": "Turn/overrideActiveContextRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/TurnOverrideActiveContextParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Turn/overrideActiveContextRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -16520,6 +16544,59 @@
"title": "TurnInterruptResponse",
"type": "object"
},
"TurnOverrideActiveContextParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"approvalPolicy": {
"anyOf": [
{
"$ref": "#/definitions/v2/AskForApproval"
},
{
"type": "null"
}
],
"description": "Override the active turn approval policy and the default for subsequent turns."
},
"approvalsReviewer": {
"anyOf": [
{
"$ref": "#/definitions/v2/ApprovalsReviewer"
},
{
"type": "null"
}
],
"description": "Override where approval requests are routed for review on the active turn and subsequent turns."
},
"expectedTurnId": {
"description": "Required active turn id precondition. The request fails when it does not match the currently active turn.",
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"expectedTurnId",
"threadId"
],
"title": "TurnOverrideActiveContextParams",
"type": "object"
},
"TurnOverrideActiveContextResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"turnId": {
"type": "string"
}
},
"required": [
"turnId"
],
"title": "TurnOverrideActiveContextResponse",
"type": "object"
},
"TurnPlanStep": {
"properties": {
"status": {
@@ -1857,6 +1857,30 @@
"title": "Turn/steerRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"turn/overrideActiveContext"
],
"title": "Turn/overrideActiveContextRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/TurnOverrideActiveContextParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Turn/overrideActiveContextRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -14414,6 +14438,59 @@
"title": "TurnInterruptResponse",
"type": "object"
},
"TurnOverrideActiveContextParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"approvalPolicy": {
"anyOf": [
{
"$ref": "#/definitions/AskForApproval"
},
{
"type": "null"
}
],
"description": "Override the active turn approval policy and the default for subsequent turns."
},
"approvalsReviewer": {
"anyOf": [
{
"$ref": "#/definitions/ApprovalsReviewer"
},
{
"type": "null"
}
],
"description": "Override where approval requests are routed for review on the active turn and subsequent turns."
},
"expectedTurnId": {
"description": "Required active turn id precondition. The request fails when it does not match the currently active turn.",
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"expectedTurnId",
"threadId"
],
"title": "TurnOverrideActiveContextParams",
"type": "object"
},
"TurnOverrideActiveContextResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"turnId": {
"type": "string"
}
},
"required": [
"turnId"
],
"title": "TurnOverrideActiveContextResponse",
"type": "object"
},
"TurnPlanStep": {
"properties": {
"status": {
@@ -0,0 +1,100 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ApprovalsReviewer": {
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.",
"enum": [
"user",
"guardian_subagent"
],
"type": "string"
},
"AskForApproval": {
"oneOf": [
{
"enum": [
"untrusted",
"on-failure",
"on-request",
"never"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"granular": {
"properties": {
"mcp_elicitations": {
"type": "boolean"
},
"request_permissions": {
"default": false,
"type": "boolean"
},
"rules": {
"type": "boolean"
},
"sandbox_approval": {
"type": "boolean"
},
"skill_approval": {
"default": false,
"type": "boolean"
}
},
"required": [
"mcp_elicitations",
"rules",
"sandbox_approval"
],
"type": "object"
}
},
"required": [
"granular"
],
"title": "GranularAskForApproval",
"type": "object"
}
]
}
},
"properties": {
"approvalPolicy": {
"anyOf": [
{
"$ref": "#/definitions/AskForApproval"
},
{
"type": "null"
}
],
"description": "Override the active turn approval policy and the default for subsequent turns."
},
"approvalsReviewer": {
"anyOf": [
{
"$ref": "#/definitions/ApprovalsReviewer"
},
{
"type": "null"
}
],
"description": "Override where approval requests are routed for review on the active turn and subsequent turns."
},
"expectedTurnId": {
"description": "Required active turn id precondition. The request fails when it does not match the currently active turn.",
"type": "string"
},
"threadId": {
"type": "string"
}
},
"required": [
"expectedTurnId",
"threadId"
],
"title": "TurnOverrideActiveContextParams",
"type": "object"
}
@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"turnId": {
"type": "string"
}
},
"required": [
"turnId"
],
"title": "TurnOverrideActiveContextResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
export type TurnOverrideActiveContextParams = { threadId: string,
/**
* Required active turn id precondition. The request fails when it does not
* match the currently active turn.
*/
expectedTurnId: string,
/**
* Override the active turn approval policy and the default for subsequent
* turns.
*/
approvalPolicy?: AskForApproval | null,
/**
* Override where approval requests are routed for review on the active
* turn and subsequent turns.
*/
approvalsReviewer?: ApprovalsReviewer | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TurnOverrideActiveContextResponse = { turnId: string, };
@@ -381,6 +381,8 @@ export type { TurnEnvironmentParams } from "./TurnEnvironmentParams";
export type { TurnError } from "./TurnError";
export type { TurnInterruptParams } from "./TurnInterruptParams";
export type { TurnInterruptResponse } from "./TurnInterruptResponse";
export type { TurnOverrideActiveContextParams } from "./TurnOverrideActiveContextParams";
export type { TurnOverrideActiveContextResponse } from "./TurnOverrideActiveContextResponse";
export type { TurnPlanStep } from "./TurnPlanStep";
export type { TurnPlanStepStatus } from "./TurnPlanStepStatus";
export type { TurnPlanUpdatedNotification } from "./TurnPlanUpdatedNotification";
@@ -439,6 +439,11 @@ client_request_definitions! {
inspect_params: true,
response: v2::TurnSteerResponse,
},
TurnOverrideActiveContext => "turn/overrideActiveContext" {
params: v2::TurnOverrideActiveContextParams,
inspect_params: true,
response: v2::TurnOverrideActiveContextResponse,
},
TurnInterrupt => "turn/interrupt" {
params: v2::TurnInterruptParams,
response: v2::TurnInterruptResponse,
@@ -4950,6 +4950,34 @@ pub struct TurnSteerResponse {
pub turn_id: String,
}
#[derive(
Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi,
)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct TurnOverrideActiveContextParams {
pub thread_id: String,
/// Required active turn id precondition. The request fails when it does not
/// match the currently active turn.
pub expected_turn_id: String,
/// Override the active turn approval policy and the default for subsequent
/// turns.
#[experimental(nested)]
#[ts(optional = nullable)]
pub approval_policy: Option<AskForApproval>,
/// Override where approval requests are routed for review on the active
/// turn and subsequent turns.
#[ts(optional = nullable)]
pub approvals_reviewer: Option<ApprovalsReviewer>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct TurnOverrideActiveContextResponse {
pub turn_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -8826,6 +8854,30 @@ mod tests {
assert_eq!(reason, Some("askForApproval.granular"));
}
#[test]
fn client_request_turn_override_active_context_granular_approval_policy_is_marked_experimental()
{
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(
&crate::ClientRequest::TurnOverrideActiveContext {
request_id: crate::RequestId::Integer(5),
params: TurnOverrideActiveContextParams {
thread_id: "thr_123".to_string(),
expected_turn_id: "turn_123".to_string(),
approval_policy: Some(AskForApproval::Granular {
sandbox_approval: false,
rules: true,
skill_approval: false,
request_permissions: false,
mcp_elicitations: true,
}),
approvals_reviewer: None,
},
},
);
assert_eq!(reason, Some("askForApproval.granular"));
}
#[test]
fn mcp_server_elicitation_response_round_trips_rmcp_result() {
let rmcp_result = rmcp::model::CreateElicitationResult {
+18
View File
@@ -158,6 +158,7 @@ Example with notification opt-out:
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
- `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success.
- `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. Review and manual compaction turns reject `turn/steer`.
- `turn/overrideActiveContext` — update approval policy or approvals reviewer for the currently active regular turn and subsequent turns; returns the active `turnId`.
- `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"`.
- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`.
- `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`.
@@ -735,6 +736,23 @@ not emit `turn/started` and does not accept turn context overrides.
active turn, or the active turn kind does not accept same-turn steering (for example review or
manual compaction), the request fails with an `invalid request` error.
Use `turn/overrideActiveContext` to change approval behavior for an already in-flight regular turn
without sending additional input. The same values become the defaults for subsequent turns.
```json
{ "method": "turn/overrideActiveContext", "id": 33, "params": {
"threadId": "thr_123",
"expectedTurnId": "turn_456",
"approvalPolicy": "never",
"approvalsReviewer": "user"
} }
{ "id": 33, "result": { "turnId": "turn_456" } }
```
`expectedTurnId` is required. At least one of `approvalPolicy` or `approvalsReviewer` must be set.
If there is no active turn, `expectedTurnId` does not match the active turn, or the active turn kind
does not support active context overrides, the request fails with an `invalid request` error.
### Example: Request a code review
Use `review/start` to run Codex’s reviewer on the currently checked-out project. The request takes the thread id plus a `target` describing what should be reviewed:
@@ -201,6 +201,8 @@ use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnOverrideActiveContextParams;
use codex_app_server_protocol::TurnOverrideActiveContextResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
@@ -1016,6 +1018,10 @@ impl CodexMessageProcessor {
self.turn_steer(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::TurnOverrideActiveContext { request_id, params } => {
self.turn_override_active_context(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::TurnInterrupt { request_id, params } => {
self.turn_interrupt(to_connection_request_id(request_id), params)
.await;
@@ -7078,6 +7084,77 @@ impl CodexMessageProcessor {
}
}
async fn turn_override_active_context(
&self,
request_id: ConnectionRequestId,
params: TurnOverrideActiveContextParams,
) {
let (_, thread) = match self.load_thread(&params.thread_id).await {
Ok(v) => v,
Err(error) => {
self.track_error_response(&request_id, &error, /*error_type*/ None);
self.outgoing.send_error(request_id, error).await;
return;
}
};
if params.expected_turn_id.is_empty() {
self.send_invalid_request_error(
request_id,
"expectedTurnId must not be empty".to_string(),
)
.await;
return;
}
if params.approval_policy.is_none() && params.approvals_reviewer.is_none() {
self.send_invalid_request_error(
request_id,
"at least one of approvalPolicy or approvalsReviewer must be set".to_string(),
)
.await;
return;
}
self.outgoing
.record_request_turn_id(&request_id, &params.expected_turn_id)
.await;
match thread
.override_active_turn_context(
&params.expected_turn_id,
params.approval_policy.map(AskForApproval::to_core),
params
.approvals_reviewer
.map(codex_app_server_protocol::ApprovalsReviewer::to_core),
)
.await
{
Ok(turn_id) => {
let response = TurnOverrideActiveContextResponse { turn_id };
if self.config.features.enabled(Feature::GeneralAnalytics) {
self.analytics_events_client.track_response(
request_id.connection_id.0,
ClientResponse::TurnOverrideActiveContext {
request_id: request_id.request_id.clone(),
response: response.clone(),
},
);
}
self.outgoing.send_response(request_id, response).await;
}
Err(CodexErr::InvalidRequest(message)) => {
self.send_invalid_request_error(request_id, message).await;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to override active turn context: {err}"),
)
.await;
}
}
}
async fn prepare_realtime_conversation_thread(
&self,
request_id: ConnectionRequestId,
+1 -1
View File
@@ -37,7 +37,7 @@ pub(crate) async fn apply_patch(
) -> InternalApplyPatchInvocation {
match assess_patch_safety(
&action,
turn_context.approval_policy.value(),
turn_context.approval_policy(),
turn_context.sandbox_policy.get(),
file_system_sandbox_policy,
&turn_context.cwd,
+12
View File
@@ -116,6 +116,18 @@ impl CodexThread {
.await
}
pub async fn override_active_turn_context(
&self,
expected_turn_id: &str,
approval_policy: Option<AskForApproval>,
approvals_reviewer: Option<ApprovalsReviewer>,
) -> CodexResult<String> {
self.codex
.session
.override_active_turn_context(expected_turn_id, approval_policy, approvals_reviewer)
.await
}
pub async fn set_app_server_client_info(
&self,
app_server_client_name: Option<String>,
+3 -3
View File
@@ -50,7 +50,7 @@ fn build_permissions_update_item(
let prev = previous?;
if prev.sandbox_policy == *next.sandbox_policy.get()
&& prev.approval_policy == next.approval_policy.value()
&& prev.approval_policy == next.approval_policy()
{
return None;
}
@@ -58,8 +58,8 @@ fn build_permissions_update_item(
Some(
PermissionsInstructions::from_policy(
next.sandbox_policy.get(),
next.approval_policy.value(),
next.config.approvals_reviewer,
next.approval_policy(),
next.approvals_reviewer(),
exec_policy,
&next.cwd,
next.features.enabled(Feature::ExecPermissionApprovals),
+2 -2
View File
@@ -142,9 +142,9 @@ fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str {
/// earlier in the flow.
pub(crate) fn routes_approval_to_guardian(turn: &TurnContext) -> bool {
matches!(
turn.approval_policy.value(),
turn.approval_policy(),
AskForApproval::OnRequest | AskForApproval::Granular(_)
) && turn.config.approvals_reviewer == ApprovalsReviewer::GuardianSubagent
) && turn.approvals_reviewer() == ApprovalsReviewer::GuardianSubagent
}
pub(crate) fn is_guardian_reviewer_source(
+1 -1
View File
@@ -479,7 +479,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
}
fn hook_permission_mode(turn_context: &TurnContext) -> String {
match turn_context.approval_policy.value() {
match turn_context.approval_policy() {
AskForApproval::Never => "bypassPermissions",
AskForApproval::UnlessTrusted
| AskForApproval::OnFailure
+1 -1
View File
@@ -220,7 +220,7 @@ async fn should_install_mcp_dependencies(
cancellation_token: &CancellationToken,
) -> bool {
if mcp_permission_prompt_is_auto_approved(
turn_context.approval_policy.value(),
turn_context.approval_policy(),
turn_context.sandbox_policy.get(),
) {
return true;
+1 -1
View File
@@ -818,7 +818,7 @@ async fn maybe_request_mcp_tool_approval(
approval_mode: AppToolApproval,
) -> Option<McpToolApprovalDecision> {
if mcp_permission_prompt_is_auto_approved(
turn_context.approval_policy.value(),
turn_context.approval_policy(),
turn_context.sandbox_policy.get(),
) {
return None;
+39
View File
@@ -4,6 +4,8 @@ use crate::realtime_conversation::handle_start as handle_realtime_conversation_s
use crate::realtime_conversation::handle_text as handle_realtime_conversation_text;
use async_channel::Receiver;
use codex_otel::set_parent_from_w3c_trace_context;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Submission;
use tracing::Instrument;
use tracing::debug_span;
@@ -113,6 +115,28 @@ pub async fn override_turn_context(sess: &Session, sub_id: String, updates: Sess
}
}
pub async fn override_active_turn_context(
sess: &Session,
sub_id: String,
turn_id: String,
approval_policy: Option<AskForApproval>,
approvals_reviewer: Option<ApprovalsReviewer>,
) {
if let Err(err) = sess
.override_active_turn_context(&turn_id, approval_policy, approvals_reviewer)
.await
{
sess.send_event_raw(Event {
id: sub_id,
msg: EventMsg::Error(ErrorEvent {
message: err.to_string(),
codex_error_info: Some(CodexErrorInfo::BadRequest),
}),
})
.await;
}
}
pub async fn user_input_or_turn(sess: &Arc<Session>, sub_id: String, op: Op) {
user_input_or_turn_inner(
sess,
@@ -1099,6 +1123,21 @@ pub(super) async fn submission_loop(
.await;
false
}
Op::OverrideActiveTurnContext {
turn_id,
approval_policy,
approvals_reviewer,
} => {
override_active_turn_context(
&sess,
sub.id.clone(),
turn_id,
approval_policy,
approvals_reviewer,
)
.await;
false
}
Op::UserInput { .. } | Op::UserTurn { .. } => {
user_input_or_turn(&sess, sub.id.clone(), sub.op).await;
false
+2 -1
View File
@@ -225,11 +225,12 @@ impl Session {
guard.cancel();
*guard = CancellationToken::new();
}
let approval_policy = turn_context.approval_policy_constrained();
let (refreshed_manager, cancel_token) = McpConnectionManager::new(
&mcp_servers,
store_mode,
auth_statuses,
&turn_context.config.permissions.approval_policy,
&approval_policy,
turn_context.sub_id.clone(),
self.get_tx_event(),
turn_context.sandbox_policy.get().clone(),
+82 -3
View File
@@ -1310,6 +1310,85 @@ impl Session {
Ok(())
}
pub(crate) async fn override_active_turn_context(
&self,
expected_turn_id: &str,
approval_policy: Option<AskForApproval>,
approvals_reviewer: Option<ApprovalsReviewer>,
) -> CodexResult<String> {
if expected_turn_id.is_empty() {
return Err(CodexErr::InvalidRequest(
"expected turn id must not be empty".to_string(),
));
}
if approval_policy.is_none() && approvals_reviewer.is_none() {
return Err(CodexErr::InvalidRequest(
"at least one of approval_policy or approvals_reviewer must be set".to_string(),
));
}
let turn_contexts = {
let active = self.active_turn.lock().await;
let active_turn = active.as_ref().ok_or_else(|| {
CodexErr::InvalidRequest("no active turn to override".to_string())
})?;
let Some((active_turn_id, active_task)) = active_turn.tasks.first() else {
return Err(CodexErr::InvalidRequest(
"no active turn to override".to_string(),
));
};
if expected_turn_id != active_turn_id {
return Err(CodexErr::InvalidRequest(format!(
"expected active turn id `{expected_turn_id}` but found `{active_turn_id}`"
)));
}
if active_task.kind != crate::state::TaskKind::Regular {
return Err(CodexErr::InvalidRequest(
"only regular turns support active context overrides".to_string(),
));
}
active_turn
.tasks
.values()
.map(|task| Arc::clone(&task.turn_context))
.collect::<Vec<_>>()
};
let updated_approval_policy = {
let updates = SessionSettingsUpdate {
approval_policy,
approvals_reviewer,
..Default::default()
};
let mut state = self.state.lock().await;
let updated = state
.session_configuration
.apply(&updates)
.map_err(|err| CodexErr::InvalidRequest(err.to_string()))?;
let updated_approval_policy = approval_policy.map(|_| updated.approval_policy.clone());
state.session_configuration = updated;
updated_approval_policy
};
for turn_context in turn_contexts {
turn_context.override_active_approval_context(approval_policy, approvals_reviewer);
}
if let Some(approval_policy) = updated_approval_policy {
self.services
.mcp_connection_manager
.read()
.await
.set_approval_policy(&approval_policy);
}
Ok(expected_turn_id.to_string())
}
pub(crate) async fn set_session_startup_prewarm(
&self,
startup_prewarm: SessionStartupPrewarmHandle,
@@ -1882,7 +1961,7 @@ impl Session {
cwd: AbsolutePathBuf,
cancellation_token: CancellationToken,
) -> Option<RequestPermissionsResponse> {
match turn_context.as_ref().approval_policy.value() {
match turn_context.as_ref().approval_policy() {
AskForApproval::Never => {
return Some(RequestPermissionsResponse {
permissions: RequestPermissionProfile::default(),
@@ -2412,8 +2491,8 @@ impl Session {
developer_sections.push(
PermissionsInstructions::from_policy(
turn_context.sandbox_policy.get(),
turn_context.approval_policy.value(),
turn_context.config.approvals_reviewer,
turn_context.approval_policy(),
turn_context.approvals_reviewer(),
self.services.exec_policy.current().as_ref(),
&turn_context.cwd,
turn_context
+3 -1
View File
@@ -63,6 +63,7 @@ pub(super) async fn spawn_review_thread(
let mut per_turn_config = (*config).clone();
per_turn_config.model = Some(model.clone());
per_turn_config.features = review_features.clone();
per_turn_config.approvals_reviewer = parent_turn_context.approvals_reviewer();
if let Err(err) = per_turn_config.web_search_mode.set(review_web_search_mode) {
let fallback_value = per_turn_config.web_search_mode.value();
tracing::warn!(
@@ -122,7 +123,8 @@ pub(super) async fn spawn_review_thread(
compact_prompt: parent_turn_context.compact_prompt.clone(),
collaboration_mode: parent_turn_context.collaboration_mode.clone(),
personality: parent_turn_context.personality,
approval_policy: parent_turn_context.approval_policy.clone(),
approval_policy: parent_turn_context.approval_policy_constrained(),
active_overrides: Default::default(),
sandbox_policy: parent_turn_context.sandbox_policy.clone(),
file_system_sandbox_policy: parent_turn_context.file_system_sandbox_policy.clone(),
network_sandbox_policy: parent_turn_context.network_sandbox_policy,
+87
View File
@@ -3723,6 +3723,15 @@ fn op_kind_distinguishes_turn_ops() {
.kind(),
"override_turn_context"
);
assert_eq!(
Op::OverrideActiveTurnContext {
turn_id: "turn-1".to_string(),
approval_policy: None,
approvals_reviewer: None,
}
.kind(),
"override_active_turn_context"
);
assert_eq!(
Op::UserInput {
environments: None,
@@ -5681,6 +5690,84 @@ async fn steer_input_requires_active_turn() {
assert!(matches!(err, SteerInputError::NoActiveTurn(_)));
}
#[tokio::test]
async fn override_active_turn_context_updates_current_turn_and_defaults() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
sess.spawn_task(
Arc::clone(&tc),
input,
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: false,
},
)
.await;
let turn_id = sess
.override_active_turn_context(
&tc.sub_id,
Some(AskForApproval::Never),
Some(ApprovalsReviewer::GuardianSubagent),
)
.await
.expect("active context override should succeed");
assert_eq!(turn_id, tc.sub_id);
assert_eq!(tc.approval_policy(), AskForApproval::Never);
assert_eq!(tc.approvals_reviewer(), ApprovalsReviewer::GuardianSubagent);
let state = sess.state.lock().await;
assert_eq!(
state.session_configuration.approval_policy.value(),
AskForApproval::Never
);
assert_eq!(
state.session_configuration.approvals_reviewer,
ApprovalsReviewer::GuardianSubagent
);
}
#[tokio::test]
async fn override_active_turn_context_rejects_missing_or_wrong_active_turn() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
let err = sess
.override_active_turn_context(&tc.sub_id, Some(AskForApproval::Never), None)
.await
.expect_err("override without active turn should fail");
assert!(matches!(err, CodexErr::InvalidRequest(_)));
let input = vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
sess.spawn_task(
Arc::clone(&tc),
input,
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: false,
},
)
.await;
let err = sess
.override_active_turn_context("other-turn", Some(AskForApproval::Never), None)
.await
.expect_err("mismatched turn id should fail");
assert!(matches!(err, CodexErr::InvalidRequest(_)));
let err = sess
.override_active_turn_context(&tc.sub_id, None, None)
.await
.expect_err("empty override should fail");
assert!(matches!(err, CodexErr::InvalidRequest(_)));
}
#[tokio::test]
async fn steer_input_enforces_expected_turn_id() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
+4 -4
View File
@@ -507,7 +507,7 @@ pub(crate) async fn run_turn(
if !needs_follow_up {
last_agent_message = sampling_request_last_agent_message;
let stop_hook_permission_mode = match turn_context.approval_policy.value() {
let stop_hook_permission_mode = match turn_context.approval_policy() {
AskForApproval::Never => "bypassPermissions",
AskForApproval::UnlessTrusted
| AskForApproval::OnFailure
@@ -697,8 +697,8 @@ async fn track_turn_resolved_config_analytics(
reasoning_effort: turn_context.reasoning_effort,
reasoning_summary: Some(turn_context.reasoning_summary),
service_tier: turn_context.config.service_tier,
approval_policy: turn_context.approval_policy.value(),
approvals_reviewer: turn_context.config.approvals_reviewer,
approval_policy: turn_context.approval_policy(),
approvals_reviewer: turn_context.approvals_reviewer(),
sandbox_network_access: turn_context.network_sandbox_policy.is_enabled(),
collaboration_mode: turn_context.collaboration_mode.mode,
personality: turn_context.personality,
@@ -1871,7 +1871,7 @@ async fn try_run_sampling_request(
) -> CodexResult<SamplingRequestResult> {
feedback_tags!(
model = turn_context.model_info.slug.clone(),
approval_policy = turn_context.approval_policy.value(),
approval_policy = turn_context.approval_policy(),
sandbox_policy = turn_context.sandbox_policy.get(),
effort = turn_context.reasoning_effort,
auth_mode = sess.services.auth_manager.auth_mode(),
+57 -1
View File
@@ -3,6 +3,7 @@ use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
use std::sync::RwLock;
pub(super) fn image_generation_tool_auth_allowed(auth_manager: Option<&AuthManager>) -> bool {
matches!(
@@ -34,6 +35,12 @@ pub(crate) struct TurnEnvironment {
pub(crate) cwd: AbsolutePathBuf,
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct ActiveTurnContextOverrides {
approval_policy: Option<AskForApproval>,
approvals_reviewer: Option<ApprovalsReviewer>,
}
/// The context needed for a single turn of the thread.
#[derive(Debug)]
pub(crate) struct TurnContext {
@@ -63,6 +70,7 @@ pub(crate) struct TurnContext {
pub(crate) collaboration_mode: CollaborationMode,
pub(crate) personality: Option<Personality>,
pub(crate) approval_policy: Constrained<AskForApproval>,
pub(crate) active_overrides: RwLock<ActiveTurnContextOverrides>,
pub(crate) sandbox_policy: Constrained<SandboxPolicy>,
pub(crate) file_system_sandbox_policy: FileSystemSandboxPolicy,
pub(crate) network_sandbox_policy: NetworkSandboxPolicy,
@@ -84,6 +92,47 @@ pub(crate) struct TurnContext {
pub(crate) turn_timing_state: Arc<TurnTimingState>,
}
impl TurnContext {
pub(crate) fn approval_policy(&self) -> AskForApproval {
self.active_overrides
.read()
.ok()
.and_then(|overrides| overrides.approval_policy)
.unwrap_or_else(|| self.approval_policy.value())
}
pub(crate) fn approval_policy_constrained(&self) -> Constrained<AskForApproval> {
let mut approval_policy = self.approval_policy.clone();
approval_policy
.set(self.approval_policy())
.unwrap_or_else(|err| {
panic!("active approval policy override was validated before being applied: {err}")
});
approval_policy
}
pub(crate) fn approvals_reviewer(&self) -> ApprovalsReviewer {
self.active_overrides
.read()
.ok()
.and_then(|overrides| overrides.approvals_reviewer)
.unwrap_or(self.config.approvals_reviewer)
}
pub(crate) fn override_active_approval_context(
&self,
approval_policy: Option<AskForApproval>,
approvals_reviewer: Option<ApprovalsReviewer>,
) {
if let Ok(mut overrides) = self.active_overrides.write() {
if let Some(approval_policy) = approval_policy {
overrides.approval_policy = Some(approval_policy);
}
if let Some(approvals_reviewer) = approvals_reviewer {
overrides.approvals_reviewer = Some(approvals_reviewer);
}
}
}
pub(crate) fn permission_profile(&self) -> PermissionProfile {
PermissionProfile::from_runtime_permissions(
&self.file_system_sandbox_policy,
@@ -144,6 +193,11 @@ impl TurnContext {
Some(reasoning_effort),
/*developer_instructions*/ None,
);
let active_overrides = self
.active_overrides
.read()
.map(|overrides| *overrides)
.unwrap_or_default();
let features = self.features.clone();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
@@ -197,6 +251,7 @@ impl TurnContext {
collaboration_mode,
personality: self.personality,
approval_policy: self.approval_policy.clone(),
active_overrides: RwLock::new(active_overrides),
sandbox_policy: self.sandbox_policy.clone(),
file_system_sandbox_policy: self.file_system_sandbox_policy.clone(),
network_sandbox_policy: self.network_sandbox_policy,
@@ -270,7 +325,7 @@ impl TurnContext {
cwd: self.cwd.to_path_buf(),
current_date: self.current_date.clone(),
timezone: self.timezone.clone(),
approval_policy: self.approval_policy.value(),
approval_policy: self.approval_policy(),
sandbox_policy: self.sandbox_policy.get().clone(),
network: self.turn_context_network_item(),
file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(),
@@ -449,6 +504,7 @@ impl Session {
collaboration_mode: session_configuration.collaboration_mode.clone(),
personality: session_configuration.personality,
approval_policy: session_configuration.approval_policy.clone(),
active_overrides: RwLock::new(ActiveTurnContextOverrides::default()),
sandbox_policy: session_configuration.sandbox_policy.clone(),
file_system_sandbox_policy: session_configuration.file_system_sandbox_policy.clone(),
network_sandbox_policy: session_configuration.network_sandbox_policy,
@@ -426,7 +426,7 @@ impl ToolHandler for ApplyPatchHandler {
&req,
&tool_ctx,
turn.as_ref(),
turn.approval_policy.value(),
turn.approval_policy(),
)
.await
.map(|result| result.output);
@@ -534,7 +534,7 @@ pub(crate) async fn intercept_apply_patch(
&req,
&tool_ctx,
turn.as_ref(),
turn.approval_policy.value(),
turn.approval_policy(),
)
.await
.map(|result| result.output);
@@ -260,7 +260,7 @@ pub(crate) fn apply_spawn_agent_runtime_overrides(
config
.permissions
.approval_policy
.set(turn.approval_policy.value())
.set(turn.approval_policy())
.map_err(|err| {
FunctionCallError::RespondToModel(format!("approval_policy is invalid: {err}"))
})?;
+5 -11
View File
@@ -449,7 +449,7 @@ impl ShellHandler {
|| {
normalize_and_validate_additional_permissions(
additional_permissions_allowed,
turn.approval_policy.value(),
turn.approval_policy(),
effective_additional_permissions.sandbox_permissions,
effective_additional_permissions.additional_permissions,
effective_additional_permissions.permissions_preapproved,
@@ -468,11 +468,11 @@ impl ShellHandler {
.requests_sandbox_override()
&& !effective_additional_permissions.permissions_preapproved
&& !matches!(
turn.approval_policy.value(),
turn.approval_policy(),
codex_protocol::protocol::AskForApproval::OnRequest
)
{
let approval_policy = turn.approval_policy.value();
let approval_policy = turn.approval_policy();
return Err(FunctionCallError::RespondToModel(format!(
"approval policy is {approval_policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {approval_policy:?}"
)));
@@ -514,7 +514,7 @@ impl ShellHandler {
.exec_policy
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &exec_params.command,
approval_policy: turn.approval_policy.value(),
approval_policy: turn.approval_policy(),
sandbox_policy: turn.sandbox_policy.get(),
file_system_sandbox_policy: &turn.file_system_sandbox_policy,
sandbox_permissions: if effective_additional_permissions.permissions_preapproved {
@@ -559,13 +559,7 @@ impl ShellHandler {
tool_name,
};
let out = orchestrator
.run(
&mut runtime,
&req,
&tool_ctx,
&turn,
turn.approval_policy.value(),
)
.run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy())
.await
.map(|result| result.output);
let event_ctx = ToolEventCtx::new(
@@ -251,11 +251,11 @@ impl ToolHandler for UnifiedExecHandler {
.requests_sandbox_override()
&& !effective_additional_permissions.permissions_preapproved
&& !matches!(
context.turn.approval_policy.value(),
context.turn.approval_policy(),
codex_protocol::protocol::AskForApproval::OnRequest
)
{
let approval_policy = context.turn.approval_policy.value();
let approval_policy = context.turn.approval_policy();
manager.release_process_id(process_id).await;
return Err(FunctionCallError::RespondToModel(format!(
"approval policy is {approval_policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {approval_policy:?}"
@@ -275,7 +275,7 @@ impl ToolHandler for UnifiedExecHandler {
|| {
normalize_and_validate_additional_permissions(
additional_permissions_allowed,
context.turn.approval_policy.value(),
context.turn.approval_policy(),
effective_additional_permissions.sandbox_permissions,
effective_additional_permissions.additional_permissions,
effective_additional_permissions.permissions_preapproved,
+1 -1
View File
@@ -359,7 +359,7 @@ impl NetworkApprovalService {
.await;
return NetworkDecision::deny(REASON_NOT_ALLOWED);
}
if !allows_network_approval_flow(turn_context.approval_policy.value()) {
if !allows_network_approval_flow(turn_context.approval_policy()) {
pending.set_decision(PendingApprovalDecision::Deny).await;
self.pending_host_approvals.lock().await.remove(&key);
self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(
@@ -199,7 +199,7 @@ pub(super) async fn try_run_zsh_fork(
turn: Arc::clone(&ctx.turn),
call_id: ctx.call_id.clone(),
tool_name: GuardianCommandSource::Shell,
approval_policy: ctx.turn.approval_policy.value(),
approval_policy: ctx.turn.approval_policy(),
sandbox_policy: command_executor.sandbox_policy.clone(),
file_system_sandbox_policy: command_executor.file_system_sandbox_policy.clone(),
network_sandbox_policy: command_executor.network_sandbox_policy,
@@ -271,7 +271,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
turn: Arc::clone(&ctx.turn),
call_id: ctx.call_id.clone(),
tool_name: GuardianCommandSource::UnifiedExec,
approval_policy: ctx.turn.approval_policy.value(),
approval_policy: ctx.turn.approval_policy(),
sandbox_policy: exec_request.sandbox_policy.clone(),
file_system_sandbox_policy: exec_request.file_system_sandbox_policy.clone(),
network_sandbox_policy: exec_request.network_sandbox_policy,
@@ -792,7 +792,7 @@ impl UnifiedExecProcessManager {
.exec_policy
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &request.command,
approval_policy: context.turn.approval_policy.value(),
approval_policy: context.turn.approval_policy(),
sandbox_policy: context.turn.sandbox_policy.get(),
file_system_sandbox_policy: &context.turn.file_system_sandbox_policy,
sandbox_permissions: if request.additional_permissions_preapproved {
@@ -832,7 +832,7 @@ impl UnifiedExecProcessManager {
&req,
&tool_ctx,
&context.turn,
context.turn.approval_policy.value(),
context.turn.approval_policy(),
)
.await
.map(|result| (result.output, result.deferred_network_approval))
+18
View File
@@ -569,6 +569,23 @@ pub enum Op {
personality: Option<Personality>,
},
/// Override approval settings for the currently active turn and future turns.
///
/// This is intentionally narrower than [`Op::OverrideTurnContext`]: it only
/// affects approval behavior that can safely change while a turn is running.
OverrideActiveTurnContext {
/// Active turn id that must match before the override is applied.
turn_id: String,
/// Updated command approval policy.
#[serde(skip_serializing_if = "Option::is_none")]
approval_policy: Option<AskForApproval>,
/// Updated approval reviewer for approval prompts.
#[serde(skip_serializing_if = "Option::is_none")]
approvals_reviewer: Option<ApprovalsReviewer>,
},
/// Approve a command execution
ExecApproval {
/// The id of the submission we are approving
@@ -803,6 +820,7 @@ impl Op {
Self::UserTurn { .. } => "user_turn",
Self::InterAgentCommunication { .. } => "inter_agent_communication",
Self::OverrideTurnContext { .. } => "override_turn_context",
Self::OverrideActiveTurnContext { .. } => "override_active_turn_context",
Self::ExecApproval { .. } => "exec_approval",
Self::PatchApproval { .. } => "patch_approval",
Self::ResolveElicitation { .. } => "resolve_elicitation",