feat(code): add [startup].mode default approval mode (#4573)

Add a `[startup].mode` option to `~/.deepagents/config.toml` for
`deepagents-code`, accepting `manual` (default) or `dangerously-auto`,
to set the interactive TUI's default approval mode without passing
`-y`/`--auto-approve` each launch.

---

Lets users pick the interactive TUI's default approval mode in
`~/.deepagents/config.toml` instead of passing `-y`/`--auto-approve` on
every launch. The `[startup].mode` option accepts `manual` (default,
human-in-the-loop approvals) or `dangerously-auto` (auto-approve gated
tool calls at launch).

Precedence: an explicit `-y`/`--auto-approve` flag still wins; when
omitted, `[startup].mode` decides; the built-in fallback is `manual`, so
existing behavior is unchanged. Implemented by switching the flag's
argparse default to `None` (so "omitted" is distinguishable from "set")
and resolving the effective value at interactive dispatch. A new
validated `load_startup_mode` loader mirrors the existing
`[threads].sort_order` pattern and falls back to `manual` on
unset/unreadable/invalid values. Follows the `config.toml`-only approach
(a `ConfigOption` in the manifest for `dcode config` discovery, no new
env var). Non-interactive (`-n`) mode computes its own approval behavior
and is untouched.

Made by [Open
SWE](https://openswe.vercel.app/agents/82b4ad9e-f42e-7f73-ae2f-5a0830f3f8cc)

## References
- Plan:
https://openswe.vercel.app/agents/82b4ad9e-f42e-7f73-ae2f-5a0830f3f8cc/plan

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-07-08 20:36:51 -04:00
committed by GitHub
parent 6e8941776c
commit 7c5bf542c2
6 changed files with 283 additions and 9 deletions
+44 -6
View File
@@ -71,12 +71,13 @@ class OptionKind(Enum):
All kinds flow through `resolve_scalar`. The scalar kinds (`BOOL`,
`BOOL_PRESENCE`, `INT`, `FLOAT`, `STR`) are coerced inline by
`_coerce_env`/`_coerce_toml`. `SHELL_LIST_DELEGATE`, `SKILLS_DIRS_DELEGATE`,
and `PTC_DELEGATE` defer to a bespoke parser (their semantics — colon-split
Path resolution, comma + `recommended`/`all` sentinels, the PTC allowlist —
do not compress into a generic coercion). `THEME_DELEGATE` is resolved
separately at the top of `resolve_scalar` and never reaches the inline
coercers. `STRUCTURED` marks user-defined tables that the scalar resolver
only passes through for display.
`PTC_DELEGATE`, and `STARTUP_MODE_DELEGATE` defer to bespoke parsers (their
semantics — colon-split Path resolution, comma + `recommended`/`all`
sentinels, the PTC/startup-mode allowlists — do not compress into a generic
coercion). `THEME_DELEGATE` is resolved separately at the top of
`resolve_scalar` and never reaches the inline coercers. `STRUCTURED` marks
user-defined tables that the scalar resolver only passes through for
display.
"""
BOOL = "bool"
@@ -101,6 +102,9 @@ class OptionKind(Enum):
PTC_DELEGATE = "ptc"
"""Delegates to `config._parse_interpreter_ptc`."""
STARTUP_MODE_DELEGATE = "startup_mode"
"""Delegates to the `[startup].mode` runtime allowlist."""
THEME_DELEGATE = "theme"
"""Delegates to the app theme-preference loader semantics."""
@@ -117,6 +121,7 @@ _KIND_TYPE_LABEL: dict[OptionKind, str] = {
OptionKind.SHELL_LIST_DELEGATE: "list[str]",
OptionKind.SKILLS_DIRS_DELEGATE: "list[path]",
OptionKind.PTC_DELEGATE: "str | list[str]",
OptionKind.STARTUP_MODE_DELEGATE: "str",
OptionKind.THEME_DELEGATE: "theme",
OptionKind.STRUCTURED: "table",
}
@@ -137,6 +142,7 @@ _KIND_DEFAULT_TYPES: dict[OptionKind, tuple[type, ...]] = {
OptionKind.INT: (int,),
OptionKind.FLOAT: (int, float),
OptionKind.STR: (str,),
OptionKind.STARTUP_MODE_DELEGATE: (str,),
}
@@ -420,6 +426,17 @@ def _coerce_env(option: ConfigOption, raw: str, name: str) -> object:
# validation. Falling back to the validated default is the safe choice.
logger.warning("%s is not env-backed; ignoring %s=%r", option.key, name, raw)
return _INVALID
if kind is OptionKind.STARTUP_MODE_DELEGATE:
from deepagents_code.model_config import VALID_STARTUP_MODES
if raw in VALID_STARTUP_MODES:
return raw
logger.warning(
"Ignoring %s=%r (expected 'manual' or 'dangerously-auto')",
name,
raw,
)
return _INVALID
assert_never(kind)
@@ -467,6 +484,17 @@ def _coerce_toml(option: ConfigOption, raw: object) -> object:
except ValueError as exc:
logger.warning("Ignoring %s in config.toml: %s", label, exc)
return _INVALID
elif kind is OptionKind.STARTUP_MODE_DELEGATE:
from deepagents_code.model_config import VALID_STARTUP_MODES
if isinstance(raw, str) and raw in VALID_STARTUP_MODES:
return raw
logger.warning(
"Ignoring %s=%r in config.toml (expected 'manual' or 'dangerously-auto')",
label,
raw,
)
return _INVALID
elif kind is OptionKind.STRUCTURED:
# Passed through verbatim for display; parsed by a dedicated loader.
return raw
@@ -1188,6 +1216,16 @@ _STATIC_OPTIONS: tuple[ConfigOption, ...] = (
default="managed",
env_var=_env_vars.RIPGREP_INSTALLER,
),
# --- Startup --------------------------------------------------------
ConfigOption(
key="startup.mode",
group="Startup",
summary="Default approval mode at launch ('manual' or 'dangerously-auto').",
kind=OptionKind.STARTUP_MODE_DELEGATE,
default="manual",
toml_keys=("startup", "mode"),
cli_flag="--auto-approve",
),
# --- Debug / Development -------------------------------------------
ConfigOption(
key="debug.enabled",
+30 -2
View File
@@ -601,6 +601,27 @@ def _resolve_interpreter_enabled(args: argparse.Namespace) -> bool:
return _resolve_enable_interpreter(args.interpreter, args.sandbox)
def _resolve_auto_approve(args: argparse.Namespace) -> bool:
"""Return whether tool calls should be auto-approved for these CLI args.
An explicit `-y`/`--auto-approve` wins; when the flag is omitted
(`args.auto_approve is None`), the persistent `[startup].mode` config
default decides — `dangerously-auto` enables auto-approval, anything else
(including missing/invalid config) keeps human-in-the-loop approvals on.
Extracted from the `cli_main` body so it is unit-testable without
constructing the full arg tree, matching `_resolve_interpreter_enabled`.
"""
if args.auto_approve is not None:
return args.auto_approve
from deepagents_code.model_config import (
STARTUP_MODE_DANGEROUSLY_AUTO,
load_startup_mode,
)
return load_startup_mode() == STARTUP_MODE_DANGEROUSLY_AUTO
def _warn_if_interpreter_disabled_by_sandbox(args: argparse.Namespace) -> None:
"""Warn that a remote sandbox suppressed the otherwise-default interpreter.
@@ -1616,11 +1637,14 @@ def parse_args() -> argparse.Namespace:
"-y",
"--auto-approve",
action="store_true",
default=None,
help=(
"Auto-approve all tool calls without prompting "
"(disables human-in-the-loop). Affected tools: shell "
"execution, file writes/edits, web search, and URL fetch. "
"Use with caution — the agent can execute arbitrary commands."
"Use with caution — the agent can execute arbitrary commands. "
"When omitted, the launch default comes from [startup].mode in "
"~/.deepagents/config.toml ('manual' or 'dangerously-auto')."
),
)
@@ -3644,10 +3668,14 @@ def cli_main() -> None:
# advisory as a startup notification instead (see
# `DeepAgentsApp._notify_interpreter_tools_without_interpreter`).
# An explicit -y/--auto-approve wins; otherwise the persistent
# [startup].mode config default decides the launch mode.
auto_approve = _resolve_auto_approve(args)
result = asyncio.run(
run_textual_cli_async(
assistant_id=assistant_id,
auto_approve=args.auto_approve,
auto_approve=auto_approve,
sandbox_type=args.sandbox,
sandbox_id=args.sandbox_id,
sandbox_snapshot_name=args.sandbox_snapshot_name,
+51
View File
@@ -3554,6 +3554,57 @@ def load_thread_sort_order(config_path: Path | None = None) -> str:
return "updated_at"
STARTUP_MODE_MANUAL = "manual"
"""Startup approval mode that keeps human-in-the-loop approvals enabled."""
STARTUP_MODE_DANGEROUSLY_AUTO = "dangerously-auto"
"""Startup approval mode that auto-approves gated tool calls at launch."""
VALID_STARTUP_MODES = frozenset({STARTUP_MODE_MANUAL, STARTUP_MODE_DANGEROUSLY_AUTO})
"""Accepted values for the `[startup].mode` config option."""
DEFAULT_STARTUP_MODE = STARTUP_MODE_MANUAL
"""Fallback startup mode when `[startup].mode` is missing, unreadable, or invalid."""
def load_startup_mode(config_path: Path | None = None) -> str:
"""Load the default startup approval mode from config.toml.
Reads `[startup].mode`, which controls whether the interactive TUI launches
with human-in-the-loop approvals enabled (`manual`) or auto-approved
(`dangerously-auto`). The explicit `-y`/`--auto-approve` flag overrides this.
Args:
config_path: Path to config file.
Returns:
`"manual"` or `"dangerously-auto"`; falls back to `"manual"` when unset,
unreadable, or invalid.
"""
if config_path is None:
config_path = DEFAULT_CONFIG_PATH
try:
if not config_path.exists():
return DEFAULT_STARTUP_MODE
with config_path.open("rb") as f:
data = tomllib.load(f)
startup = data.get("startup")
value = startup.get("mode") if isinstance(startup, dict) else None
# `value` may be any TOML type; guard against non-strings (e.g. an
# array or table) before the frozenset membership test, which would
# otherwise raise `TypeError: unhashable type` and crash startup.
if isinstance(value, str) and value in VALID_STARTUP_MODES:
return value
if value is not None:
logger.warning(
"Ignoring [startup].mode=%r (expected 'manual' or 'dangerously-auto')",
value,
)
except (OSError, tomllib.TOMLDecodeError):
logger.debug("Could not read startup mode config", exc_info=True)
return DEFAULT_STARTUP_MODE
def save_thread_sort_order(sort_order: str, config_path: Path | None = None) -> bool:
"""Save the sort order preference for the thread selector.
@@ -32,7 +32,7 @@ from deepagents_code.config_manifest import (
resolve_interpreter_kwargs,
resolve_scalar,
)
from deepagents_code.model_config import PROVIDER_API_KEY_ENV
from deepagents_code.model_config import DEFAULT_STARTUP_MODE, PROVIDER_API_KEY_ENV
# Most unit tests set `DEEPAGENTS_CODE_NO_UPDATE_CHECK=1` to avoid accidental
# PyPI/DNS work. This module checks whether update settings came from the env,
@@ -1448,6 +1448,42 @@ def test_resolve_toml_str_success_and_type_mismatch(caplog) -> None:
assert any("sort_order" in r.getMessage() for r in caplog.records)
def test_startup_mode_option_definition() -> None:
"""`startup.mode` is config.toml-only and uses runtime mode validation."""
opt = get_option("startup.mode")
assert opt is not None
assert opt.group == "Startup"
assert opt.kind is OptionKind.STARTUP_MODE_DELEGATE
assert opt.default == DEFAULT_STARTUP_MODE
assert opt.toml_keys == ("startup", "mode")
assert opt.env_var is None
def test_resolve_startup_mode_from_toml(caplog) -> None:
"""`startup.mode` resolves only valid runtime modes from config.toml."""
import logging
opt = get_option("startup.mode")
assert opt is not None
assert resolve_scalar(opt, toml_data={"startup": {"mode": "dangerously-auto"}}) == (
"dangerously-auto",
"config.toml",
)
with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"):
value, source = resolve_scalar(opt, toml_data={"startup": {"mode": "yolo"}})
assert (value, source) == (DEFAULT_STARTUP_MODE, "default")
assert any("[startup].mode='yolo'" in r.getMessage() for r in caplog.records)
for raw in (["manual"], {"name": "manual"}):
caplog.clear()
with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"):
value, source = resolve_scalar(opt, toml_data={"startup": {"mode": raw}})
assert (value, source) == (DEFAULT_STARTUP_MODE, "default")
assert any("[startup].mode" in r.getMessage() for r in caplog.records)
assert resolve_scalar(opt, toml_data={}) == (DEFAULT_STARTUP_MODE, "default")
def test_resolve_toml_float_success_non_bool() -> None:
"""A FLOAT option reads a real number from TOML and coerces an int to float."""
opt = get_option("interpreter.timeout_seconds")
@@ -118,6 +118,60 @@ def test_shell_allow_list_combined_with_other_args(mock_argv: MockArgvType) -> N
assert parsed_args.auto_approve is True
class TestAutoApproveArgument:
"""Tests for -y / --auto-approve parsing and its config.toml default."""
def test_flag_sets_true(self, mock_argv: MockArgvType) -> None:
"""Passing -y sets auto_approve to True."""
with mock_argv("-y"):
assert parse_args().auto_approve is True
def test_long_flag_sets_true(self, mock_argv: MockArgvType) -> None:
"""Passing --auto-approve sets auto_approve to True."""
with mock_argv("--auto-approve"):
assert parse_args().auto_approve is True
def test_omitted_is_none(self, mock_argv: MockArgvType) -> None:
"""Omitting the flag leaves auto_approve as None so config.toml decides."""
with mock_argv():
assert parse_args().auto_approve is None
class TestResolveAutoApprove:
"""Tests for `_resolve_auto_approve` (flag vs. `[startup].mode` precedence)."""
def test_explicit_flag_wins_without_consulting_config(self) -> None:
"""An explicit -y (True) resolves True and never reads config."""
from deepagents_code.main import _resolve_auto_approve
args = argparse.Namespace(auto_approve=True)
with patch("deepagents_code.model_config.load_startup_mode") as mock_load:
assert _resolve_auto_approve(args) is True
mock_load.assert_not_called()
def test_omitted_flag_manual_config_resolves_false(self) -> None:
"""No flag + `[startup].mode = manual` keeps approvals on (False)."""
from deepagents_code.main import _resolve_auto_approve
args = argparse.Namespace(auto_approve=None)
with patch(
"deepagents_code.model_config.load_startup_mode",
return_value="manual",
):
assert _resolve_auto_approve(args) is False
def test_omitted_flag_dangerously_auto_config_resolves_true(self) -> None:
"""No flag + `[startup].mode = dangerously-auto` auto-approves (True)."""
from deepagents_code.main import _resolve_auto_approve
args = argparse.Namespace(auto_approve=None)
with patch(
"deepagents_code.model_config.load_startup_mode",
return_value="dangerously-auto",
):
assert _resolve_auto_approve(args) is True
@pytest.mark.parametrize(
("input_str", "expected"),
[
@@ -12,11 +12,14 @@ import pytest
from deepagents_code import model_config
from deepagents_code.model_config import (
DEFAULT_STARTUP_MODE,
IMPLICIT_AUTH_PROVIDERS,
NO_AUTH_REQUIRED_PROVIDERS,
PROVIDER_API_KEY_ENV,
PROVIDER_BASE_URL_ENV,
RETRY_PARAM_BY_PROVIDER,
STARTUP_MODE_DANGEROUSLY_AUTO,
STARTUP_MODE_MANUAL,
THREAD_COLUMN_DEFAULTS,
McpServerTrustLists,
ModelConfig,
@@ -43,6 +46,7 @@ from deepagents_code.model_config import (
load_mcp_server_trust_lists,
load_recent_agent,
load_recent_models,
load_startup_mode,
load_thread_columns,
save_default_agent,
save_recent_agent,
@@ -5881,3 +5885,66 @@ enabled = false
assert not any(
spec.startswith(f"{model_config.CODEX_PROVIDER}:") for spec in profiles
)
class TestLoadStartupMode:
"""Tests for `load_startup_mode` reading `[startup].mode` from config.toml."""
def test_missing_file_returns_default(self, tmp_path: Path) -> None:
"""A nonexistent config file falls back to the default mode."""
assert load_startup_mode(tmp_path / "missing.toml") == DEFAULT_STARTUP_MODE
assert DEFAULT_STARTUP_MODE == STARTUP_MODE_MANUAL
def test_unset_option_returns_default(self, tmp_path: Path) -> None:
"""A config file without `[startup].mode` falls back to the default."""
config = tmp_path / "config.toml"
config.write_text("[threads]\nsort_order = 'created_at'\n")
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
def test_explicit_manual(self, tmp_path: Path) -> None:
"""`mode = 'manual'` is returned verbatim."""
config = tmp_path / "config.toml"
config.write_text("[startup]\nmode = 'manual'\n")
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
def test_explicit_dangerously_auto(self, tmp_path: Path) -> None:
"""`mode = 'dangerously-auto'` is returned verbatim."""
config = tmp_path / "config.toml"
config.write_text("[startup]\nmode = 'dangerously-auto'\n")
assert load_startup_mode(config) == STARTUP_MODE_DANGEROUSLY_AUTO
def test_invalid_value_returns_default(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unrecognized mode logs a warning and falls back to the default."""
config = tmp_path / "config.toml"
config.write_text("[startup]\nmode = 'yolo'\n")
with caplog.at_level(logging.WARNING, logger="deepagents_code.model_config"):
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
assert any("startup" in r.getMessage().lower() for r in caplog.records)
def test_malformed_startup_table_returns_default(self, tmp_path: Path) -> None:
"""A non-table `startup` value does not crash and falls back."""
config = tmp_path / "config.toml"
config.write_text("startup = 'oops'\n")
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
def test_non_scalar_mode_returns_default(self, tmp_path: Path) -> None:
"""A non-string `mode` (e.g. array) falls back instead of raising.
`value in VALID_STARTUP_MODES` (a frozenset) would raise `TypeError:
unhashable type` on a list/dict; the isinstance guard must prevent that.
"""
config = tmp_path / "config.toml"
config.write_text("[startup]\nmode = ['dangerously-auto']\n")
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
def test_unparseable_file_returns_default(self, tmp_path: Path) -> None:
"""Syntactically invalid TOML is swallowed and falls back to default.
Exercises the `except (OSError, tomllib.TOMLDecodeError)` branch, which
must fail closed (to `manual`) rather than propagate and abort startup.
"""
config = tmp_path / "config.toml"
config.write_text("this is not valid toml [[[\n")
assert load_startup_mode(config) == STARTUP_MODE_MANUAL