fix(config): honor workspace shell opt-in
This commit is contained in:
@@ -2704,7 +2704,7 @@ fn expand_pathbuf(path: PathBuf) -> PathBuf {
|
||||
path
|
||||
}
|
||||
|
||||
fn resolve_load_config_path(path: Option<PathBuf>) -> Option<PathBuf> {
|
||||
pub(crate) fn resolve_load_config_path(path: Option<PathBuf>) -> Option<PathBuf> {
|
||||
if let Some(path) = path {
|
||||
return Some(expand_pathbuf(path));
|
||||
}
|
||||
|
||||
+158
-2
@@ -898,11 +898,13 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
Commands::Exec(args) => {
|
||||
let config = load_config_from_cli(&cli)?;
|
||||
let model = resolve_exec_model(&config, args.model.as_deref());
|
||||
let prompt = join_prompt_parts(&args.prompt);
|
||||
let workspace = cli.workspace.clone().unwrap_or_else(|| {
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
});
|
||||
let mut config = config.clone();
|
||||
merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
|
||||
let model = resolve_exec_model(&config, args.model.as_deref());
|
||||
let prompt = join_prompt_parts(&args.prompt);
|
||||
let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?;
|
||||
// The `deepseek` launcher forwards `--yolo` to this binary via
|
||||
// the DEEPSEEK_YOLO env var (which the config loader folds into
|
||||
@@ -4952,6 +4954,67 @@ fn merge_project_config(config: &mut Config, workspace: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_user_workspace_config(
|
||||
config: &mut Config,
|
||||
config_path: Option<PathBuf>,
|
||||
workspace: &Path,
|
||||
) {
|
||||
if config.managed_config_path.is_some() || config.requirements_path.is_some() {
|
||||
return;
|
||||
}
|
||||
let allow_shell_before = config.allow_shell;
|
||||
let allow_shell_from_env = std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_some();
|
||||
let Some(path) = crate::config::resolve_load_config_path(config_path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(raw) = std::fs::read_to_string(path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(doc) = toml::from_str::<toml::Value>(&raw) else {
|
||||
return;
|
||||
};
|
||||
merge_user_workspace_config_from_doc(config, &doc, workspace);
|
||||
if allow_shell_from_env {
|
||||
config.allow_shell = allow_shell_before;
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_user_workspace_config_from_doc(config: &mut Config, doc: &toml::Value, workspace: &Path) {
|
||||
for table_name in ["workspace", "projects"] {
|
||||
let Some(entries) = doc.get(table_name).and_then(toml::Value::as_table) else {
|
||||
continue;
|
||||
};
|
||||
for (raw_path, entry) in entries {
|
||||
if !workspace_config_path_matches(raw_path, workspace) {
|
||||
continue;
|
||||
}
|
||||
if let Some(allow_shell) = entry.get("allow_shell").and_then(toml::Value::as_bool) {
|
||||
config.allow_shell = Some(allow_shell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_config_path_matches(raw_path: &str, workspace: &Path) -> bool {
|
||||
let configured = crate::config::expand_path(raw_path);
|
||||
let configured = configured.canonicalize().unwrap_or(configured);
|
||||
let workspace = workspace
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace.to_path_buf());
|
||||
paths_equal_for_config(&configured, &workspace)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
|
||||
left.to_string_lossy()
|
||||
.eq_ignore_ascii_case(&right.to_string_lossy())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
|
||||
left == right
|
||||
}
|
||||
|
||||
async fn run_interactive(
|
||||
cli: &Cli,
|
||||
config: &Config,
|
||||
@@ -4967,6 +5030,7 @@ async fn run_interactive(
|
||||
// or legacy $WORKSPACE/.deepseek/config.toml
|
||||
// unless --no-project-config was passed (#485).
|
||||
let mut merged_config = config.clone();
|
||||
merge_user_workspace_config(&mut merged_config, cli.config.clone(), &workspace);
|
||||
if !cli.no_project_config {
|
||||
merge_project_config(&mut merged_config, &workspace);
|
||||
}
|
||||
@@ -6805,6 +6869,98 @@ allow_shell = false
|
||||
assert_eq!(config.allow_shell, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_workspace_overlay_can_enable_shell_for_matching_workspace() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let workspace = tmp.path().join("project");
|
||||
fs::create_dir_all(&workspace).expect("mkdir workspace");
|
||||
let raw = format!(
|
||||
"[workspace.'{}']\nallow_shell = true\n",
|
||||
workspace.display()
|
||||
);
|
||||
let doc: toml::Value = toml::from_str(&raw).expect("parse config");
|
||||
|
||||
let mut config = Config::default();
|
||||
merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
|
||||
|
||||
assert_eq!(config.allow_shell, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_workspace_overlay_ignores_non_matching_workspace() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let configured_workspace = tmp.path().join("configured");
|
||||
let active_workspace = tmp.path().join("active");
|
||||
fs::create_dir_all(&configured_workspace).expect("mkdir configured workspace");
|
||||
fs::create_dir_all(&active_workspace).expect("mkdir active workspace");
|
||||
let raw = format!(
|
||||
"[workspace.'{}']\nallow_shell = true\n",
|
||||
configured_workspace.display()
|
||||
);
|
||||
let doc: toml::Value = toml::from_str(&raw).expect("parse config");
|
||||
|
||||
let mut config = Config::default();
|
||||
merge_user_workspace_config_from_doc(&mut config, &doc, &active_workspace);
|
||||
|
||||
assert_eq!(config.allow_shell, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_workspace_overlay_preserves_allow_shell_env_override() {
|
||||
let _guard = crate::test_support::lock_test_env();
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let workspace = tmp.path().join("project");
|
||||
fs::create_dir_all(&workspace).expect("mkdir workspace");
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
"[workspace.'{}']\nallow_shell = true\n",
|
||||
workspace.display()
|
||||
),
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("DEEPSEEK_ALLOW_SHELL", "false");
|
||||
}
|
||||
let mut config = Config {
|
||||
allow_shell: Some(false),
|
||||
..Config::default()
|
||||
};
|
||||
merge_user_workspace_config(&mut config, Some(config_path), &workspace);
|
||||
unsafe {
|
||||
std::env::remove_var("DEEPSEEK_ALLOW_SHELL");
|
||||
}
|
||||
|
||||
assert_eq!(config.allow_shell, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_workspace_overlay_does_not_override_managed_config() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let workspace = tmp.path().join("project");
|
||||
fs::create_dir_all(&workspace).expect("mkdir workspace");
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
"[workspace.'{}']\nallow_shell = true\n",
|
||||
workspace.display()
|
||||
),
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let mut config = Config {
|
||||
allow_shell: Some(false),
|
||||
managed_config_path: Some("managed.toml".to_string()),
|
||||
..Config::default()
|
||||
};
|
||||
merge_user_workspace_config(&mut config, Some(config_path), &workspace);
|
||||
|
||||
assert_eq!(config.allow_shell, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_overlay_clamps_max_subagents_to_safe_range() {
|
||||
let tmp = workspace_with_project_config(
|
||||
|
||||
+15
-1
@@ -20,6 +20,20 @@ Overrides:
|
||||
|
||||
If both are set, `--config` wins. Environment variable overrides are applied after the file is loaded.
|
||||
|
||||
### User workspace entries
|
||||
|
||||
For a shell opt-in that should live in the user's global config rather than in
|
||||
the repository, add a workspace-scoped entry:
|
||||
|
||||
```toml
|
||||
[workspace.'/absolute/path/to/project']
|
||||
allow_shell = true
|
||||
```
|
||||
|
||||
The entry applies only when the launched workspace path matches the table key.
|
||||
The legacy `[projects."/absolute/path/to/project"]` table is also accepted for
|
||||
this user-owned override.
|
||||
|
||||
### Per-project overlay (#485)
|
||||
|
||||
When the TUI starts in a workspace that contains a
|
||||
@@ -596,7 +610,7 @@ If you are upgrading from older releases:
|
||||
- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://openrouter.ai/api/v1` for `openrouter`, `https://api.xiaomimimo.com/v1` for `xiaomi-mimo`, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features.
|
||||
- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow, `kimi-k2.6` for Moonshot, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026, except SiliconFlow maps `deepseek-reasoner` and `deepseek-r1` to its Pro model while `deepseek-chat` and `deepseek-v3` map to Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. OpenRouter also recognizes recent large IDs such as `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-35b-a3b`, `google/gemma-4-31b-it`, and `moonshotai/kimi-k2.6`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, and Ollama model IDs are passed through unchanged. OpenRouter and SiliconFlow provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias.
|
||||
- `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`.
|
||||
- `allow_shell` (bool, optional): defaults to `true` (sandboxed).
|
||||
- `allow_shell` (bool, optional): defaults to `false`; shell tools must be explicitly enabled.
|
||||
- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.
|
||||
- `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`.
|
||||
Platform support is not identical. macOS uses Seatbelt for policy
|
||||
|
||||
Reference in New Issue
Block a user