Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/apps/cli/src/runtime/approval.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use bitfun_agent_runtime::permission::PERMISSION_MODE_CONTEXT_KEY;
use bitfun_agent_runtime::sdk::{PermissionRequest, AUTO_APPROVE_ASK_CONTEXT_KEY};
use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY;
use bitfun_runtime_ports::PermissionMode;
use serde_json::{Map, Value};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
Expand Down Expand Up @@ -37,6 +39,21 @@ pub(crate) fn approval_metadata(approval_policy: CliApprovalPolicy) -> Map<Strin
AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(),
Value::Bool(auto_approve_ask),
);
// Carry the whole mode as well, so this invocation resolves through the
// same single value as every other surface. `Ask` stays absent: it means
// "inherit the persisted preference", not "force the ask mode".
metadata.insert(
PERMISSION_MODE_CONTEXT_KEY.to_string(),
Value::String(
if auto_approve_ask {
PermissionMode::AutoApprove
} else {
PermissionMode::Ask
}
.as_str()
.to_string(),
),
);
}
metadata
}
Expand All @@ -55,6 +72,7 @@ pub(crate) fn permission_request_targets_session(
#[cfg(test)]
mod tests {
use super::{approval_metadata, permission_request_targets_session, CliApprovalPolicy};
use bitfun_agent_runtime::permission::PERMISSION_MODE_CONTEXT_KEY;
use bitfun_agent_runtime::sdk::{
PermissionDelegationContext, PermissionRequest, PermissionRequestSource,
PermissionRequestSourceKind, AUTO_APPROVE_ASK_CONTEXT_KEY,
Expand Down Expand Up @@ -135,4 +153,25 @@ mod tests {
Some(&serde_json::Value::Bool(false))
);
}

#[test]
fn headless_approval_metadata_carries_the_resolved_permission_mode() {
assert_eq!(
approval_metadata(CliApprovalPolicy::Auto).get(PERMISSION_MODE_CONTEXT_KEY),
Some(&serde_json::Value::String("auto_approve".to_string()))
);
for policy in [CliApprovalPolicy::DisableAuto, CliApprovalPolicy::Reject] {
assert_eq!(
approval_metadata(policy).get(PERMISSION_MODE_CONTEXT_KEY),
Some(&serde_json::Value::String("ask".to_string()))
);
}

// Inheriting the persisted preference must stay absent, so it does not
// pin the turn to the ask mode.
assert_eq!(
approval_metadata(CliApprovalPolicy::Ask).get(PERMISSION_MODE_CONTEXT_KEY),
None
);
}
}
99 changes: 98 additions & 1 deletion src/apps/desktop/src/api/agentic_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ use bitfun_core_types::{
WorktreeError, WorktreeErrorCode,
};
use bitfun_product_domains::tool_permissions::PermissionRule;
use bitfun_runtime_ports::SessionTurnWindowRequest;
use bitfun_runtime_ports::{PermissionMode, SessionTurnWindowRequest};

const SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET: usize = 512 * 1024;
const SESSION_VIEW_TOOL_RESULT_STRING_CHAR_LIMIT: usize = 16 * 1024;
Expand Down Expand Up @@ -243,6 +243,31 @@ pub struct UpdateSessionModelRequest {
pub include_internal: bool,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSessionPermissionModeRequest {
pub session_id: String,
/// `None` clears the session override so the session follows the
/// user-level default again, including later changes to that default.
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub workspace_path: Option<String>,
#[serde(default)]
pub remote_connection_id: Option<String>,
#[serde(default)]
pub remote_ssh_host: Option<String>,
#[serde(default)]
pub include_internal: bool,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionPermissionModeResponse {
/// The session's own selection, or `null` when it follows the default.
pub mode: Option<PermissionMode>,
}

fn deserialize_present_nullable<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: serde::Deserializer<'de>,
Expand Down Expand Up @@ -1799,6 +1824,78 @@ pub async fn update_session_model(
.map_err(|error| format!("Failed to update session model: {}", error.into_message()))
}

/// Sets the tool permission mode this session runs with.
///
/// The mode is a per-session selector, so switching it in one conversation
/// leaves every other open session on its own selection. Passing no mode clears
/// the override and returns the session to the user-level default.
#[tauri::command]
pub async fn update_session_permission_mode(
runtime: State<'_, DesktopRuntimeContext>,
coordinator: State<'_, Arc<ConversationCoordinator>>,
request: UpdateSessionPermissionModeRequest,
) -> Result<SessionPermissionModeResponse, String> {
let session_id = request.session_id.trim().to_string();
if session_id.is_empty() {
return Err("session_id is required".to_string());
}
let mode = match request.mode.as_deref().map(str::trim) {
None | Some("") => None,
Some(value) => Some(
PermissionMode::parse(value)
.ok_or_else(|| format!("unsupported permission mode: {value}"))?,
),
};

ensure_session_loaded_for_selector_update(
runtime.inner(),
&session_id,
request.workspace_path,
request.remote_connection_id,
request.remote_ssh_host,
request.include_internal,
)
.await?;

coordinator
.get_session_manager()
.update_session_permission_mode(&session_id, mode)
.await
.map_err(|error| format!("Failed to update session permission mode: {error}"))?;

Ok(SessionPermissionModeResponse { mode })
}

/// Reads the session's own permission mode selection.
///
/// `null` means the session never chose one and follows the user-level default.
#[tauri::command]
pub async fn get_session_permission_mode(
runtime: State<'_, DesktopRuntimeContext>,
coordinator: State<'_, Arc<ConversationCoordinator>>,
request: UpdateSessionPermissionModeRequest,
) -> Result<SessionPermissionModeResponse, String> {
let session_id = request.session_id.trim().to_string();
if session_id.is_empty() {
return Err("session_id is required".to_string());
}
ensure_session_loaded_for_selector_update(
runtime.inner(),
&session_id,
request.workspace_path,
request.remote_connection_id,
request.remote_ssh_host,
request.include_internal,
)
.await?;

Ok(SessionPermissionModeResponse {
mode: coordinator
.get_session_manager()
.session_permission_mode(&session_id),
})
}

async fn ensure_session_loaded_for_selector_update(
runtime: &DesktopRuntimeContext,
session_id: &str,
Expand Down
8 changes: 8 additions & 0 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
RemoteWorkspacePolicy::LegacyUnaudited,
),
("get_session_lineage", RemoteWorkspacePolicy::RemoteRouted),
(
"get_session_permission_mode",
RemoteWorkspacePolicy::RemoteRouted,
),
("get_session_files", RemoteWorkspacePolicy::LegacyUnaudited),
(
"get_session_operations",
Expand Down Expand Up @@ -1980,6 +1984,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
),
("update_miniapp", RemoteWorkspacePolicy::LegacyUnaudited),
("update_session_mode", RemoteWorkspacePolicy::RemoteRouted),
(
"update_session_permission_mode",
RemoteWorkspacePolicy::RemoteRouted,
),
(
"update_session_model",
RemoteWorkspacePolicy::LegacyUnaudited,
Expand Down
2 changes: 2 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,8 @@ pub async fn run() {
api::agentic_api::create_session,
api::agentic_api::update_session_mode,
api::agentic_api::update_session_model,
api::agentic_api::update_session_permission_mode,
api::agentic_api::get_session_permission_mode,
api::agentic_api::reload_session_context,
api::agentic_api::update_session_title,
api::agentic_api::ensure_coordinator_session,
Expand Down
Loading