Closes https://github.com/zed-industries/zed/discussions/64266 Opening a folder in an existing window force-opens the Threads Sidebar, and there is no way to prevent it. `open_paths` called `MultiWorkspace::open_sidebar` unconditionally on the reused window, so the sidebar reopened on every such open regardless of whether the user had closed it. This adds `agent.threads_sidebar_auto_open`, defaulting to `true` so current behavior is unchanged. With `false`, the folder still opens in the existing window, but the sidebar stays closed. This path is reached when `default_open_behavior` or `cli_default_open_behavior` is set to `existing_window`. `open_sidebar` was doing double duty: besides showing the sidebar, it pinned the workspace being navigated away from so it survives the switch. When the setting is disabled, `retain_active_workspace` is called instead, so only sidebar visibility changes. https://github.com/user-attachments/assets/fc299df0-27e2-4ac6-98cb-d817d5f71d0e ## Tests Two tests in `multi_workspace_tests.rs` cover both branches: the sidebar opens by default, and stays closed when the setting is disabled while the folder still opens in the same window. Release Notes: - Added `agent.threads_sidebar_auto_open` to control whether opening a folder in an existing window also opens the Threads Sidebar
1448 lines
52 KiB
Rust
1448 lines
52 KiB
Rust
use collections::{HashMap, IndexMap};
|
|
use schemars::{JsonSchema, json_schema};
|
|
use serde::{Deserialize, Serialize};
|
|
use settings_macros::{MergeFrom, with_fallible_options};
|
|
use std::sync::Arc;
|
|
use std::{
|
|
borrow::Cow,
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
use crate::ExtendingVec;
|
|
|
|
use crate::DockPosition;
|
|
|
|
/// Where to position the threads sidebar.
|
|
#[derive(
|
|
Clone,
|
|
Copy,
|
|
Debug,
|
|
Default,
|
|
PartialEq,
|
|
Eq,
|
|
Serialize,
|
|
Deserialize,
|
|
JsonSchema,
|
|
MergeFrom,
|
|
strum::VariantArray,
|
|
strum::VariantNames,
|
|
)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SidebarDockPosition {
|
|
/// Always show the sidebar on the left side.
|
|
#[default]
|
|
Left,
|
|
/// Always show the sidebar on the right side.
|
|
Right,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
|
|
pub enum SidebarSide {
|
|
#[default]
|
|
Left,
|
|
Right,
|
|
}
|
|
|
|
/// How thinking blocks should be displayed by default in the agent panel.
|
|
#[derive(
|
|
Clone,
|
|
Copy,
|
|
Debug,
|
|
Default,
|
|
PartialEq,
|
|
Eq,
|
|
Serialize,
|
|
Deserialize,
|
|
JsonSchema,
|
|
MergeFrom,
|
|
strum::VariantArray,
|
|
strum::VariantNames,
|
|
)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ThinkingBlockDisplay {
|
|
/// Thinking blocks fully expand during streaming, then auto-collapse
|
|
/// when the model finishes thinking. Users can re-expand after collapse.
|
|
#[default]
|
|
Auto,
|
|
/// Thinking blocks auto-expand with a height constraint during streaming,
|
|
/// then remain in their constrained state when complete. Users can click
|
|
/// to fully expand or collapse.
|
|
Preview,
|
|
/// Thinking blocks are always fully expanded by default (no height constraint).
|
|
AlwaysExpanded,
|
|
/// Thinking blocks are always collapsed by default.
|
|
AlwaysCollapsed,
|
|
}
|
|
|
|
/// Threshold at which agent auto-compaction runs. See
|
|
/// [`AutoCompactSettingsContent::threshold`] for the accepted formats.
|
|
///
|
|
/// The canonical textual form is stored verbatim so it can round-trip through
|
|
/// the settings UI; it is serialized back as a JSON string for percentages and
|
|
/// as a JSON integer for token counts.
|
|
#[derive(Clone, Debug, PartialEq, Eq, MergeFrom)]
|
|
pub struct AutoCompactThreshold(pub String);
|
|
|
|
impl From<String> for AutoCompactThreshold {
|
|
fn from(value: String) -> Self {
|
|
Self(value)
|
|
}
|
|
}
|
|
|
|
impl From<AutoCompactThreshold> for String {
|
|
fn from(value: AutoCompactThreshold) -> Self {
|
|
value.0
|
|
}
|
|
}
|
|
|
|
impl AsRef<str> for AutoCompactThreshold {
|
|
fn as_ref(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl Serialize for AutoCompactThreshold {
|
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
if self.0.ends_with('%') {
|
|
serializer.serialize_str(&self.0)
|
|
} else if let Ok(tokens) = self.0.parse::<i64>() {
|
|
serializer.serialize_i64(tokens)
|
|
} else {
|
|
serializer.serialize_str(&self.0)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for AutoCompactThreshold {
|
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
struct ThresholdVisitor;
|
|
|
|
impl serde::de::Visitor<'_> for ThresholdVisitor {
|
|
type Value = AutoCompactThreshold;
|
|
|
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
formatter
|
|
.write_str("a percentage string like \"90%\" or an integer number of tokens")
|
|
}
|
|
|
|
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
|
|
Ok(AutoCompactThreshold(value.to_owned()))
|
|
}
|
|
|
|
fn visit_i64<E: serde::de::Error>(self, value: i64) -> Result<Self::Value, E> {
|
|
Ok(AutoCompactThreshold(value.to_string()))
|
|
}
|
|
|
|
fn visit_u64<E: serde::de::Error>(self, value: u64) -> Result<Self::Value, E> {
|
|
Ok(AutoCompactThreshold(value.to_string()))
|
|
}
|
|
|
|
fn visit_f64<E: serde::de::Error>(self, value: f64) -> Result<Self::Value, E> {
|
|
Ok(AutoCompactThreshold(value.to_string()))
|
|
}
|
|
}
|
|
|
|
deserializer.deserialize_any(ThresholdVisitor)
|
|
}
|
|
}
|
|
|
|
impl JsonSchema for AutoCompactThreshold {
|
|
fn schema_name() -> Cow<'static, str> {
|
|
"AutoCompactThreshold".into()
|
|
}
|
|
|
|
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
|
json_schema!({
|
|
"oneOf": [
|
|
{
|
|
"type": "string",
|
|
"pattern": "^\\d+(\\.\\d+)?%$"
|
|
},
|
|
{
|
|
"type": "integer"
|
|
}
|
|
]
|
|
})
|
|
}
|
|
}
|
|
|
|
#[with_fallible_options]
|
|
#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)]
|
|
pub struct AutoCompactSettingsContent {
|
|
/// Whether to automatically compact the agent's context when it grows too
|
|
/// large, summarizing earlier messages to free up room in the model's
|
|
/// context window.
|
|
///
|
|
/// Default: true
|
|
pub enabled: Option<bool>,
|
|
/// The threshold at which auto-compaction runs. This is one of:
|
|
///
|
|
/// - A percentage string ending in `%`, e.g. `"90%"`, measured against the
|
|
/// model's context window. `"90%"` compacts once the context is 90% full.
|
|
/// - A positive integer: compaction runs once that many tokens have been
|
|
/// used. For example, `100000` compacts after 100,000 tokens are used.
|
|
/// - A negative integer: compaction runs once that many tokens remain in
|
|
/// the context window. For example, `-20000` compacts once there are
|
|
/// fewer than 20,000 tokens of headroom left in the context window.
|
|
///
|
|
/// `0` is not a valid threshold.
|
|
///
|
|
/// Default: "90%"
|
|
pub threshold: Option<AutoCompactThreshold>,
|
|
}
|
|
|
|
#[with_fallible_options]
|
|
#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)]
|
|
pub struct AgentSettingsContent {
|
|
/// Whether the Agent is enabled.
|
|
///
|
|
/// Default: true
|
|
pub enabled: Option<bool>,
|
|
/// Whether to show the agent panel button in the status bar.
|
|
///
|
|
/// Default: true
|
|
pub button: Option<bool>,
|
|
/// Where to dock the agent panel.
|
|
///
|
|
/// Default: left (Agentic layout), right (Classic layout)
|
|
pub dock: Option<DockPosition>,
|
|
/// Whether the agent panel should use flexible (proportional) sizing when docked to the
|
|
/// left or right.
|
|
///
|
|
/// When enabled, `default_width` does not control the panel width, and resetting the panel
|
|
/// restores the default proportion.
|
|
///
|
|
/// Default: true
|
|
pub flexible: Option<bool>,
|
|
/// Where to position the sidebar holding the threads list and the agent panel.
|
|
///
|
|
/// Default: left
|
|
pub sidebar_side: Option<SidebarDockPosition>,
|
|
/// Default width in pixels for the Threads Sidebar.
|
|
///
|
|
/// Values range from 200 to 800, matching the widths the sidebar can be
|
|
/// dragged to. Values outside that range are clamped into it.
|
|
///
|
|
/// Default: 300
|
|
#[schemars(range(min = 200, max = 800))]
|
|
pub threads_sidebar_default_width: Option<crate::PixelSetting>,
|
|
/// Whether opening a folder in an existing window automatically opens the
|
|
/// Threads Sidebar.
|
|
///
|
|
/// This applies when a folder opens in an existing window instead of a new
|
|
/// one, which happens when `default_open_behavior` or
|
|
/// `cli_default_open_behavior` is set to `existing_window`. When disabled,
|
|
/// the folder still opens in that window, but the sidebar stays closed
|
|
/// until you open it.
|
|
///
|
|
/// Default: true
|
|
pub threads_sidebar_auto_open: Option<bool>,
|
|
/// Default fixed width in pixels when the agent panel is docked to the left or right and
|
|
/// `flexible` is false.
|
|
///
|
|
/// Default: 640
|
|
pub default_width: Option<crate::PixelSetting>,
|
|
/// Default height in pixels when the agent panel is docked to the bottom.
|
|
///
|
|
/// Default: 320
|
|
pub default_height: Option<crate::PixelSetting>,
|
|
/// Whether to limit the content width in the agent panel. When enabled,
|
|
/// content will be constrained to `max_content_width` and centered when
|
|
/// the panel is wider than that value, for optimal readability.
|
|
///
|
|
/// Default: true
|
|
pub limit_content_width: Option<bool>,
|
|
/// Maximum content width in pixels for the agent panel. Content will be
|
|
/// centered when the panel is wider than this value.
|
|
///
|
|
/// Default: 850
|
|
pub max_content_width: Option<crate::PixelSetting>,
|
|
/// The default model to use when creating new chats and for other features when a specific model is not specified.
|
|
pub default_model: Option<LanguageModelSelection>,
|
|
/// The model to use for subagents spawned via the `spawn_agent` tool. Defaults to the parent agent's model when not specified.
|
|
pub subagent_model: Option<LanguageModelSelection>,
|
|
/// Favorite models to show at the top of the model selector.
|
|
#[serde(default)]
|
|
pub favorite_models: Vec<LanguageModelSelection>,
|
|
/// Model to use for the inline assistant. Defaults to default_model when not specified.
|
|
pub inline_assistant_model: Option<LanguageModelSelection>,
|
|
/// Model to use for the inline assistant when streaming tools are enabled.
|
|
///
|
|
/// Default: true
|
|
pub inline_assistant_use_streaming_tools: Option<bool>,
|
|
/// Model to use for generating git commit messages. Defaults to default_model when not specified.
|
|
pub commit_message_model: Option<LanguageModelSelection>,
|
|
/// Whether to include project rules files (AGENTS.md, CLAUDE.md, .rules, etc.)
|
|
/// in the prompt when generating git commit messages.
|
|
///
|
|
/// Default: true
|
|
pub commit_message_include_project_rules: Option<bool>,
|
|
/// Custom instructions to include in the prompt when generating git commit messages.
|
|
/// Applied in addition to any project rules files (such as `.rules` or `AGENTS.md`).
|
|
pub commit_message_instructions: Option<String>,
|
|
/// Model to use for generating thread summaries. Defaults to default_model when not specified.
|
|
pub thread_summary_model: Option<LanguageModelSelection>,
|
|
/// Model to use for context compaction (`/compact` and auto-compaction).
|
|
/// Falls back to the thread's currently selected model when not specified.
|
|
/// If the configured model is unavailable (provider not registered, model
|
|
/// not found), the thread's current model is used instead.
|
|
pub compaction_model: Option<LanguageModelSelection>,
|
|
/// Additional models with which to generate alternatives when performing inline assists.
|
|
pub inline_alternatives: Option<Vec<LanguageModelSelection>>,
|
|
/// The default profile to use in the Agent.
|
|
///
|
|
/// Default: write
|
|
pub default_profile: Option<Arc<str>>,
|
|
/// The available agent profiles.
|
|
pub profiles: Option<IndexMap<Arc<str>, AgentProfileContent>>,
|
|
/// Where to show a popup notification when the agent is waiting for user input.
|
|
///
|
|
/// Default: "primary_screen"
|
|
pub notify_when_agent_waiting: Option<NotifyWhenAgentWaiting>,
|
|
/// When to play a sound when the agent has either completed its response, or needs user input.
|
|
///
|
|
/// Default: never
|
|
pub play_sound_when_agent_done: Option<PlaySoundWhenAgentDone>,
|
|
/// Whether to keep the system awake while agent threads are running.
|
|
///
|
|
/// Default: true
|
|
pub prevent_idle_sleep: Option<bool>,
|
|
/// Whether to display agent edits in single-file editors in addition to the review multibuffer pane.
|
|
///
|
|
/// Default: false
|
|
pub single_file_review: Option<bool>,
|
|
/// Additional parameters for language model requests. When making a request
|
|
/// to a model, parameters will be taken from the last entry in this list
|
|
/// that matches the model's provider and name. In each entry, both provider
|
|
/// and model are optional, so that you can specify parameters for either
|
|
/// one.
|
|
///
|
|
/// Default: []
|
|
#[serde(default)]
|
|
pub model_parameters: Vec<LanguageModelParameters>,
|
|
/// Settings for automatic agent context compaction, which summarizes
|
|
/// earlier messages to free up room in the model's context window once the
|
|
/// context grows too large.
|
|
pub auto_compact: Option<AutoCompactSettingsContent>,
|
|
/// Whether to show thumb buttons for feedback in the agent panel.
|
|
///
|
|
/// Default: true
|
|
pub enable_feedback: Option<bool>,
|
|
/// Whether to have edit cards in the agent panel expanded, showing a preview of the full diff.
|
|
///
|
|
/// Default: true
|
|
pub expand_edit_card: Option<bool>,
|
|
/// Whether to have terminal cards in the agent panel expanded, showing the whole command output.
|
|
///
|
|
/// Default: true
|
|
pub expand_terminal_card: Option<bool>,
|
|
/// Command to automatically run when Zed creates a Terminal Thread shell in the agent panel.
|
|
/// The command is sent to the shell as if typed, so it is interpreted by your
|
|
/// configured shell (including on Windows and remote/WSL projects).
|
|
/// An empty string disables this behavior.
|
|
///
|
|
/// Default: ""
|
|
pub terminal_init_command: Option<String>,
|
|
/// How thinking blocks should be displayed by default in the agent panel.
|
|
///
|
|
/// Default: automatic
|
|
pub thinking_display: Option<ThinkingBlockDisplay>,
|
|
/// Whether clicking the stop button on a running terminal tool should also cancel the agent's generation.
|
|
/// Note that this only applies to the stop button, not to ctrl+c inside the terminal.
|
|
///
|
|
/// Default: true
|
|
pub cancel_generation_on_terminal_stop: Option<bool>,
|
|
/// Whether to always use cmd-enter (or ctrl-enter on Linux or Windows) to send messages in the agent panel.
|
|
///
|
|
/// Default: false
|
|
pub use_modifier_to_send: Option<bool>,
|
|
/// Minimum number of lines of height the agent message editor should have.
|
|
///
|
|
/// Default: 4
|
|
pub message_editor_min_lines: Option<usize>,
|
|
/// Whether to show turn statistics (elapsed time during generation, final turn duration).
|
|
///
|
|
/// Default: false
|
|
pub show_turn_stats: Option<bool>,
|
|
/// Whether to show the merge conflict indicator in the status bar
|
|
/// that offers to resolve conflicts using the agent.
|
|
///
|
|
/// Default: true
|
|
pub show_merge_conflict_indicator: Option<bool>,
|
|
/// Per-tool permission rules for granular control over which tool actions
|
|
/// require confirmation.
|
|
///
|
|
/// The global `default` applies when no tool-specific rules match.
|
|
/// For external agent servers (e.g. Claude Agent) that define their own
|
|
/// permission modes, "deny" and "confirm" still take precedence — the
|
|
/// external agent's permission system is only used when Zed would allow
|
|
/// the action. Per-tool regex patterns (`always_allow`, `always_deny`,
|
|
/// `always_confirm`) match against the tool's text input (command, path,
|
|
/// URL, etc.).
|
|
pub tool_permissions: Option<ToolPermissionsContent>,
|
|
|
|
/// Persistent sandbox permission grants for agent-run terminal commands.
|
|
/// These are populated when choosing "Allow always" from a sandbox
|
|
/// escalation prompt.
|
|
pub sandbox_permissions: Option<SandboxPermissionsContent>,
|
|
}
|
|
|
|
impl AgentSettingsContent {
|
|
pub fn set_dock(&mut self, dock: DockPosition) {
|
|
self.dock = Some(dock);
|
|
}
|
|
|
|
pub fn set_sidebar_side(&mut self, position: SidebarDockPosition) {
|
|
self.sidebar_side = Some(position);
|
|
}
|
|
|
|
pub fn set_flexible_size(&mut self, flexible: bool) {
|
|
self.flexible = Some(flexible);
|
|
}
|
|
|
|
pub fn set_model(&mut self, language_model: LanguageModelSelection) {
|
|
self.default_model = Some(language_model)
|
|
}
|
|
|
|
pub fn set_inline_assistant_model(&mut self, provider: String, model: String) {
|
|
self.inline_assistant_model = Some(LanguageModelSelection {
|
|
provider: provider.into(),
|
|
model,
|
|
enable_thinking: false,
|
|
effort: None,
|
|
speed: None,
|
|
});
|
|
}
|
|
|
|
pub fn set_profile(&mut self, profile_id: Arc<str>) {
|
|
self.default_profile = Some(profile_id);
|
|
}
|
|
|
|
pub fn add_favorite_model(&mut self, model: LanguageModelSelection) {
|
|
// Note: this is intentional to not compare using `PartialEq`here.
|
|
// Full equality would treat entries that differ just in thinking/effort/speed
|
|
// as distinct and silently produce duplicates.
|
|
if !self
|
|
.favorite_models
|
|
.iter()
|
|
.any(|m| m.provider == model.provider && m.model == model.model)
|
|
{
|
|
self.favorite_models.push(model);
|
|
}
|
|
}
|
|
|
|
pub fn remove_favorite_model(&mut self, model: &LanguageModelSelection) {
|
|
self.favorite_models
|
|
.retain(|m| !(m.provider == model.provider && m.model == model.model));
|
|
}
|
|
|
|
pub fn update_favorite_model<F>(&mut self, provider: &str, model: &str, f: F)
|
|
where
|
|
F: FnOnce(&mut LanguageModelSelection),
|
|
{
|
|
if let Some(entry) = self
|
|
.favorite_models
|
|
.iter_mut()
|
|
.find(|m| m.provider.0 == provider && m.model == model)
|
|
{
|
|
f(entry);
|
|
}
|
|
}
|
|
|
|
pub fn set_tool_default_permission(&mut self, tool_id: &str, mode: ToolPermissionMode) {
|
|
let tool_permissions = self.tool_permissions.get_or_insert_default();
|
|
let tool_rules = tool_permissions
|
|
.tools
|
|
.entry(Arc::from(tool_id))
|
|
.or_default();
|
|
tool_rules.default = Some(mode);
|
|
}
|
|
|
|
pub fn add_tool_allow_pattern(&mut self, tool_name: &str, pattern: String) {
|
|
let tool_permissions = self.tool_permissions.get_or_insert_default();
|
|
let tool_rules = tool_permissions
|
|
.tools
|
|
.entry(Arc::from(tool_name))
|
|
.or_default();
|
|
let always_allow = tool_rules.always_allow.get_or_insert_default();
|
|
if !always_allow.0.iter().any(|r| r.pattern == pattern) {
|
|
always_allow.0.push(ToolRegexRule {
|
|
pattern,
|
|
case_sensitive: None,
|
|
});
|
|
}
|
|
}
|
|
|
|
pub fn add_tool_deny_pattern(&mut self, tool_name: &str, pattern: String) {
|
|
let tool_permissions = self.tool_permissions.get_or_insert_default();
|
|
let tool_rules = tool_permissions
|
|
.tools
|
|
.entry(Arc::from(tool_name))
|
|
.or_default();
|
|
let always_deny = tool_rules.always_deny.get_or_insert_default();
|
|
if !always_deny.0.iter().any(|r| r.pattern == pattern) {
|
|
always_deny.0.push(ToolRegexRule {
|
|
pattern,
|
|
case_sensitive: None,
|
|
});
|
|
}
|
|
}
|
|
|
|
pub fn allow_sandbox_all_hosts(&mut self) {
|
|
self.sandbox_permissions
|
|
.get_or_insert_default()
|
|
.allow_all_hosts = Some(true);
|
|
}
|
|
|
|
/// The persisted sandbox network host patterns, as written (callers own
|
|
/// parsing/validation).
|
|
pub fn sandbox_network_hosts(&self) -> &[String] {
|
|
self.sandbox_permissions
|
|
.as_ref()
|
|
.and_then(|permissions| permissions.network_hosts.as_ref())
|
|
.map(|hosts| hosts.0.as_slice())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Replace the persisted sandbox network host patterns. Callers compute
|
|
/// the new list (typically the old list plus newly granted hosts, pruned
|
|
/// of entries subsumed by wildcards) rather than appending blindly.
|
|
pub fn set_sandbox_network_hosts(&mut self, hosts: Vec<String>) {
|
|
self.sandbox_permissions
|
|
.get_or_insert_default()
|
|
.network_hosts = Some(ExtendingVec(hosts));
|
|
}
|
|
|
|
pub fn allow_sandbox_fs_write_all(&mut self) {
|
|
self.sandbox_permissions
|
|
.get_or_insert_default()
|
|
.allow_fs_write_all = Some(true);
|
|
}
|
|
|
|
pub fn allow_sandbox_unsandboxed(&mut self) {
|
|
self.sandbox_permissions
|
|
.get_or_insert_default()
|
|
.allow_unsandboxed = Some(true);
|
|
}
|
|
|
|
pub fn add_sandbox_write_path(&mut self, granted: GrantedWritePathContent) {
|
|
let write_paths = &mut self
|
|
.sandbox_permissions
|
|
.get_or_insert_default()
|
|
.write_paths
|
|
.get_or_insert_default()
|
|
.0;
|
|
|
|
// Mirror `util::paths::insert_subtree`, keeping the grant set minimal,
|
|
// but compare by each entry's canonical (resolved) grant path.
|
|
let canonical = granted.canonical_or_requested().to_path_buf();
|
|
if write_paths
|
|
.iter()
|
|
.any(|existing| canonical.starts_with(existing.canonical_or_requested()))
|
|
{
|
|
return;
|
|
}
|
|
write_paths.retain(|existing| !existing.canonical_or_requested().starts_with(&canonical));
|
|
write_paths.push(granted);
|
|
}
|
|
}
|
|
|
|
#[with_fallible_options]
|
|
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, JsonSchema, MergeFrom)]
|
|
pub struct AgentProfileContent {
|
|
pub name: Arc<str>,
|
|
#[serde(default)]
|
|
pub tools: IndexMap<Arc<str>, bool>,
|
|
/// Whether all context servers are enabled by default.
|
|
pub enable_all_context_servers: Option<bool>,
|
|
#[serde(default)]
|
|
pub context_servers: IndexMap<Arc<str>, ContextServerPresetContent>,
|
|
/// The default language model selected when using this profile.
|
|
pub default_model: Option<LanguageModelSelection>,
|
|
}
|
|
|
|
#[with_fallible_options]
|
|
#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
|
|
pub struct ContextServerPresetContent {
|
|
pub tools: IndexMap<Arc<str>, bool>,
|
|
}
|
|
|
|
#[derive(
|
|
Copy,
|
|
Clone,
|
|
Default,
|
|
Debug,
|
|
Serialize,
|
|
Deserialize,
|
|
JsonSchema,
|
|
MergeFrom,
|
|
PartialEq,
|
|
strum::VariantArray,
|
|
strum::VariantNames,
|
|
)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum NotifyWhenAgentWaiting {
|
|
#[default]
|
|
PrimaryScreen,
|
|
AllScreens,
|
|
Never,
|
|
}
|
|
|
|
#[derive(
|
|
Copy,
|
|
Clone,
|
|
Default,
|
|
Debug,
|
|
Serialize,
|
|
Deserialize,
|
|
JsonSchema,
|
|
MergeFrom,
|
|
PartialEq,
|
|
strum::VariantArray,
|
|
strum::VariantNames,
|
|
)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum PlaySoundWhenAgentDone {
|
|
#[default]
|
|
Never,
|
|
WhenHidden,
|
|
Always,
|
|
}
|
|
|
|
impl PlaySoundWhenAgentDone {
|
|
pub fn should_play(&self, visible: bool) -> bool {
|
|
match self {
|
|
PlaySoundWhenAgentDone::Never => false,
|
|
PlaySoundWhenAgentDone::WhenHidden => !visible,
|
|
PlaySoundWhenAgentDone::Always => true,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[with_fallible_options]
|
|
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
|
|
pub struct LanguageModelSelection {
|
|
pub provider: LanguageModelProviderSetting,
|
|
pub model: String,
|
|
#[serde(default)]
|
|
pub enable_thinking: bool,
|
|
pub effort: Option<String>,
|
|
pub speed: Option<language_model_core::Speed>,
|
|
}
|
|
|
|
#[with_fallible_options]
|
|
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
|
|
pub struct LanguageModelParameters {
|
|
pub provider: Option<LanguageModelProviderSetting>,
|
|
pub model: Option<String>,
|
|
#[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")]
|
|
pub temperature: Option<f32>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, MergeFrom)]
|
|
pub struct LanguageModelProviderSetting(pub String);
|
|
|
|
impl JsonSchema for LanguageModelProviderSetting {
|
|
fn schema_name() -> Cow<'static, str> {
|
|
"LanguageModelProviderSetting".into()
|
|
}
|
|
|
|
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
|
// list the builtin providers as a subset so that we still auto complete them in the settings
|
|
json_schema!({
|
|
"anyOf": [
|
|
{
|
|
"type": "string",
|
|
"enum": [
|
|
"amazon-bedrock",
|
|
"anthropic",
|
|
"copilot_chat",
|
|
"deepseek",
|
|
"google",
|
|
"lmstudio",
|
|
"mistral",
|
|
"ollama",
|
|
"openai",
|
|
"openai-subscribed",
|
|
"opencode",
|
|
"openrouter",
|
|
"vercel_ai_gateway",
|
|
"x_ai",
|
|
"x_ai_subscribed",
|
|
"zed.dev"
|
|
]
|
|
},
|
|
{
|
|
"type": "string",
|
|
}
|
|
]
|
|
})
|
|
}
|
|
}
|
|
|
|
impl From<String> for LanguageModelProviderSetting {
|
|
fn from(provider: String) -> Self {
|
|
Self(provider)
|
|
}
|
|
}
|
|
|
|
impl From<&str> for LanguageModelProviderSetting {
|
|
fn from(provider: &str) -> Self {
|
|
Self(provider.to_string())
|
|
}
|
|
}
|
|
|
|
#[with_fallible_options]
|
|
#[derive(Default, PartialEq, Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug)]
|
|
#[serde(transparent)]
|
|
pub struct AllAgentServersSettings(pub HashMap<String, CustomAgentServerSettings>);
|
|
|
|
impl std::ops::Deref for AllAgentServersSettings {
|
|
type Target = HashMap<String, CustomAgentServerSettings>;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl std::ops::DerefMut for AllAgentServersSettings {
|
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
&mut self.0
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug, PartialEq, Eq)]
|
|
#[serde(untagged)]
|
|
pub enum AgentConfigOptionValue {
|
|
ValueId(String),
|
|
Boolean(bool),
|
|
}
|
|
|
|
impl AgentConfigOptionValue {
|
|
pub fn as_value_id(&self) -> Option<&str> {
|
|
match self {
|
|
Self::ValueId(value) => Some(value),
|
|
Self::Boolean(_) => None,
|
|
}
|
|
}
|
|
|
|
pub fn as_bool(&self) -> Option<bool> {
|
|
match self {
|
|
Self::Boolean(value) => Some(*value),
|
|
Self::ValueId(_) => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for AgentConfigOptionValue {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::ValueId(value) => formatter.write_str(value),
|
|
Self::Boolean(value) => value.fmt(formatter),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<String> for AgentConfigOptionValue {
|
|
fn from(value: String) -> Self {
|
|
Self::ValueId(value)
|
|
}
|
|
}
|
|
|
|
impl From<&str> for AgentConfigOptionValue {
|
|
fn from(value: &str) -> Self {
|
|
Self::ValueId(value.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<bool> for AgentConfigOptionValue {
|
|
fn from(value: bool) -> Self {
|
|
Self::Boolean(value)
|
|
}
|
|
}
|
|
|
|
#[with_fallible_options]
|
|
#[derive(Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug, PartialEq)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum CustomAgentServerSettings {
|
|
Custom {
|
|
#[serde(rename = "command")]
|
|
path: PathBuf,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
args: Vec<String>,
|
|
/// Default: {}
|
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
env: HashMap<String, String>,
|
|
/// The default mode to use for this agent.
|
|
///
|
|
/// Note: Not only all agents support modes.
|
|
///
|
|
/// Default: None
|
|
default_mode: Option<String>,
|
|
/// Default values for session config options.
|
|
///
|
|
/// This is a map from config option ID to the default value for that option.
|
|
///
|
|
/// Default: {}
|
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
default_config_options: HashMap<String, AgentConfigOptionValue>,
|
|
/// Favorited values for session config options.
|
|
///
|
|
/// This is a map from config option ID to a list of favorited value IDs.
|
|
///
|
|
/// Default: {}
|
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
favorite_config_option_values: HashMap<String, Vec<String>>,
|
|
},
|
|
// Used for the ACP extension migration
|
|
#[serde(alias = "extension")]
|
|
Registry {
|
|
/// Additional environment variables to pass to the agent.
|
|
///
|
|
/// Default: {}
|
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
env: HashMap<String, String>,
|
|
/// The default mode to use for this agent.
|
|
///
|
|
/// Note: Not only all agents support modes.
|
|
///
|
|
/// Default: None
|
|
default_mode: Option<String>,
|
|
/// Default values for session config options.
|
|
///
|
|
/// This is a map from config option ID to the default value for that option.
|
|
///
|
|
/// Default: {}
|
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
default_config_options: HashMap<String, AgentConfigOptionValue>,
|
|
/// Favorited values for session config options.
|
|
///
|
|
/// This is a map from config option ID to a list of favorited value IDs.
|
|
///
|
|
/// Default: {}
|
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
favorite_config_option_values: HashMap<String, Vec<String>>,
|
|
},
|
|
}
|
|
|
|
/// A persisted sandbox writable-path grant. Deserializes from either a bare
|
|
/// path string (`"/tmp/x"`, a legacy/hand-authored entry with no resolved
|
|
/// target) or an object (`{ "requested": "/tmp/x", "resolved": "/tmp/real" }`).
|
|
/// Serializes back as a bare string when `resolved` is `None` and as an object
|
|
/// otherwise, so hand-authored bare strings round-trip and Zed-written grants
|
|
/// are objects.
|
|
#[derive(Clone, Debug, Default, PartialEq, MergeFrom)]
|
|
pub struct GrantedWritePathContent {
|
|
/// The path exactly as the user/model requested it.
|
|
pub requested: PathBuf,
|
|
/// The canonical, symlink-resolved target established when the grant was
|
|
/// approved. Absent for a bare-string entry.
|
|
pub resolved: Option<PathBuf>,
|
|
/// Windows/WSL only: whether the canonical target lives on a Windows-hosted
|
|
/// (DrvFs) filesystem, whose sandbox-integrity guarantees are weaker. Absent
|
|
/// (false) on other platforms and for bare-string entries.
|
|
pub on_windows_fs: bool,
|
|
}
|
|
|
|
impl GrantedWritePathContent {
|
|
/// The path used for lexical subtree/coverage/dedup logic: the resolved
|
|
/// canonical target when known, otherwise the requested path.
|
|
fn canonical_or_requested(&self) -> &Path {
|
|
self.resolved.as_deref().unwrap_or(&self.requested)
|
|
}
|
|
}
|
|
|
|
impl Serialize for GrantedWritePathContent {
|
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
match &self.resolved {
|
|
None => self.requested.serialize(serializer),
|
|
Some(resolved) => {
|
|
use serde::ser::SerializeStruct as _;
|
|
let field_count = if self.on_windows_fs { 3 } else { 2 };
|
|
let mut state =
|
|
serializer.serialize_struct("GrantedWritePathContent", field_count)?;
|
|
state.serialize_field("requested", &self.requested)?;
|
|
state.serialize_field("resolved", resolved)?;
|
|
if self.on_windows_fs {
|
|
state.serialize_field("on_windows_fs", &self.on_windows_fs)?;
|
|
}
|
|
state.end()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for GrantedWritePathContent {
|
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
#[derive(Deserialize)]
|
|
struct Object {
|
|
requested: PathBuf,
|
|
#[serde(default)]
|
|
resolved: Option<PathBuf>,
|
|
#[serde(default)]
|
|
on_windows_fs: bool,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum StringOrObject {
|
|
String(PathBuf),
|
|
Object(Object),
|
|
}
|
|
|
|
Ok(match StringOrObject::deserialize(deserializer)? {
|
|
StringOrObject::String(requested) => Self {
|
|
requested,
|
|
resolved: None,
|
|
on_windows_fs: false,
|
|
},
|
|
StringOrObject::Object(Object {
|
|
requested,
|
|
resolved,
|
|
on_windows_fs,
|
|
}) => Self {
|
|
requested,
|
|
resolved,
|
|
on_windows_fs,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
impl JsonSchema for GrantedWritePathContent {
|
|
fn schema_name() -> Cow<'static, str> {
|
|
"GrantedWritePathContent".into()
|
|
}
|
|
|
|
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
|
json_schema!({
|
|
"oneOf": [
|
|
{ "type": "string" },
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"requested": { "type": "string" },
|
|
"resolved": { "type": ["string", "null"] },
|
|
"on_windows_fs": { "type": "boolean" }
|
|
},
|
|
"required": ["requested"]
|
|
}
|
|
]
|
|
})
|
|
}
|
|
}
|
|
|
|
#[with_fallible_options]
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
|
|
pub struct SandboxPermissionsContent {
|
|
/// Whether sandboxed terminal commands may always reach any host over the
|
|
/// network without prompting.
|
|
/// Default: false
|
|
pub allow_all_hosts: Option<bool>,
|
|
|
|
/// Hosts that sandboxed terminal commands may always reach over the
|
|
/// network without prompting. Each entry is an exact hostname
|
|
/// (`github.com`) or a leading-`*.` subdomain wildcard (`*.npmjs.org`).
|
|
/// Default: []
|
|
pub network_hosts: Option<ExtendingVec<String>>,
|
|
|
|
/// Whether sandboxed terminal commands may always write anywhere on the
|
|
/// filesystem without prompting.
|
|
/// Default: false
|
|
pub allow_fs_write_all: Option<bool>,
|
|
|
|
/// Whether to persistently run agent terminal commands outside the OS
|
|
/// sandbox. This is the model-facing "off switch": when true, the sandboxed
|
|
/// terminal tool is not exposed and the system prompt omits the sandbox
|
|
/// section, so the model uses the plain `terminal` tool. On Windows, WSL
|
|
/// sandbox setup is skipped. Distinct from the model-requested
|
|
/// `unsandboxed: true` escape approved "once" or "for this thread".
|
|
/// Default: false
|
|
pub allow_unsandboxed: Option<bool>,
|
|
|
|
/// Directory subtrees that sandboxed terminal commands may always write
|
|
/// to without prompting. Each entry is either a bare path string or an
|
|
/// object `{requested, resolved}`; Zed writes objects (the canonical,
|
|
/// symlink-resolved target established at approval time), while
|
|
/// hand-authored entries may be bare path strings. Paths written by Zed
|
|
/// are absolute.
|
|
/// Default: []
|
|
pub write_paths: Option<ExtendingVec<GrantedWritePathContent>>,
|
|
|
|
/// Whether to warn when a sandbox escalation prompt requests a domain or
|
|
/// write path that contains potentially confusable Unicode characters
|
|
/// (homoglyphs, invisible characters, or bidirectional overrides). When
|
|
/// enabled, such prompts show a warning that must be acknowledged before
|
|
/// the request can be allowed.
|
|
/// Default: true
|
|
pub warn_confusable_unicode: Option<bool>,
|
|
|
|
/// Whether to warn (Windows/WSL only) when a sandbox grant targets a file on
|
|
/// a Windows-hosted (DrvFs) filesystem. Such grants are enforced inside WSL
|
|
/// via a translated path, and their sandbox-integrity guarantees are weaker
|
|
/// than a distro-native filesystem. When enabled, such grants show a warning
|
|
/// that must be acknowledged before the command runs.
|
|
/// Default: true
|
|
pub warn_ntfs_grants: Option<bool>,
|
|
}
|
|
|
|
#[with_fallible_options]
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
|
|
pub struct ToolPermissionsContent {
|
|
/// Global default permission when no tool-specific rules match.
|
|
/// Individual tools can override this with their own default.
|
|
/// Default: confirm
|
|
#[serde(alias = "default_mode")]
|
|
pub default: Option<ToolPermissionMode>,
|
|
|
|
/// Per-tool permission rules.
|
|
/// Keys are tool names (e.g. terminal, edit_file, fetch) including MCP
|
|
/// tools (e.g. mcp:server_name:tool_name). Any tool name is accepted;
|
|
/// even tools without meaningful text input can have a `default` set.
|
|
#[serde(default)]
|
|
pub tools: HashMap<Arc<str>, ToolRulesContent>,
|
|
}
|
|
|
|
#[with_fallible_options]
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
|
|
pub struct ToolRulesContent {
|
|
/// Default mode when no regex rules match.
|
|
/// When unset, inherits from the global `tool_permissions.default`.
|
|
#[serde(alias = "default_mode")]
|
|
pub default: Option<ToolPermissionMode>,
|
|
|
|
/// Regexes for inputs to auto-approve.
|
|
/// For terminal: matches command. For file tools: matches path. For fetch: matches URL.
|
|
/// For `copy_path` and `move_path`, patterns are matched independently against each
|
|
/// path (source and destination).
|
|
/// Patterns accumulate across settings layers (user, project, profile) and cannot be
|
|
/// removed by a higher-priority layer—only new patterns can be added.
|
|
/// Default: []
|
|
pub always_allow: Option<ExtendingVec<ToolRegexRule>>,
|
|
|
|
/// Regexes for inputs to auto-reject.
|
|
/// **SECURITY**: These take precedence over ALL other rules, across ALL settings layers.
|
|
/// For `copy_path` and `move_path`, patterns are matched independently against each
|
|
/// path (source and destination).
|
|
/// Patterns accumulate across settings layers (user, project, profile) and cannot be
|
|
/// removed by a higher-priority layer—only new patterns can be added.
|
|
/// Default: []
|
|
pub always_deny: Option<ExtendingVec<ToolRegexRule>>,
|
|
|
|
/// Regexes for inputs that must always prompt.
|
|
/// Takes precedence over always_allow but not always_deny.
|
|
/// For `copy_path` and `move_path`, patterns are matched independently against each
|
|
/// path (source and destination).
|
|
/// Patterns accumulate across settings layers (user, project, profile) and cannot be
|
|
/// removed by a higher-priority layer—only new patterns can be added.
|
|
/// Default: []
|
|
pub always_confirm: Option<ExtendingVec<ToolRegexRule>>,
|
|
}
|
|
|
|
#[with_fallible_options]
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
|
|
pub struct ToolRegexRule {
|
|
/// The regex pattern to match.
|
|
#[serde(default)]
|
|
pub pattern: String,
|
|
|
|
/// Whether the regex is case-sensitive.
|
|
/// Default: false (case-insensitive)
|
|
pub case_sensitive: Option<bool>,
|
|
}
|
|
|
|
#[derive(
|
|
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom,
|
|
)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ToolPermissionMode {
|
|
/// Auto-approve without prompting.
|
|
Allow,
|
|
/// Auto-reject with an error.
|
|
Deny,
|
|
/// Always prompt for confirmation (default behavior).
|
|
#[default]
|
|
Confirm,
|
|
}
|
|
|
|
impl std::fmt::Display for ToolPermissionMode {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
ToolPermissionMode::Allow => write!(f, "Allow"),
|
|
ToolPermissionMode::Deny => write!(f, "Deny"),
|
|
ToolPermissionMode::Confirm => write!(f, "Confirm"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn agent_config_option_value_serializes_value_id_as_string() {
|
|
let value = AgentConfigOptionValue::from("manual");
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(&value).expect("serialize value id"),
|
|
serde_json::json!("manual")
|
|
);
|
|
assert_eq!(
|
|
serde_json::from_value::<AgentConfigOptionValue>(serde_json::json!("manual"))
|
|
.expect("deserialize value id"),
|
|
AgentConfigOptionValue::ValueId("manual".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn agent_config_option_value_serializes_boolean_as_boolean() {
|
|
let value = AgentConfigOptionValue::Boolean(true);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(&value).expect("serialize boolean"),
|
|
serde_json::json!(true)
|
|
);
|
|
assert_eq!(
|
|
serde_json::from_value::<AgentConfigOptionValue>(serde_json::json!(true))
|
|
.expect("deserialize boolean"),
|
|
AgentConfigOptionValue::Boolean(true)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn agent_config_option_value_merge_replaces_existing_value() {
|
|
use crate::merge_from::MergeFrom as _;
|
|
|
|
let mut value = AgentConfigOptionValue::ValueId("manual".to_string());
|
|
value.merge_from(&AgentConfigOptionValue::Boolean(true));
|
|
|
|
assert_eq!(value, AgentConfigOptionValue::Boolean(true));
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_tool_default_permission_creates_structure() {
|
|
let mut settings = AgentSettingsContent::default();
|
|
assert!(settings.tool_permissions.is_none());
|
|
|
|
settings.set_tool_default_permission("terminal", ToolPermissionMode::Allow);
|
|
|
|
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
|
|
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
|
|
assert_eq!(terminal_rules.default, Some(ToolPermissionMode::Allow));
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_tool_default_permission_updates_existing() {
|
|
let mut settings = AgentSettingsContent::default();
|
|
|
|
settings.set_tool_default_permission("terminal", ToolPermissionMode::Confirm);
|
|
settings.set_tool_default_permission("terminal", ToolPermissionMode::Allow);
|
|
|
|
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
|
|
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
|
|
assert_eq!(terminal_rules.default, Some(ToolPermissionMode::Allow));
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_tool_default_permission_for_mcp_tool() {
|
|
let mut settings = AgentSettingsContent::default();
|
|
|
|
settings.set_tool_default_permission("mcp:github:create_issue", ToolPermissionMode::Allow);
|
|
|
|
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
|
|
let mcp_rules = tool_permissions
|
|
.tools
|
|
.get("mcp:github:create_issue")
|
|
.unwrap();
|
|
assert_eq!(mcp_rules.default, Some(ToolPermissionMode::Allow));
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_tool_allow_pattern_creates_structure() {
|
|
let mut settings = AgentSettingsContent::default();
|
|
assert!(settings.tool_permissions.is_none());
|
|
|
|
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
|
|
|
|
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
|
|
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
|
|
let always_allow = terminal_rules.always_allow.as_ref().unwrap();
|
|
assert_eq!(always_allow.0.len(), 1);
|
|
assert_eq!(always_allow.0[0].pattern, "^cargo\\s");
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_tool_allow_pattern_appends_to_existing() {
|
|
let mut settings = AgentSettingsContent::default();
|
|
|
|
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
|
|
settings.add_tool_allow_pattern("terminal", "^npm\\s".to_string());
|
|
|
|
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
|
|
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
|
|
let always_allow = terminal_rules.always_allow.as_ref().unwrap();
|
|
assert_eq!(always_allow.0.len(), 2);
|
|
assert_eq!(always_allow.0[0].pattern, "^cargo\\s");
|
|
assert_eq!(always_allow.0[1].pattern, "^npm\\s");
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_tool_allow_pattern_does_not_duplicate() {
|
|
let mut settings = AgentSettingsContent::default();
|
|
|
|
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
|
|
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
|
|
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
|
|
|
|
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
|
|
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
|
|
let always_allow = terminal_rules.always_allow.as_ref().unwrap();
|
|
assert_eq!(
|
|
always_allow.0.len(),
|
|
1,
|
|
"Duplicate patterns should not be added"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_tool_allow_pattern_for_different_tools() {
|
|
let mut settings = AgentSettingsContent::default();
|
|
|
|
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
|
|
settings.add_tool_allow_pattern("fetch", "^https?://github\\.com".to_string());
|
|
|
|
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
|
|
|
|
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
|
|
assert_eq!(
|
|
terminal_rules.always_allow.as_ref().unwrap().0[0].pattern,
|
|
"^cargo\\s"
|
|
);
|
|
|
|
let fetch_rules = tool_permissions.tools.get("fetch").unwrap();
|
|
assert_eq!(
|
|
fetch_rules.always_allow.as_ref().unwrap().0[0].pattern,
|
|
"^https?://github\\.com"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_tool_deny_pattern_creates_structure() {
|
|
let mut settings = AgentSettingsContent::default();
|
|
assert!(settings.tool_permissions.is_none());
|
|
|
|
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
|
|
|
|
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
|
|
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
|
|
let always_deny = terminal_rules.always_deny.as_ref().unwrap();
|
|
assert_eq!(always_deny.0.len(), 1);
|
|
assert_eq!(always_deny.0[0].pattern, "^rm\\s");
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_tool_deny_pattern_appends_to_existing() {
|
|
let mut settings = AgentSettingsContent::default();
|
|
|
|
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
|
|
settings.add_tool_deny_pattern("terminal", "^sudo\\s".to_string());
|
|
|
|
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
|
|
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
|
|
let always_deny = terminal_rules.always_deny.as_ref().unwrap();
|
|
assert_eq!(always_deny.0.len(), 2);
|
|
assert_eq!(always_deny.0[0].pattern, "^rm\\s");
|
|
assert_eq!(always_deny.0[1].pattern, "^sudo\\s");
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_tool_deny_pattern_does_not_duplicate() {
|
|
let mut settings = AgentSettingsContent::default();
|
|
|
|
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
|
|
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
|
|
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
|
|
|
|
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
|
|
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
|
|
let always_deny = terminal_rules.always_deny.as_ref().unwrap();
|
|
assert_eq!(
|
|
always_deny.0.len(),
|
|
1,
|
|
"Duplicate patterns should not be added"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_tool_deny_and_allow_patterns_separate() {
|
|
let mut settings = AgentSettingsContent::default();
|
|
|
|
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
|
|
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
|
|
|
|
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
|
|
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
|
|
|
|
let always_allow = terminal_rules.always_allow.as_ref().unwrap();
|
|
assert_eq!(always_allow.0.len(), 1);
|
|
assert_eq!(always_allow.0[0].pattern, "^cargo\\s");
|
|
|
|
let always_deny = terminal_rules.always_deny.as_ref().unwrap();
|
|
assert_eq!(always_deny.0.len(), 1);
|
|
assert_eq!(always_deny.0[0].pattern, "^rm\\s");
|
|
}
|
|
|
|
#[test]
|
|
fn test_allow_sandbox_permissions_create_structure() {
|
|
let mut settings = AgentSettingsContent::default();
|
|
assert!(settings.sandbox_permissions.is_none());
|
|
|
|
settings.allow_sandbox_all_hosts();
|
|
assert_eq!(settings.sandbox_network_hosts(), &[] as &[String]);
|
|
settings
|
|
.set_sandbox_network_hosts(vec!["github.com".to_string(), "*.npmjs.org".to_string()]);
|
|
assert_eq!(
|
|
settings.sandbox_network_hosts(),
|
|
&["github.com".to_string(), "*.npmjs.org".to_string()]
|
|
);
|
|
settings.allow_sandbox_fs_write_all();
|
|
settings.allow_sandbox_unsandboxed();
|
|
settings.add_sandbox_write_path(GrantedWritePathContent {
|
|
requested: PathBuf::from("/tmp/build"),
|
|
resolved: None,
|
|
on_windows_fs: false,
|
|
});
|
|
|
|
let sandbox_permissions = settings.sandbox_permissions.as_ref().unwrap();
|
|
assert_eq!(sandbox_permissions.allow_all_hosts, Some(true));
|
|
assert_eq!(
|
|
sandbox_permissions
|
|
.network_hosts
|
|
.as_ref()
|
|
.unwrap()
|
|
.0
|
|
.as_slice(),
|
|
&["github.com".to_string(), "*.npmjs.org".to_string()]
|
|
);
|
|
assert_eq!(sandbox_permissions.allow_fs_write_all, Some(true));
|
|
assert_eq!(sandbox_permissions.allow_unsandboxed, Some(true));
|
|
assert_eq!(
|
|
sandbox_permissions
|
|
.write_paths
|
|
.as_ref()
|
|
.unwrap()
|
|
.0
|
|
.as_slice(),
|
|
&[GrantedWritePathContent {
|
|
requested: PathBuf::from("/tmp/build"),
|
|
resolved: None,
|
|
on_windows_fs: false,
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_sandbox_write_path_prunes_redundant_paths() {
|
|
let mut settings = AgentSettingsContent::default();
|
|
|
|
settings.add_sandbox_write_path(GrantedWritePathContent {
|
|
requested: PathBuf::from("/tmp/build/cache"),
|
|
resolved: None,
|
|
on_windows_fs: false,
|
|
});
|
|
settings.add_sandbox_write_path(GrantedWritePathContent {
|
|
requested: PathBuf::from("/tmp/build"),
|
|
resolved: None,
|
|
on_windows_fs: false,
|
|
});
|
|
settings.add_sandbox_write_path(GrantedWritePathContent {
|
|
requested: PathBuf::from("/tmp/build/output"),
|
|
resolved: None,
|
|
on_windows_fs: false,
|
|
});
|
|
|
|
let write_paths = settings
|
|
.sandbox_permissions
|
|
.as_ref()
|
|
.unwrap()
|
|
.write_paths
|
|
.as_ref()
|
|
.unwrap()
|
|
.0
|
|
.as_slice();
|
|
assert_eq!(
|
|
write_paths,
|
|
&[GrantedWritePathContent {
|
|
requested: PathBuf::from("/tmp/build"),
|
|
resolved: None,
|
|
on_windows_fs: false,
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_granted_write_path_content_deserializes_string_or_object() {
|
|
let from_string: GrantedWritePathContent =
|
|
serde_json::from_value(serde_json::json!("/tmp/x")).unwrap();
|
|
assert_eq!(
|
|
from_string,
|
|
GrantedWritePathContent {
|
|
requested: PathBuf::from("/tmp/x"),
|
|
resolved: None,
|
|
on_windows_fs: false,
|
|
}
|
|
);
|
|
|
|
let from_object: GrantedWritePathContent = serde_json::from_value(
|
|
serde_json::json!({ "requested": "/tmp/x", "resolved": "/tmp/real" }),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
from_object,
|
|
GrantedWritePathContent {
|
|
requested: PathBuf::from("/tmp/x"),
|
|
resolved: Some(PathBuf::from("/tmp/real")),
|
|
on_windows_fs: false,
|
|
}
|
|
);
|
|
|
|
// `resolved` is optional in the object form.
|
|
let object_without_resolved: GrantedWritePathContent =
|
|
serde_json::from_value(serde_json::json!({ "requested": "/tmp/x" })).unwrap();
|
|
assert_eq!(
|
|
object_without_resolved,
|
|
GrantedWritePathContent {
|
|
requested: PathBuf::from("/tmp/x"),
|
|
resolved: None,
|
|
on_windows_fs: false,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_granted_write_path_content_serializes_string_or_object() {
|
|
// No resolved target serializes as a bare string, so hand-authored
|
|
// bare strings round-trip.
|
|
let bare = GrantedWritePathContent {
|
|
requested: PathBuf::from("/tmp/x"),
|
|
resolved: None,
|
|
on_windows_fs: false,
|
|
};
|
|
assert_eq!(
|
|
serde_json::to_value(&bare).unwrap(),
|
|
serde_json::json!("/tmp/x")
|
|
);
|
|
|
|
// A resolved target serializes as an object.
|
|
let resolved = GrantedWritePathContent {
|
|
requested: PathBuf::from("/tmp/x"),
|
|
resolved: Some(PathBuf::from("/tmp/real")),
|
|
on_windows_fs: false,
|
|
};
|
|
assert_eq!(
|
|
serde_json::to_value(&resolved).unwrap(),
|
|
serde_json::json!({ "requested": "/tmp/x", "resolved": "/tmp/real" })
|
|
);
|
|
}
|
|
}
|