feat(config): expand per-project overlay to cover provider, sandbox, approval, mcp_path, max_subagents, allow_shell (#485)

The project-config overlay (`<workspace>/.deepseek/config.toml` merged
on top of the user's global `~/.deepseek/config.toml`) was already
wired but only carried four string fields: model, api_key, base_url,
reasoning_effort. The use cases users actually file under #485 — "this
repo wants a different sandbox / approval policy / MCP server set / hard
sub-agent cap" — weren't covered.

### What ships

Adds the following keys to the project overlay, all merged with
identical "non-empty wins" semantics for strings:

- `provider` — pick a different backend per repo (e.g. `nvidia-nim` for
  an enterprise repo, `deepseek-cn` for a CN-team repo).
- `approval_policy` — `never` / `on-request` / `untrusted` for repos
  with strict policies.
- `sandbox_mode` — `read-only` / `workspace-write` / `danger-full-access`.
- `mcp_config_path` — per-repo MCP server set without touching the
  user's global file.
- `notes_path` — keep notes in-repo for projects where the notes tool
  is part of the dev workflow.

Plus two non-string fields:

- `max_subagents` (positive integer; clamped to `1..=MAX_SUBAGENTS=20`).
- `allow_shell` (bool).

### What stays user-global

`skills_dir`, `hooks`, `[capacity]`, `[retry]`, `[memory]`, etc. — those
are user-shaped settings, not repo-shaped. If a future use case
demands per-project values for any of them, a follow-up PR can extend
the overlay rather than letting the boundary blur.

### Tests

- 8 new tests in `project_config_tests` covering: provider+model,
  approval+sandbox, max_subagents+allow_shell, max_subagents
  clamping, negative-max_subagents rejection, missing config file
  pass-through, malformed TOML pass-through, and empty-string
  no-op.

### Docs

- New "Per-project overlay (#485)" section in `docs/CONFIGURATION.md`
  with a table of supported keys and the rationale for which fields
  stay user-global.

### Verification

cargo fmt --all -- --check                                          ✓
cargo clippy --workspace --all-targets --all-features --locked --   -D warnings   ✓
cargo test --workspace --all-features --locked                      ✓ 1828 + supporting (was 1820)

Closes #485

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-03 03:25:43 -05:00
parent a723ddd63d
commit 4d4a9b424c
2 changed files with 194 additions and 1 deletions
+163 -1
View File
@@ -2933,12 +2933,28 @@ fn merge_project_config(config: &mut Config, workspace: &Path) {
None => return,
};
// Apply top-level string fields that make sense for project overrides.
// String fields a project may legitimately want to override:
// - `provider` — pick a different backend per repo (e.g. NVIDIA NIM
// for an enterprise repo, deepseek-cn for a CN-team repo).
// - `model` / `api_key` / `base_url` / `reasoning_effort` — the
// original four.
// - `approval_policy` / `sandbox_mode` — let an opinionated repo
// demand `never` approval or `read-only` sandbox so the agent
// can't accidentally write.
// - `mcp_config_path` — point at a per-repo MCP config without
// touching the user's global file.
// - `notes_path` — keep notes in-repo for projects where the
// notes tool is part of the dev workflow.
for (key, field) in [
("provider", &mut config.provider),
("model", &mut config.default_text_model),
("api_key", &mut config.api_key),
("base_url", &mut config.base_url),
("reasoning_effort", &mut config.reasoning_effort),
("approval_policy", &mut config.approval_policy),
("sandbox_mode", &mut config.sandbox_mode),
("mcp_config_path", &mut config.mcp_config_path),
("notes_path", &mut config.notes_path),
] {
if let Some(v) = table.get(key).and_then(toml::Value::as_str)
&& !v.is_empty()
@@ -2946,6 +2962,16 @@ fn merge_project_config(config: &mut Config, workspace: &Path) {
*field = Some(v.to_string());
}
}
// Numeric / bool fields that benefit from per-project overrides.
if let Some(v) = table.get("max_subagents").and_then(toml::Value::as_integer)
&& v > 0
{
config.max_subagents = Some((v as usize).clamp(1, crate::config::MAX_SUBAGENTS));
}
if let Some(v) = table.get("allow_shell").and_then(toml::Value::as_bool) {
config.allow_shell = Some(v);
}
}
async fn run_interactive(
@@ -3413,6 +3439,142 @@ mod terminal_mode_tests {
}
}
#[cfg(test)]
mod project_config_tests {
use super::*;
use std::fs;
use tempfile::tempdir;
/// Write a `<workspace>/.deepseek/config.toml` and return the workspace
/// root so the merge function can find it.
fn workspace_with_project_config(body: &str) -> tempfile::TempDir {
let tmp = tempdir().expect("tempdir");
let project_dir = tmp.path().join(".deepseek");
fs::create_dir_all(&project_dir).expect("mkdir .deepseek");
fs::write(project_dir.join("config.toml"), body).expect("write project config");
tmp
}
#[test]
fn project_overlay_overrides_provider_and_model() {
let tmp = workspace_with_project_config(
r#"
provider = "nvidia-nim"
model = "deepseek-ai/deepseek-v4-pro"
"#,
);
let mut config = Config::default();
merge_project_config(&mut config, tmp.path());
assert_eq!(config.provider.as_deref(), Some("nvidia-nim"));
assert_eq!(
config.default_text_model.as_deref(),
Some("deepseek-ai/deepseek-v4-pro")
);
}
#[test]
fn project_overlay_overrides_approval_and_sandbox() {
let tmp = workspace_with_project_config(
r#"
approval_policy = "never"
sandbox_mode = "read-only"
"#,
);
let mut config = Config::default();
merge_project_config(&mut config, tmp.path());
assert_eq!(config.approval_policy.as_deref(), Some("never"));
assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
}
#[test]
fn project_overlay_overrides_max_subagents_and_allow_shell() {
let tmp = workspace_with_project_config(
r#"
max_subagents = 4
allow_shell = false
"#,
);
let mut config = Config::default();
merge_project_config(&mut config, tmp.path());
assert_eq!(config.max_subagents, Some(4));
assert_eq!(config.allow_shell, Some(false));
}
#[test]
fn project_overlay_clamps_max_subagents_to_safe_range() {
let tmp = workspace_with_project_config(
r#"
max_subagents = 500
"#,
);
let mut config = Config::default();
merge_project_config(&mut config, tmp.path());
assert_eq!(
config.max_subagents,
Some(crate::config::MAX_SUBAGENTS),
"should clamp to MAX_SUBAGENTS"
);
}
#[test]
fn project_overlay_ignores_negative_max_subagents() {
let tmp = workspace_with_project_config(
r#"
max_subagents = -3
"#,
);
let mut config = Config::default();
merge_project_config(&mut config, tmp.path());
assert_eq!(config.max_subagents, None, "negative should be ignored");
}
#[test]
fn project_overlay_skips_missing_config_file() {
let tmp = tempdir().expect("tempdir");
let mut config = Config {
provider: Some("deepseek".to_string()),
..Config::default()
};
merge_project_config(&mut config, tmp.path());
// Untouched.
assert_eq!(config.provider.as_deref(), Some("deepseek"));
}
#[test]
fn project_overlay_skips_malformed_toml() {
let tmp = workspace_with_project_config("this is not valid TOML !!");
let mut config = Config {
provider: Some("deepseek".to_string()),
..Config::default()
};
merge_project_config(&mut config, tmp.path());
// Untouched on parse error — better to fall back to global than crash.
assert_eq!(config.provider.as_deref(), Some("deepseek"));
}
#[test]
fn project_overlay_ignores_empty_string_values() {
let tmp = workspace_with_project_config(
r#"
provider = ""
model = ""
"#,
);
let mut config = Config {
provider: Some("deepseek".to_string()),
default_text_model: Some("deepseek-v4-pro".to_string()),
..Config::default()
};
merge_project_config(&mut config, tmp.path());
// Empty strings are ignored — they're rarely a deliberate override.
assert_eq!(config.provider.as_deref(), Some("deepseek"));
assert_eq!(
config.default_text_model.as_deref(),
Some("deepseek-v4-pro")
);
}
}
#[cfg(test)]
mod doctor_mcp_tests {
use super::*;
+31
View File
@@ -18,6 +18,37 @@ Overrides:
If both are set, `--config` wins. Environment variable overrides are applied after the file is loaded.
### Per-project overlay (#485)
When the TUI starts in a workspace that contains a
`<workspace>/.deepseek/config.toml` file, the values declared in that
file are merged on top of the global config. This lets a repo lock its
own provider, model, sandbox policy, or approval policy without
touching the user's `~/.deepseek/config.toml`. Pass
`--no-project-config` to skip the overlay for one launch.
Supported keys in the project overlay (top-level fields only):
| Key | Effect |
|---|---|
| `provider` | switch backend (e.g. `"nvidia-nim"` for an enterprise repo) |
| `model` | override `default_text_model` |
| `api_key` | use a per-repo key (typically read from `.env`, **not committed**) |
| `base_url` | point at a self-hosted endpoint |
| `reasoning_effort` | force `"high"` / `"max"` for a complex repo |
| `approval_policy` | `"never"` / `"on-request"` / `"untrusted"` for opinionated repos |
| `sandbox_mode` | `"read-only"` / `"workspace-write"` / `"danger-full-access"` |
| `mcp_config_path` | per-repo MCP server set |
| `notes_path` | keep notes in-repo |
| `max_subagents` | clamp concurrency for a constrained repo (clamped to 1..=20) |
| `allow_shell` | gate shell tool access on `false` |
The overlay is intentionally narrow — it covers the fields a repo
maintainer is most likely to want to standardize across contributors.
Other settings (skills_dir, hooks, capacity, retry, etc.) stay
user-global. If your repo needs more, file an issue describing the
specific use case.
The `deepseek` facade and `deepseek-tui` binary share the same config file for
DeepSeek auth and model defaults. `deepseek login --api-key ...` writes the
root `api_key` field that `deepseek-tui` reads directly, and `deepseek --model