diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 10dd8493..ecce5eff 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -2704,7 +2704,7 @@ fn expand_pathbuf(path: PathBuf) -> PathBuf { path } -fn resolve_load_config_path(path: Option) -> Option { +pub(crate) fn resolve_load_config_path(path: Option) -> Option { if let Some(path) = path { return Some(expand_pathbuf(path)); } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 47468b73..206b8bb3 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -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, + 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::(&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( diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8c3919b0..4aa2d8e3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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