fix(runtime): honor named permission postures
Normalize current and legacy mode/permission wires into one runtime policy. Persist canonical thread defaults and per-turn receipts, then drive the engine's actual approval mode from that policy. Named postures are authoritative over legacy auto_approve/yolo fields, while trust_mode remains separate and invalid values fail closed.
This commit is contained in:
@@ -105,6 +105,7 @@ mod route_runtime;
|
||||
mod runtime_api;
|
||||
mod runtime_handoff;
|
||||
mod runtime_log;
|
||||
mod runtime_policy;
|
||||
mod runtime_threads;
|
||||
mod safe_label;
|
||||
mod sandbox;
|
||||
|
||||
@@ -202,6 +202,7 @@ struct StreamTurnRequest {
|
||||
prompt: String,
|
||||
model: Option<String>,
|
||||
mode: Option<String>,
|
||||
permission_posture: Option<String>,
|
||||
workspace: Option<PathBuf>,
|
||||
allow_shell: Option<bool>,
|
||||
trust_mode: Option<bool>,
|
||||
@@ -2050,6 +2051,7 @@ async fn retry_thread_turn(
|
||||
input_summary: None,
|
||||
model: None,
|
||||
mode: None,
|
||||
permission_posture: None,
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
@@ -2363,6 +2365,7 @@ async fn stream_turn(
|
||||
.clone()
|
||||
.unwrap_or_else(|| state.workspace.clone());
|
||||
let mode = req.mode.clone().unwrap_or_else(|| "agent".to_string());
|
||||
let permission_posture = req.permission_posture.clone();
|
||||
let allow_shell = req.allow_shell.unwrap_or(state.config.read().allow_shell());
|
||||
let trust_mode = req.trust_mode.unwrap_or(false);
|
||||
let auto_approve = req.auto_approve.unwrap_or(false);
|
||||
@@ -2374,6 +2377,7 @@ async fn stream_turn(
|
||||
model: Some(model.clone()),
|
||||
workspace: Some(workspace.clone()),
|
||||
mode: Some(mode.clone()),
|
||||
permission_posture: permission_posture.clone(),
|
||||
allow_shell: Some(allow_shell),
|
||||
trust_mode: Some(trust_mode),
|
||||
auto_approve: Some(auto_approve),
|
||||
@@ -2407,6 +2411,7 @@ async fn stream_turn(
|
||||
input_summary: None,
|
||||
model: Some(model.clone()),
|
||||
mode: Some(mode.clone()),
|
||||
permission_posture,
|
||||
allow_shell: Some(allow_shell),
|
||||
trust_mode: Some(trust_mode),
|
||||
auto_approve: Some(auto_approve),
|
||||
|
||||
@@ -270,6 +270,7 @@ fn messages_from_thread_detail_batches_tool_results() {
|
||||
model_provider_id: None,
|
||||
workspace: PathBuf::from("."),
|
||||
mode: "agent".to_string(),
|
||||
permission_posture: Some("ask".to_string()),
|
||||
allow_shell: false,
|
||||
trust_mode: false,
|
||||
auto_approve: false,
|
||||
@@ -292,6 +293,7 @@ fn messages_from_thread_detail_batches_tool_results() {
|
||||
ended_at: Some(now),
|
||||
duration_ms: Some(0),
|
||||
usage: None,
|
||||
permission_posture: Some("ask".to_string()),
|
||||
effective_provider: None,
|
||||
effective_provider_id: None,
|
||||
effective_billing_surface: None,
|
||||
@@ -455,6 +457,7 @@ fn legacy_exact_thread_export_normalizes_provider_kind_and_id() {
|
||||
model_provider_id: None,
|
||||
workspace: PathBuf::from("."),
|
||||
mode: "agent".to_string(),
|
||||
permission_posture: None,
|
||||
allow_shell: false,
|
||||
trust_mode: false,
|
||||
auto_approve: false,
|
||||
@@ -4935,6 +4938,61 @@ async fn create_thread_accepts_dynamic_tools_and_environments() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_thread_normalizes_and_persists_named_permission_posture() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
let created: serde_json::Value = client
|
||||
.post(format!("http://{addr}/v1/threads"))
|
||||
.json(&json!({
|
||||
"model": "test-model",
|
||||
"mode": "operate",
|
||||
"permission_posture": "auto-review"
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(created["mode"], "operate");
|
||||
assert_eq!(created["permission_posture"], "auto_review");
|
||||
assert_eq!(created["auto_approve"], false);
|
||||
assert_eq!(created["trust_mode"], false);
|
||||
|
||||
let authoritative_ask: serde_json::Value = client
|
||||
.post(format!("http://{addr}/v1/threads"))
|
||||
.json(&json!({
|
||||
"model": "test-model",
|
||||
"mode": "yolo",
|
||||
"permission_posture": "ask",
|
||||
"auto_approve": true
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(authoritative_ask["mode"], "agent");
|
||||
assert_eq!(authoritative_ask["permission_posture"], "ask");
|
||||
assert_eq!(authoritative_ask["auto_approve"], false);
|
||||
|
||||
let invalid = client
|
||||
.post(format!("http://{addr}/v1/threads"))
|
||||
.json(&json!({
|
||||
"model": "test-model",
|
||||
"permission_posture": "owner"
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_turn_accepts_dynamic_tools_and_environment_id() -> Result<()> {
|
||||
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
@@ -4963,7 +5021,8 @@ async fn start_turn_accepts_dynamic_tools_and_environment_id() -> Result<()> {
|
||||
"input_schema": { "type": "object" }
|
||||
}
|
||||
],
|
||||
"environment_id": "local"
|
||||
"environment_id": "local",
|
||||
"permission_posture": "auto-review"
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
@@ -4971,6 +5030,17 @@ async fn start_turn_accepts_dynamic_tools_and_environment_id() -> Result<()> {
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(started["turn"]["thread_id"], thread_id);
|
||||
assert_eq!(started["thread"]["permission_posture"], "ask");
|
||||
assert_eq!(started["turn"]["permission_posture"], "auto_review");
|
||||
|
||||
let stored: serde_json::Value = client
|
||||
.get(format!("http://{addr}/v1/threads/{thread_id}"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(stored["turns"][0]["permission_posture"], "auto_review");
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Normalization for Runtime mode and permission-posture wires.
|
||||
//!
|
||||
//! Older clients can still send mode aliases and the legacy `auto_approve`
|
||||
//! bit. New callers send a named posture. Keep those inputs readable, but
|
||||
//! persist and execute one contract: Plan / Act / Operate plus Ask /
|
||||
//! Auto-Review / Full Access.
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
use crate::tui::app::AppMode;
|
||||
use crate::tui::approval::ApprovalMode;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct RuntimePolicyProjection {
|
||||
pub(crate) mode: AppMode,
|
||||
pub(crate) permission: ApprovalMode,
|
||||
}
|
||||
|
||||
impl RuntimePolicyProjection {
|
||||
/// Read a persisted compatibility shape. Unknown historical values fail
|
||||
/// closed to Act + Ask unless the old bypass bit is explicitly present.
|
||||
#[must_use]
|
||||
pub(crate) fn from_persisted(
|
||||
mode: &str,
|
||||
permission_posture: Option<&str>,
|
||||
auto_approve: bool,
|
||||
) -> Self {
|
||||
let parsed_mode = parse_runtime_mode(mode).unwrap_or(AppMode::Agent);
|
||||
let permission = permission_posture
|
||||
.and_then(ApprovalMode::from_config_value)
|
||||
.filter(|permission| *permission != ApprovalMode::Never)
|
||||
.unwrap_or_else(|| {
|
||||
if parsed_mode == AppMode::Yolo || auto_approve {
|
||||
ApprovalMode::Bypass
|
||||
} else {
|
||||
ApprovalMode::Suggest
|
||||
}
|
||||
});
|
||||
Self {
|
||||
mode: visible_mode(parsed_mode),
|
||||
permission,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate and normalize a new Runtime request. Legacy aliases are
|
||||
/// accepted as one-way inputs, but only current values are persisted.
|
||||
pub(crate) fn from_request(
|
||||
mode: &str,
|
||||
permission_posture: Option<&str>,
|
||||
auto_approve: Option<bool>,
|
||||
) -> Result<Self> {
|
||||
let parsed_mode = parse_runtime_mode(mode).ok_or_else(|| {
|
||||
anyhow::anyhow!("unsupported Runtime mode {mode:?}; expected plan, act, or operate")
|
||||
})?;
|
||||
let permission = match permission_posture {
|
||||
Some(value) => ApprovalMode::from_config_value(value).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"unsupported permission posture {value:?}; expected ask, auto-review, or full-access"
|
||||
)
|
||||
})?,
|
||||
None if parsed_mode == AppMode::Yolo || auto_approve.unwrap_or(false) => {
|
||||
ApprovalMode::Bypass
|
||||
}
|
||||
None => ApprovalMode::Suggest,
|
||||
};
|
||||
if permission == ApprovalMode::Never {
|
||||
bail!("permission posture 'never' is not part of the Runtime product contract");
|
||||
}
|
||||
Ok(Self {
|
||||
mode: visible_mode(parsed_mode),
|
||||
permission,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn mode_setting(self) -> &'static str {
|
||||
self.mode.as_setting()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn permission_wire(self) -> &'static str {
|
||||
match self.permission {
|
||||
ApprovalMode::Suggest => "ask",
|
||||
ApprovalMode::Auto => "auto_review",
|
||||
ApprovalMode::Bypass => "full_access",
|
||||
ApprovalMode::Never => "ask",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn auto_approve(self) -> bool {
|
||||
self.permission == ApprovalMode::Bypass
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn parse_runtime_mode(value: &str) -> Option<AppMode> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"normal" => Some(AppMode::Agent),
|
||||
other => AppMode::parse(other),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn visible_mode(mode: AppMode) -> AppMode {
|
||||
match mode {
|
||||
AppMode::Auto | AppMode::Yolo => AppMode::Agent,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_inputs_project_to_current_mode_and_permission_wires() {
|
||||
for alias in ["normal", "agent", "auto"] {
|
||||
let policy = RuntimePolicyProjection::from_persisted(alias, None, false);
|
||||
assert_eq!(policy.mode_setting(), "agent", "{alias}");
|
||||
assert_eq!(policy.permission_wire(), "ask", "{alias}");
|
||||
}
|
||||
for alias in ["yolo", "bypass"] {
|
||||
let policy = RuntimePolicyProjection::from_persisted(alias, None, false);
|
||||
assert_eq!(policy.mode_setting(), "agent", "{alias}");
|
||||
assert_eq!(policy.permission_wire(), "full_access", "{alias}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_posture_is_normalized_and_authoritative() {
|
||||
let auto = RuntimePolicyProjection::from_request("operate", Some("auto-review"), None)
|
||||
.expect("compat permission");
|
||||
assert_eq!(auto.mode_setting(), "operate");
|
||||
assert_eq!(auto.permission_wire(), "auto_review");
|
||||
assert_eq!(auto.permission, ApprovalMode::Auto);
|
||||
assert!(!auto.auto_approve());
|
||||
|
||||
let full = RuntimePolicyProjection::from_request("act", Some("full_access"), None)
|
||||
.expect("current permission wire");
|
||||
assert_eq!(full.permission_wire(), "full_access");
|
||||
assert_eq!(full.permission, ApprovalMode::Bypass);
|
||||
assert!(full.auto_approve());
|
||||
|
||||
let ask = RuntimePolicyProjection::from_request("yolo", Some("ask"), Some(true))
|
||||
.expect("named posture overrides legacy flags");
|
||||
assert_eq!(ask.mode_setting(), "agent");
|
||||
assert_eq!(ask.permission_wire(), "ask");
|
||||
assert_eq!(ask.permission, ApprovalMode::Suggest);
|
||||
assert!(!ask.auto_approve());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_or_never_postures_are_rejected() {
|
||||
assert!(RuntimePolicyProjection::from_request("act", Some("owner"), None).is_err());
|
||||
assert!(RuntimePolicyProjection::from_request("act", Some("never"), None).is_err());
|
||||
}
|
||||
}
|
||||
@@ -46,9 +46,11 @@ use crate::route_budget::{
|
||||
use crate::route_runtime::{
|
||||
ResolvedRuntimeRoute, resolve_runtime_route, resolve_runtime_route_for_identity,
|
||||
};
|
||||
use crate::runtime_policy::RuntimePolicyProjection;
|
||||
use crate::tools::plan::new_shared_plan_state;
|
||||
use crate::tools::subagent::SubAgentStatus;
|
||||
use crate::tools::todo::new_shared_todo_list;
|
||||
#[cfg(test)]
|
||||
use crate::tui::app::AppMode;
|
||||
use codewhale_protocol::runtime::{
|
||||
DynamicToolCallContent, DynamicToolCallParams, DynamicToolCallResult, DynamicToolSpec,
|
||||
@@ -432,6 +434,10 @@ pub struct ThreadRecord {
|
||||
pub model_provider_id: Option<String>,
|
||||
pub workspace: PathBuf,
|
||||
pub mode: String,
|
||||
/// Named default permission posture for new turns. Absent on legacy
|
||||
/// records, whose effective posture is derived from the old fields.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub permission_posture: Option<String>,
|
||||
pub allow_shell: bool,
|
||||
pub trust_mode: bool,
|
||||
pub auto_approve: bool,
|
||||
@@ -467,6 +473,7 @@ fn thread_execution_state_matches(left: &ThreadRecord, right: &ThreadRecord) ->
|
||||
&& left.model_provider_id == right.model_provider_id
|
||||
&& left.workspace == right.workspace
|
||||
&& left.mode == right.mode
|
||||
&& left.permission_posture == right.permission_posture
|
||||
&& left.allow_shell == right.allow_shell
|
||||
&& left.trust_mode == right.trust_mode
|
||||
&& left.auto_approve == right.auto_approve
|
||||
@@ -495,6 +502,10 @@ pub struct TurnRecord {
|
||||
pub duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<Usage>,
|
||||
/// Canonical posture that governed this turn. New records always carry
|
||||
/// this receipt; old records deserialize with no fabricated value.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub permission_posture: Option<String>,
|
||||
/// Concrete generic provider kind selected for this turn.
|
||||
#[serde(
|
||||
default,
|
||||
@@ -1477,6 +1488,8 @@ pub struct CreateThreadRequest {
|
||||
pub model_provider_id: Option<String>,
|
||||
pub workspace: Option<PathBuf>,
|
||||
pub mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub permission_posture: Option<String>,
|
||||
pub allow_shell: Option<bool>,
|
||||
pub trust_mode: Option<bool>,
|
||||
pub auto_approve: Option<bool>,
|
||||
@@ -1505,6 +1518,7 @@ pub struct UpdateThreadRequest {
|
||||
pub auto_approve: Option<bool>,
|
||||
pub model: Option<String>,
|
||||
pub mode: Option<String>,
|
||||
pub permission_posture: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub system_prompt: Option<String>,
|
||||
pub workspace: Option<PathBuf>,
|
||||
@@ -1517,6 +1531,8 @@ pub struct StartTurnRequest {
|
||||
pub input_summary: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub permission_posture: Option<String>,
|
||||
pub allow_shell: Option<bool>,
|
||||
pub trust_mode: Option<bool>,
|
||||
pub auto_approve: Option<bool>,
|
||||
@@ -3504,15 +3520,22 @@ impl RuntimeThreadManager {
|
||||
.filter(|m| !m.trim().is_empty())
|
||||
.unwrap_or(default_model);
|
||||
let workspace = req.workspace.unwrap_or_else(|| self.workspace.clone());
|
||||
let mode = req
|
||||
let requested_mode = req
|
||||
.mode
|
||||
.filter(|m| !m.trim().is_empty())
|
||||
.unwrap_or_else(|| "agent".to_string());
|
||||
let policy = RuntimePolicyProjection::from_request(
|
||||
&requested_mode,
|
||||
req.permission_posture.as_deref(),
|
||||
req.auto_approve,
|
||||
)?;
|
||||
let mode = policy.mode_setting().to_string();
|
||||
let permission_posture = Some(policy.permission_wire().to_string());
|
||||
let allow_shell = req
|
||||
.allow_shell
|
||||
.unwrap_or_else(|| self.read_config().allow_shell());
|
||||
let trust_mode = req.trust_mode.unwrap_or(false);
|
||||
let auto_approve = req.auto_approve.unwrap_or(false);
|
||||
let auto_approve = policy.auto_approve();
|
||||
|
||||
let thread = ThreadRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
@@ -3524,6 +3547,7 @@ impl RuntimeThreadManager {
|
||||
model_provider_id,
|
||||
workspace,
|
||||
mode,
|
||||
permission_posture,
|
||||
allow_shell,
|
||||
trust_mode,
|
||||
auto_approve,
|
||||
@@ -3668,6 +3692,7 @@ impl RuntimeThreadManager {
|
||||
&& req.auto_approve.is_none()
|
||||
&& req.model.is_none()
|
||||
&& req.mode.is_none()
|
||||
&& req.permission_posture.is_none()
|
||||
&& req.title.is_none()
|
||||
&& req.system_prompt.is_none()
|
||||
&& req.workspace.is_none()
|
||||
@@ -3685,6 +3710,11 @@ impl RuntimeThreadManager {
|
||||
{
|
||||
bail!("mode must not be empty");
|
||||
}
|
||||
if let Some(permission_posture) = req.permission_posture.as_ref()
|
||||
&& permission_posture.trim().is_empty()
|
||||
{
|
||||
bail!("permission_posture must not be empty");
|
||||
}
|
||||
if let Some(workspace) = req.workspace.as_ref()
|
||||
&& workspace.as_os_str().is_empty()
|
||||
{
|
||||
@@ -3702,6 +3732,33 @@ impl RuntimeThreadManager {
|
||||
.load_thread(id)
|
||||
.with_context(|| format!("Thread not found: {id}"))?;
|
||||
let mut changes = serde_json::Map::new();
|
||||
let policy_patch = if req.mode.is_some()
|
||||
|| req.permission_posture.is_some()
|
||||
|| req.auto_approve.is_some()
|
||||
{
|
||||
let requested_mode = req.mode.as_deref().unwrap_or(&thread.mode);
|
||||
let requested_permission = if req.permission_posture.is_some() {
|
||||
req.permission_posture.as_deref()
|
||||
} else if req.auto_approve.is_some()
|
||||
|| req.mode.as_deref().is_some_and(|mode| {
|
||||
matches!(
|
||||
mode.trim().to_ascii_lowercase().as_str(),
|
||||
"yolo" | "4" | "bypass" | "bypass-permissions" | "bypasspermissions"
|
||||
)
|
||||
})
|
||||
{
|
||||
None
|
||||
} else {
|
||||
thread.permission_posture.as_deref()
|
||||
};
|
||||
Some(RuntimePolicyProjection::from_request(
|
||||
requested_mode,
|
||||
requested_permission,
|
||||
req.auto_approve,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(archived) = req.archived
|
||||
&& thread.archived != archived
|
||||
@@ -3721,23 +3778,28 @@ impl RuntimeThreadManager {
|
||||
thread.trust_mode = trust_mode;
|
||||
changes.insert("trust_mode".to_string(), json!(trust_mode));
|
||||
}
|
||||
if let Some(auto_approve) = req.auto_approve
|
||||
&& thread.auto_approve != auto_approve
|
||||
{
|
||||
thread.auto_approve = auto_approve;
|
||||
changes.insert("auto_approve".to_string(), json!(auto_approve));
|
||||
}
|
||||
if let Some(model) = req.model
|
||||
&& thread.model != model
|
||||
{
|
||||
thread.model = model.clone();
|
||||
changes.insert("model".to_string(), json!(model));
|
||||
}
|
||||
if let Some(mode) = req.mode
|
||||
&& thread.mode != mode
|
||||
{
|
||||
thread.mode = mode.clone();
|
||||
changes.insert("mode".to_string(), json!(mode));
|
||||
if let Some(policy) = policy_patch {
|
||||
let mode = policy.mode_setting().to_string();
|
||||
let permission_posture = Some(policy.permission_wire().to_string());
|
||||
let auto_approve = policy.auto_approve();
|
||||
if thread.mode != mode {
|
||||
thread.mode = mode.clone();
|
||||
changes.insert("mode".to_string(), json!(mode));
|
||||
}
|
||||
if thread.permission_posture != permission_posture {
|
||||
thread.permission_posture = permission_posture.clone();
|
||||
changes.insert("permission_posture".to_string(), json!(permission_posture));
|
||||
}
|
||||
if thread.auto_approve != auto_approve {
|
||||
thread.auto_approve = auto_approve;
|
||||
changes.insert("auto_approve".to_string(), json!(auto_approve));
|
||||
}
|
||||
}
|
||||
if let Some(title) = req.title {
|
||||
// Empty string clears a previously-set title and reverts to derived.
|
||||
@@ -4438,6 +4500,7 @@ impl RuntimeThreadManager {
|
||||
ended_at: Some(now),
|
||||
duration_ms: Some(0),
|
||||
usage: None,
|
||||
permission_posture: None,
|
||||
effective_provider: None,
|
||||
effective_provider_id: None,
|
||||
effective_billing_surface: None,
|
||||
@@ -4862,6 +4925,37 @@ impl RuntimeThreadManager {
|
||||
}
|
||||
|
||||
let thread = self.get_thread(thread_id).await?;
|
||||
let requested_mode = req.mode.as_deref().unwrap_or(&thread.mode);
|
||||
let policy =
|
||||
if req.mode.is_some() || req.permission_posture.is_some() || req.auto_approve.is_some()
|
||||
{
|
||||
let requested_permission = if req.permission_posture.is_some() {
|
||||
req.permission_posture.as_deref()
|
||||
} else if req.auto_approve.is_some()
|
||||
|| req.mode.as_deref().is_some_and(|mode| {
|
||||
matches!(
|
||||
mode.trim().to_ascii_lowercase().as_str(),
|
||||
"yolo" | "4" | "bypass" | "bypass-permissions" | "bypasspermissions"
|
||||
)
|
||||
})
|
||||
{
|
||||
None
|
||||
} else {
|
||||
thread.permission_posture.as_deref()
|
||||
};
|
||||
RuntimePolicyProjection::from_request(
|
||||
requested_mode,
|
||||
requested_permission,
|
||||
req.auto_approve,
|
||||
)?
|
||||
} else {
|
||||
RuntimePolicyProjection::from_persisted(
|
||||
requested_mode,
|
||||
thread.permission_posture.as_deref(),
|
||||
thread.auto_approve,
|
||||
)
|
||||
};
|
||||
let mode = policy.mode;
|
||||
let engine = self.ensure_engine_loaded(&thread).await?;
|
||||
|
||||
let client_preflight_required = {
|
||||
@@ -4880,11 +4974,6 @@ impl RuntimeThreadManager {
|
||||
// Resolve the concrete provider/model before persisting a turn. Auto
|
||||
// routing can fail, and such a failure must not leave a zombie
|
||||
// in-progress record behind.
|
||||
let mode = req
|
||||
.mode
|
||||
.as_deref()
|
||||
.and_then(parse_mode_opt)
|
||||
.unwrap_or_else(|| parse_mode(&thread.mode));
|
||||
let requested_model = req.model.as_deref().unwrap_or(&thread.model).to_string();
|
||||
let auto_model = requested_model.trim().eq_ignore_ascii_case("auto");
|
||||
let cfg_snapshot = self.config.read().clone();
|
||||
@@ -4973,6 +5062,7 @@ impl RuntimeThreadManager {
|
||||
ended_at: None,
|
||||
duration_ms: None,
|
||||
usage: None,
|
||||
permission_posture: Some(policy.permission_wire().to_string()),
|
||||
effective_provider: Some(provider.as_str().to_string()),
|
||||
effective_provider_id: provider_identity
|
||||
.exact_id
|
||||
@@ -5009,7 +5099,7 @@ impl RuntimeThreadManager {
|
||||
|
||||
let allow_shell = req.allow_shell.unwrap_or(thread.allow_shell);
|
||||
let trust_mode = req.trust_mode.unwrap_or(thread.trust_mode);
|
||||
let auto_approve = req.auto_approve.unwrap_or(thread.auto_approve);
|
||||
let auto_approve = policy.auto_approve();
|
||||
let op = Op::SendMessage {
|
||||
content: prompt,
|
||||
mode,
|
||||
@@ -5028,11 +5118,7 @@ impl RuntimeThreadManager {
|
||||
allowed_tools: None,
|
||||
dynamic_tools: req.dynamic_tools,
|
||||
hook_executor: None,
|
||||
approval_mode: if auto_approve {
|
||||
crate::tui::approval::ApprovalMode::Bypass
|
||||
} else {
|
||||
crate::tui::approval::ApprovalMode::Suggest
|
||||
},
|
||||
approval_mode: policy.permission,
|
||||
verbosity,
|
||||
provenance: crate::core::ops::UserInputProvenance::ExternalUser,
|
||||
};
|
||||
@@ -5307,6 +5393,15 @@ impl RuntimeThreadManager {
|
||||
ended_at: None,
|
||||
duration_ms: None,
|
||||
usage: None,
|
||||
permission_posture: Some(
|
||||
RuntimePolicyProjection::from_persisted(
|
||||
&thread.mode,
|
||||
thread.permission_posture.as_deref(),
|
||||
thread.auto_approve,
|
||||
)
|
||||
.permission_wire()
|
||||
.to_string(),
|
||||
),
|
||||
effective_provider: Some(route_provider.as_str().to_string()),
|
||||
effective_provider_id: route_identity
|
||||
.exact_id
|
||||
@@ -5663,7 +5758,12 @@ impl RuntimeThreadManager {
|
||||
system_prompt_override: thread.system_prompt.is_some(),
|
||||
model: route_model.clone(),
|
||||
workspace: thread.workspace.clone(),
|
||||
mode: parse_mode(&thread.mode),
|
||||
mode: RuntimePolicyProjection::from_persisted(
|
||||
&thread.mode,
|
||||
thread.permission_posture.as_deref(),
|
||||
thread.auto_approve,
|
||||
)
|
||||
.mode,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to sync thread session: {e}"))?;
|
||||
@@ -7464,22 +7564,13 @@ fn enforce_lru_capacity(
|
||||
evicted
|
||||
}
|
||||
|
||||
/// Resolves only explicit mode tokens to an app mode. Free-form prompt text is
|
||||
/// never a valid mode token: `parse_mode_opt` returns `None` unless the input is
|
||||
/// exactly `agent`/`plan`/`yolo` or numeric aliases `1`/`2`/`4`. Mode
|
||||
/// changes originate from the Tab cycle, `/mode`, the mode picker, or
|
||||
/// config/startup defaults, not from submitted natural-language prompt text.
|
||||
///
|
||||
/// Textual `auto` is a legacy alias for Agent while Auto is deferred (#3733).
|
||||
/// Compatibility parser retained for focused Runtime tests.
|
||||
#[cfg(test)]
|
||||
fn parse_mode_opt(mode: &str) -> Option<AppMode> {
|
||||
match mode.trim().to_ascii_lowercase().as_str() {
|
||||
"agent" | "auto" | "1" => Some(AppMode::Agent),
|
||||
"plan" | "2" => Some(AppMode::Plan),
|
||||
"yolo" | "4" | "bypass" | "bypass-permissions" | "bypasspermissions" => Some(AppMode::Yolo),
|
||||
_ => None,
|
||||
}
|
||||
crate::runtime_policy::parse_runtime_mode(mode)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn parse_mode(mode: &str) -> AppMode {
|
||||
parse_mode_opt(mode).unwrap_or(AppMode::Agent)
|
||||
}
|
||||
|
||||
@@ -318,6 +318,7 @@ fn sample_thread(thread_id: &str) -> ThreadRecord {
|
||||
model_provider_id: None,
|
||||
workspace: PathBuf::from("."),
|
||||
mode: AppMode::Agent.as_setting().to_string(),
|
||||
permission_posture: Some("ask".to_string()),
|
||||
allow_shell: false,
|
||||
trust_mode: false,
|
||||
auto_approve: false,
|
||||
@@ -344,6 +345,7 @@ fn sample_turn(thread_id: &str, turn_id: &str, status: RuntimeTurnStatus) -> Tur
|
||||
ended_at: None,
|
||||
duration_ms: None,
|
||||
usage: None,
|
||||
permission_posture: None,
|
||||
effective_provider: None,
|
||||
effective_provider_id: None,
|
||||
effective_billing_surface: None,
|
||||
@@ -4268,7 +4270,7 @@ async fn start_turn_passes_effective_auto_approve_to_engine() -> Result<()> {
|
||||
let harness = install_mock_engine(&manager, &thread.id).await;
|
||||
let mut rx_op = harness.rx_op;
|
||||
|
||||
let _turn = manager
|
||||
let turn = manager
|
||||
.start_turn(
|
||||
&thread.id,
|
||||
StartTurnRequest {
|
||||
@@ -4283,9 +4285,17 @@ async fn start_turn_passes_effective_auto_approve_to_engine() -> Result<()> {
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(turn.permission_posture.as_deref(), Some("full_access"));
|
||||
|
||||
match rx_op.recv().await {
|
||||
Some(Op::SendMessage { auto_approve, .. }) => assert!(auto_approve),
|
||||
Some(Op::SendMessage {
|
||||
auto_approve,
|
||||
approval_mode,
|
||||
..
|
||||
}) => {
|
||||
assert!(auto_approve);
|
||||
assert_eq!(approval_mode, crate::tui::approval::ApprovalMode::Bypass);
|
||||
}
|
||||
other => panic!("expected SendMessage op, got {other:?}"),
|
||||
}
|
||||
|
||||
@@ -4313,7 +4323,7 @@ async fn start_turn_can_override_thread_auto_approve_to_false() -> Result<()> {
|
||||
let harness = install_mock_engine(&manager, &thread.id).await;
|
||||
let mut rx_op = harness.rx_op;
|
||||
|
||||
let _turn = manager
|
||||
let turn = manager
|
||||
.start_turn(
|
||||
&thread.id,
|
||||
StartTurnRequest {
|
||||
@@ -4328,15 +4338,69 @@ async fn start_turn_can_override_thread_auto_approve_to_false() -> Result<()> {
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(turn.permission_posture.as_deref(), Some("ask"));
|
||||
|
||||
match rx_op.recv().await {
|
||||
Some(Op::SendMessage { auto_approve, .. }) => assert!(!auto_approve),
|
||||
Some(Op::SendMessage {
|
||||
auto_approve,
|
||||
approval_mode,
|
||||
..
|
||||
}) => {
|
||||
assert!(!auto_approve);
|
||||
assert_eq!(approval_mode, crate::tui::approval::ApprovalMode::Suggest);
|
||||
}
|
||||
other => panic!("expected SendMessage op, got {other:?}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_turn_enforces_and_records_auto_review_without_legacy_bypass() -> Result<()> {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
permission_posture: Some("ask".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let harness = install_mock_engine(&manager, &thread.id).await;
|
||||
let mut rx_op = harness.rx_op;
|
||||
|
||||
let turn = manager
|
||||
.start_turn(
|
||||
&thread.id,
|
||||
StartTurnRequest {
|
||||
prompt: "review autonomously".to_string(),
|
||||
permission_posture: Some("auto-review".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(turn.permission_posture.as_deref(), Some("auto_review"));
|
||||
assert_eq!(
|
||||
manager
|
||||
.store
|
||||
.load_turn(&turn.id)?
|
||||
.permission_posture
|
||||
.as_deref(),
|
||||
Some("auto_review")
|
||||
);
|
||||
match rx_op.recv().await {
|
||||
Some(Op::SendMessage {
|
||||
auto_approve,
|
||||
approval_mode,
|
||||
..
|
||||
}) => {
|
||||
assert!(!auto_approve);
|
||||
assert_eq!(approval_mode, crate::tui::approval::ApprovalMode::Auto);
|
||||
}
|
||||
other => panic!("expected SendMessage op, got {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compact_thread_preserves_thread_auto_approve_policy() -> Result<()> {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
@@ -8492,6 +8556,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
|
||||
model_provider_id: None,
|
||||
workspace: PathBuf::from("."),
|
||||
mode: "agent".to_string(),
|
||||
permission_posture: None,
|
||||
allow_shell: false,
|
||||
trust_mode: false,
|
||||
auto_approve: false,
|
||||
@@ -8559,6 +8624,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
|
||||
ended_at: None,
|
||||
duration_ms: None,
|
||||
usage: None,
|
||||
permission_posture: None,
|
||||
effective_provider: None,
|
||||
effective_provider_id: None,
|
||||
effective_billing_surface: None,
|
||||
@@ -8584,6 +8650,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
|
||||
ended_at: None,
|
||||
duration_ms: None,
|
||||
usage: None,
|
||||
permission_posture: None,
|
||||
effective_provider: None,
|
||||
effective_provider_id: None,
|
||||
effective_billing_surface: None,
|
||||
@@ -8664,7 +8731,8 @@ fn parse_mode_opt_resolves_explicit_tokens_and_aliases() {
|
||||
assert_eq!(parse_mode_opt("plan"), Some(AppMode::Plan));
|
||||
assert_eq!(parse_mode_opt("2"), Some(AppMode::Plan));
|
||||
assert_eq!(parse_mode_opt("auto"), Some(AppMode::Agent));
|
||||
assert_eq!(parse_mode_opt("3"), None);
|
||||
assert_eq!(parse_mode_opt("operate"), Some(AppMode::Operate));
|
||||
assert_eq!(parse_mode_opt("3"), Some(AppMode::Operate));
|
||||
assert_eq!(parse_mode_opt("yolo"), Some(AppMode::Yolo));
|
||||
assert_eq!(parse_mode_opt("4"), Some(AppMode::Yolo));
|
||||
assert_eq!(parse_mode_opt(" PLAN "), Some(AppMode::Plan));
|
||||
@@ -8689,7 +8757,7 @@ fn parse_mode_wrapper_defaults_and_resolves_numeric_aliases() {
|
||||
assert_eq!(parse_mode("auto"), AppMode::Agent);
|
||||
assert_eq!(parse_mode("1"), AppMode::Agent);
|
||||
assert_eq!(parse_mode("2"), AppMode::Plan);
|
||||
assert_eq!(parse_mode("3"), AppMode::Agent);
|
||||
assert_eq!(parse_mode("3"), AppMode::Operate);
|
||||
assert_eq!(parse_mode("4"), AppMode::Yolo);
|
||||
}
|
||||
|
||||
@@ -8839,6 +8907,7 @@ fn seed_turns_with_user_messages(
|
||||
ended_at: Some(created_at),
|
||||
duration_ms: Some(0),
|
||||
usage: None,
|
||||
permission_posture: None,
|
||||
effective_provider: None,
|
||||
effective_provider_id: None,
|
||||
effective_billing_surface: None,
|
||||
|
||||
@@ -81,7 +81,9 @@ impl ApprovalMode {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"auto" | "auto-review" | "auto_review" => Some(ApprovalMode::Auto),
|
||||
"bypass" | "yolo" | "dontask" | "dont_ask" | "bypass-permissions"
|
||||
| "bypasspermissions" | "full-access" | "full" => Some(ApprovalMode::Bypass),
|
||||
| "bypasspermissions" | "full-access" | "full_access" | "full" => {
|
||||
Some(ApprovalMode::Bypass)
|
||||
}
|
||||
"suggest" | "suggested" | "on-request" | "untrusted" | "ask" => {
|
||||
Some(ApprovalMode::Suggest)
|
||||
}
|
||||
@@ -4377,6 +4379,10 @@ diff --git a/src/b.rs b/src/b.rs
|
||||
ApprovalMode::from_config_value("on-request"),
|
||||
Some(ApprovalMode::Suggest)
|
||||
);
|
||||
assert_eq!(
|
||||
ApprovalMode::from_config_value("full_access"),
|
||||
Some(ApprovalMode::Bypass)
|
||||
);
|
||||
assert_eq!(
|
||||
ApprovalMode::from_config_value("deny"),
|
||||
Some(ApprovalMode::Never)
|
||||
|
||||
Reference in New Issue
Block a user