feat(code): make project .env loading configurable (#5726)

`dcode` can now be told not to read the *project* `.env` file. A new
`startup.read_project_dotenv` option (default `true`, preserving current
behavior) controls whether the nearest `.env` discovered walking up from
the working directory is applied to the process environment. Set
`DEEPAGENTS_CODE_READ_PROJECT_DOTENV=0`/`false`, or
`[startup].read_project_dotenv = false` in `~/.deepagents/config.toml`,
to skip the project file. The global `~/.deepagents/.env` still loads
either way.

---

Loading the project `.env` is the trust boundary tracked as T12 in
`libs/code/THREAT_MODEL.md`: the file travels with a cloned repo, so its
values are attacker-controlled input that lands in `os.environ` and
reaches every subprocess `dcode` spawns (including the startup `git`
detection that runs before any approval prompt). The denylist from #4288
and the git keys from #5723 are best-effort enumerations of known
execution hooks — this option gives a user (or an org's managed config)
a way to close the boundary entirely when working in untrusted trees,
rather than waiting for the next unlisted hook to be reported.

Design points:

- **Default-on** so existing workflows (API keys, `DEEPAGENTS_CODE_*`
settings carried in a project `.env`) keep working; this is strictly an
opt-out.
- **Scoped to the project file only.** The user's own global
`~/.deepagents/.env` is trusted and unaffected.
- **Cannot self-veto.** The option resolves from managed config →
`DEEPAGENTS_CODE_READ_PROJECT_DOTENV` → user `~/.deepagents/config.toml`
→ default. All of those are read *before* any project `.env` is applied,
and the env var lives in the user-controlled process env (not a repo
file), so a project `.env` cannot turn the toggle off (or back on) for
itself.
- Wired as a first-class manifest option (`startup.read_project_dotenv`,
BOOL, default `True`) so `dcode config get startup.read_project_dotenv`
reports the resolved value and source like any other option. Resolution
is a thin `resolve_read_project_dotenv` over the standard ranked engine;
`_load_dotenv` consults it before the project-file branch and skips to
the global file when false. The dry-run `_preview_dotenv_environ` is
unchanged — it reports what a real reload would load, and a real reload
now loads nothing from the project file when the option is off.
This commit is contained in:
Mason Daugherty
2026-08-21 12:51:49 -04:00
committed by GitHub
parent 42afcded97
commit 995cad6751
7 changed files with 435 additions and 35 deletions
+13
View File
@@ -444,6 +444,19 @@ request. Also the escape hatch for hosts embedding this package that manage
process, so an embedder that starts its own would otherwise race this one.
"""
READ_PROJECT_DOTENV = "DEEPAGENTS_CODE_READ_PROJECT_DOTENV"
"""Toggle loading the *project* `.env` (the one found walking up from cwd).
Enabled by default, preserving the historical behavior of applying the nearest
project `.env` to the process environment (`override=False`, shell exports
win). Set to a falsy value (`0`, `false`, `no`, `off`) — or `[startup]`
`read_project_dotenv = false` in config.toml — to skip the project file
entirely, as defense-in-depth against a cloned repo whose `.env` carries
hostile values the dotenv denylist does not yet enumerate. The global
`~/.deepagents/.env` is unaffected. This is user-controlled process env, not a
repo file, so a project `.env` cannot disable itself.
"""
RECURSION_LIMIT = "DEEPAGENTS_CODE_RECURSION_LIMIT"
"""Override the main agent's LangGraph `recursion_limit` (graph step budget).
@@ -1037,13 +1037,17 @@ def _config_path_status(
*,
exists: bool,
health: ManagedHealth | None = None,
project_dotenv_enabled: bool = True,
) -> str:
"""Return a diagnostic status for one config-path row.
The managed row reports parse health rather than mere existence, so a
corrupt file is not shown as present and fine. A file that parses but
declares an unenforceable key is reported as rejected, because it is the
other half of exit 78 and read as `ok` before.
other half of exit 78 and read as `ok` before. The project `.env` row is
reported as `disabled` when `startup.read_project_dotenv` is off: the file
exists on disk but is skipped at bootstrap, and `ok` would wrongly imply it
is a live config source.
Returns:
A short status word for the row.
@@ -1056,6 +1060,8 @@ def _config_path_status(
if health.status.usable and health.violations:
return "rejected"
return health.status.health.value.lower()
if label == "project .env" and not project_dotenv_enabled:
return "disabled"
return "ok" if exists else "missing"
@@ -1067,6 +1073,12 @@ def _run_path(output_format: OutputFormat) -> int:
"""
paths = _config_paths()
_, health = _load_managed_generation()
# The project `.env` is listed whether or not it is loaded; when
# `startup.read_project_dotenv` is off the file exists on disk but is skipped
# at bootstrap, so its row is reported as disabled rather than a live source.
from deepagents_code.config_manifest import resolve_read_project_dotenv
project_dotenv_enabled = resolve_read_project_dotenv()
if output_format == "json":
write_json(
@@ -1080,6 +1092,7 @@ def _run_path(output_format: OutputFormat) -> int:
label,
exists=exists,
health=health,
project_dotenv_enabled=project_dotenv_enabled,
),
}
for label, path, exists in paths
@@ -1092,9 +1105,16 @@ def _run_path(output_format: OutputFormat) -> int:
console.print()
console.print("[bold]Config locations[/bold]")
for label, path, exists in paths:
status = _config_path_status(label, exists=exists, health=health)
status = _config_path_status(
label,
exists=exists,
health=health,
project_dotenv_enabled=project_dotenv_enabled,
)
if status in {"ok", "missing"}:
marker = "[green]ok[/green]" if status == "ok" else "[dim]missing[/dim]"
elif status == "disabled":
marker = "[yellow]disabled[/yellow]"
else:
marker = f"[red]{status}[/red]"
console.print(f" {label:<22} {path} ({marker})", highlight=False)
+81 -28
View File
@@ -29,6 +29,7 @@ from deepagents_code._env_vars import (
DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS,
DISABLED_PROJECT_MCP_SERVERS,
HIDE_SPLASH_VERSION,
READ_PROJECT_DOTENV,
is_env_truthy,
)
from deepagents_code._git import resolve_git_branch
@@ -143,6 +144,7 @@ _DOTENV_DENIED_ENV_KEYS = frozenset(
"SSH_ASKPASS",
"SYSTEMROOT",
"WINDIR",
READ_PROJECT_DOTENV,
_INHERITED_PYTHONPATH_ENV,
}
)
@@ -183,6 +185,14 @@ checking which category it belongs to:
`PYTHONPATH` into agent `execute` commands through the carrier var; the carrier
is only meant to relay a value the user set in their launch environment.
`READ_PROJECT_DOTENV` is denied from *every* `.env` (not just the project one)
because it is a trust decision about the loader itself: if a project `.env`
could set it, first-write-wins would let that file pin it `true` and block the
trusted global `.env` from opting out, and if the global file set it the
project file would already have loaded by the time it was read. The option is
resolved from the trusted global file (read directly, before the project file)
plus the process env and config.toml, never from dotenv injection.
Matching is case-sensitive on POSIX because the protected consumers (the
dynamic linker, bash, CPython, git) read these names only in their canonical
case, so a lowercase `bash_env` injected into the environment is inert there.
@@ -315,7 +325,9 @@ def _preview_dotenv_environ(*, start_path: Path | None = None) -> dict[str, str]
Returns:
Environment mapping with project and global dotenv values applied using
the same first-write-wins precedence as `_load_dotenv`.
the same first-write-wins precedence as `_load_dotenv`. The project
`.env` is skipped when `startup.read_project_dotenv` resolves false, so
a preview never reports a value a real reload would not load.
"""
import dotenv
@@ -354,21 +366,30 @@ def _preview_dotenv_environ(*, start_path: Path | None = None) -> dict[str, str]
continue
env[key] = value
project_dotenv: Path | None = None
try:
project_dotenv = (
_find_dotenv_from_start_path(start_path)
if start_path is not None
else _find_dotenv_from_start_path(Path.cwd())
)
except OSError:
logger.warning(
"Could not inspect project dotenv at %s; previewed project env vars may "
"be incomplete",
from deepagents_code.config_manifest import resolve_read_project_dotenv
if resolve_read_project_dotenv():
project_dotenv: Path | None = None
try:
project_dotenv = (
_find_dotenv_from_start_path(start_path)
if start_path is not None
else _find_dotenv_from_start_path(Path.cwd())
)
except OSError:
logger.warning(
"Could not inspect project dotenv at %s; previewed project env "
"vars may be incomplete",
start_path or "cwd",
exc_info=True,
)
apply_dotenv(project_dotenv, is_project=True)
else:
logger.debug(
"Skipping project dotenv preview at %s: startup.read_project_dotenv "
"is false",
start_path or "cwd",
exc_info=True,
)
apply_dotenv(project_dotenv, is_project=True)
try:
global_dotenv = _GLOBAL_DOTENV_PATH if _GLOBAL_DOTENV_PATH.is_file() else None
@@ -407,7 +428,8 @@ def _load_dotenv(
Loads in order (first write wins, `override=False`):
1. Project/CWD `.env` — project-specific values
1. Project/CWD `.env` — project-specific values (skipped when
`startup.read_project_dotenv` resolves false)
2. `~/.deepagents/.env` — global user defaults
Both layers use `override=False` (the python-dotenv default) so that
@@ -468,25 +490,56 @@ def _load_dotenv(
return applied
# 1. Project/CWD .env — loads first so project values are set before the
# global file, which can only fill in vars not already present.
dotenv_path: Path | str | None = None
# global file, which can only fill in vars not already present. Skipped
# entirely when `startup.read_project_dotenv` resolves false. The option's
# own env var is denied from *every* `.env` (see `_DOTENV_DENIED_ENV_KEYS`),
# so neither file can inject it; but the trusted global `.env` is a
# legitimate place to opt out, so read just that key from it *before* the
# project file is touched — otherwise a hostile project file would load (and
# could pin the var true) before the trusted opt-out was ever seen.
from deepagents_code.config_manifest import resolve_read_project_dotenv
global_toggle: dict[str, str] = {}
try:
if start_path is None:
found = dotenv.find_dotenv(usecwd=True)
if found:
dotenv_path = found
loaded = apply_dotenv(Path(found), is_project=True) or loaded
else:
dotenv_path = _find_dotenv_from_start_path(start_path)
if dotenv_path is not None:
loaded = apply_dotenv(dotenv_path, is_project=True) or loaded
if _GLOBAL_DOTENV_PATH.is_file():
raw = dotenv.dotenv_values(dotenv_path=_GLOBAL_DOTENV_PATH).get(
READ_PROJECT_DOTENV
)
if raw is not None:
global_toggle[READ_PROJECT_DOTENV] = raw
except (OSError, ValueError):
logger.warning(
"Could not read project dotenv at %s; project env vars will not be loaded",
dotenv_path or start_path or "cwd",
"Could not read global dotenv at %s; global defaults will not be applied",
_GLOBAL_DOTENV_PATH,
exc_info=True,
)
read_project = resolve_read_project_dotenv(global_dotenv=global_toggle)
dotenv_path: Path | str | None = None
if read_project:
try:
if start_path is None:
found = dotenv.find_dotenv(usecwd=True)
if found:
dotenv_path = found
loaded = apply_dotenv(Path(found), is_project=True) or loaded
else:
dotenv_path = _find_dotenv_from_start_path(start_path)
if dotenv_path is not None:
loaded = apply_dotenv(dotenv_path, is_project=True) or loaded
except (OSError, ValueError):
logger.warning(
"Could not read project dotenv at %s; project env vars will not "
"be loaded",
dotenv_path or start_path or "cwd",
exc_info=True,
)
else:
logger.debug(
"Skipping project dotenv at %s: startup.read_project_dotenv is false",
start_path or "cwd",
)
# 2. Global (~/.deepagents/.env) — fills in any vars not already set by
# the shell or the project dotenv.
# try/except wraps both is_file() and load_dotenv() to cover the TOCTOU
@@ -788,6 +788,74 @@ def resolve_scalar(
return resolved.value, _ranked_source(resolved)
def resolve_read_project_dotenv(
*,
toml_data: Mapping[str, Any] | None = None,
managed_toml_data: Mapping[str, Any] | None = None,
global_dotenv: Mapping[str, str] | None = None,
) -> bool:
"""Resolve whether the project `.env` should be loaded into the process env.
Resolves `startup.read_project_dotenv` with precedence managed → process
env → global `~/.deepagents/.env` → `config.toml` → default. The default
(`True`) preserves the historical behavior of loading the project `.env`.
Disabling skips only the project file — the global `~/.deepagents/.env`
still loads — as defense-in-depth against an untrusted repo whose `.env`
carries hostile values the dotenv denylist does not yet enumerate.
The option's own env var is denied from every `.env` (see
`config._DOTENV_DENIED_ENV_KEYS`), so neither dotenv file can inject it into
the process env; and the global-file value is read directly (before the
project file is touched) and supplied here as `global_dotenv`, so the
trusted global opt-out is honored for the current startup and a project
`.env` cannot pin the toggle true via first-write-wins.
Args:
toml_data: Parsed `config.toml`; loaded automatically when omitted.
managed_toml_data: Parsed managed TOML; the process snapshot is used when
omitted.
global_dotenv: The trusted global `~/.deepagents/.env` value for the
option's env var, when present; occupies a tier between the process
env and `config.toml`.
Returns:
`True` (the default) to load the project `.env`, `False` to skip it.
"""
option = get_option("startup.read_project_dotenv")
if option is None:
return True
# Managed policy and the process env outrank the trusted global dotenv;
# resolve them (and TOML, which we discard here in favor of the global
# file) through the standard engine, then layer the global dotenv between
# the env and TOML to match the option's env-over-file precedence.
data = load_config_toml() if toml_data is None else toml_data
value, source = resolve_scalar(
option, toml_data=data, managed_toml_data=managed_toml_data
)
if source.startswith(("managed", "env (")):
return bool(value)
# The trusted global `~/.deepagents/.env` is a legitimate place to opt out
# and is read (by the caller) before the project file is touched.
raw = (global_dotenv or {}).get(option.env_var or "")
if raw is not None:
classified = _env_vars.classify_env_bool(raw)
if classified is not None:
return classified
logger.warning(
"Ignoring unrecognized %s value %r in the global dotenv; using %r",
option.env_var,
raw,
option.default,
)
# Fall back to a TOML value (if any), then the typed default.
if source == "config.toml":
return bool(value)
return bool(option.default)
def load_bool_display_preference(
key: str,
*,
@@ -2246,6 +2314,18 @@ _STATIC_OPTIONS: tuple[ConfigOption, ...] = (
toml_keys=("startup", "mode"),
cli_flag="--auto-approve",
),
ConfigOption(
key="startup.read_project_dotenv",
group="Startup",
summary=(
"Load the project `.env` (found walking up from cwd) into the "
"process environment; disable to skip an untrusted repo's file."
),
kind=OptionKind.BOOL,
default=True,
env_var=_env_vars.READ_PROJECT_DOTENV,
toml_keys=("startup", "read_project_dotenv"),
),
ConfigOption(
key="startup.yolo_switcher",
group="Startup",
+57
View File
@@ -322,6 +322,63 @@ class TestProjectDotenvDeniedKeys:
assert env["DEEPAGENTS_CODE_OPENAI_API_KEY"] == "sk-from-project"
class TestResolveReadProjectDotenv:
"""`startup.read_project_dotenv` resolution across config layers."""
def test_default_is_true(self) -> None:
"""Unset everywhere preserves the historical load-the-project-.env behavior."""
from deepagents_code.config_manifest import resolve_read_project_dotenv
assert resolve_read_project_dotenv(toml_data={}, managed_toml_data={}) is True
def test_env_disables(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A falsy env value skips the project `.env`."""
from deepagents_code._env_vars import READ_PROJECT_DOTENV
from deepagents_code.config_manifest import resolve_read_project_dotenv
monkeypatch.setattr(
"deepagents_code.model_config.DEFAULT_CONFIG_PATH",
tmp_path / "missing" / "config.toml",
)
monkeypatch.setenv(READ_PROJECT_DOTENV, "0")
assert resolve_read_project_dotenv(managed_toml_data={}) is False
def test_toml_disables(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""`[startup].read_project_dotenv = false` applies when env is unset."""
from deepagents_code._env_vars import READ_PROJECT_DOTENV
from deepagents_code.config_manifest import resolve_read_project_dotenv
monkeypatch.delenv(READ_PROJECT_DOTENV, raising=False)
assert (
resolve_read_project_dotenv(
toml_data={"startup": {"read_project_dotenv": False}},
managed_toml_data={},
)
is False
)
def test_managed_beats_env(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A managed `false` overrides an env `true` (managed rank outranks env)."""
from deepagents_code._env_vars import READ_PROJECT_DOTENV
from deepagents_code.config_manifest import resolve_read_project_dotenv
monkeypatch.setattr(
"deepagents_code.model_config.DEFAULT_CONFIG_PATH",
tmp_path / "missing" / "config.toml",
)
monkeypatch.setenv(READ_PROJECT_DOTENV, "1")
assert (
resolve_read_project_dotenv(
managed_toml_data={"startup": {"read_project_dotenv": False}}
)
is False
)
class TestProjectRootDetection:
"""Test project root detection via .git directory."""
@@ -2187,6 +2187,31 @@ def test_diagnostics_report_an_unenforceable_managed_policy(
service.invalidate_config_sources()
def test_project_dotenv_path_reports_disabled_when_reading_off() -> None:
"""A skipped project `.env` must not read as a live `ok` config source.
`dcode config path` lists the project `.env` whether or not it is loaded;
with `startup.read_project_dotenv` off the file exists but is skipped at
bootstrap, so the row says `disabled` instead of `ok`.
"""
from deepagents_code.client.commands.config import _config_path_status
assert (
_config_path_status("project .env", exists=True, project_dotenv_enabled=False)
== "disabled"
)
# Enabled and missing cases are unaffected.
assert (
_config_path_status("project .env", exists=True, project_dotenv_enabled=True)
== "ok"
)
assert (
_config_path_status("project .env", exists=False, project_dotenv_enabled=False)
== "disabled"
)
assert _config_path_status("global .env", exists=True) == "ok"
def test_a_guessed_managed_path_is_not_a_clean_missing_file(
monkeypatch: pytest.MonkeyPatch,
) -> None:
+157 -5
View File
@@ -392,12 +392,12 @@ class TestReloadFromEnvironment:
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
original_dotenv_values = _dotenv_module.dotenv_values
call_count = 0
global_calls = 0
def _fail_on_global(*, dotenv_path: Path) -> dict[str, str | None]:
nonlocal call_count
call_count += 1
if call_count == 2:
nonlocal global_calls
if dotenv_path == global_env:
global_calls += 1
msg = "read error"
raise OSError(msg)
return dict(original_dotenv_values(dotenv_path=dotenv_path))
@@ -407,7 +407,9 @@ class TestReloadFromEnvironment:
with caplog.at_level(logging.WARNING, logger="deepagents_code.config"):
settings.reload_from_environment(start_path=tmp_path)
assert call_count == 2
# The global file is read once for the trusted `read_project_dotenv`
# pre-check and once for its remaining values; both hit the failure.
assert global_calls == 2
assert os.environ["OPENAI_API_KEY"] == "sk-ok"
assert any("Could not read global dotenv" in r.message for r in caplog.records)
@@ -554,6 +556,156 @@ class TestReloadFromEnvironment:
assert key not in os.environ
assert os.environ["OPENAI_API_KEY"] == "sk-ok"
def test_project_dotenv_skipped_when_read_project_dotenv_false(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""`startup.read_project_dotenv = false` skips the project `.env`.
The project file must not apply its values, while the global
`~/.deepagents/.env` still loads — disabling is scoped to the untrusted,
repo-traveling file, not the user's own global defaults.
"""
from deepagents_code.config import _load_dotenv
project_env = tmp_path / ".env"
project_env.write_text("GIT_CONFIG_COUNT=1\nPROJECT_ONLY_KEY=project-value\n")
global_dir = tmp_path / "global"
global_dir.mkdir()
global_env = global_dir / ".env"
global_env.write_text("GLOBAL_ONLY_KEY=global-value\n")
monkeypatch.setattr("deepagents_code.config._GLOBAL_DOTENV_PATH", global_env)
for key in ("GIT_CONFIG_COUNT", "PROJECT_ONLY_KEY", "GLOBAL_ONLY_KEY"):
monkeypatch.delenv(key, raising=False)
monkeypatch.delenv("DEEPAGENTS_CODE_READ_PROJECT_DOTENV", raising=False)
monkeypatch.setattr(
"deepagents_code.config_manifest.resolve_read_project_dotenv",
lambda **_kw: False,
)
_load_dotenv(start_path=tmp_path)
assert "GIT_CONFIG_COUNT" not in os.environ
assert "PROJECT_ONLY_KEY" not in os.environ
assert os.environ["GLOBAL_ONLY_KEY"] == "global-value"
def test_project_dotenv_loads_when_read_project_dotenv_default(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Default (`startup.read_project_dotenv` true) still loads the project file."""
from deepagents_code.config import _load_dotenv
project_env = tmp_path / ".env"
project_env.write_text("PROJECT_ONLY_KEY=project-value\n")
monkeypatch.setattr(
"deepagents_code.config._GLOBAL_DOTENV_PATH",
tmp_path / "nonexistent" / ".env",
)
monkeypatch.delenv("PROJECT_ONLY_KEY", raising=False)
monkeypatch.setattr(
"deepagents_code.config_manifest.resolve_read_project_dotenv",
lambda **_kw: True,
)
_load_dotenv(start_path=tmp_path)
assert os.environ["PROJECT_ONLY_KEY"] == "project-value"
def test_project_dotenv_cannot_set_read_project_dotenv(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A project `.env` cannot inject the toggle that skips it.
`DEEPAGENTS_CODE_READ_PROJECT_DOTENV` is denied from every `.env` (the
`_DOTENV_DENIED_ENV_KEYS` set), so a hostile project file cannot pin it
true and block the trusted global file from opting out via
first-write-wins.
"""
from deepagents_code.config import _load_dotenv
project_env = tmp_path / ".env"
project_env.write_text(
"DEEPAGENTS_CODE_READ_PROJECT_DOTENV=1\nPROJECT_ONLY_KEY=project-value\n"
)
monkeypatch.setattr(
"deepagents_code.config._GLOBAL_DOTENV_PATH",
tmp_path / "nonexistent" / ".env",
)
monkeypatch.delenv("DEEPAGENTS_CODE_READ_PROJECT_DOTENV", raising=False)
monkeypatch.delenv("PROJECT_ONLY_KEY", raising=False)
_load_dotenv(start_path=tmp_path)
assert "DEEPAGENTS_CODE_READ_PROJECT_DOTENV" not in os.environ
assert os.environ["PROJECT_ONLY_KEY"] == "project-value"
def test_global_dotenv_read_project_dotenv_false_protects_startup(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The trusted global `.env` opt-out is honored for the current startup.
The toggle is read from the global file *before* the project file is
touched, so `DEEPAGENTS_CODE_READ_PROJECT_DOTENV=false` in
`~/.deepagents/.env` skips the untrusted project `.env` even though the
global file is otherwise loaded after it.
"""
from deepagents_code.config import _load_dotenv
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".env").write_text("PROJECT_ONLY_KEY=project-value\n")
global_dir = tmp_path / "global"
global_dir.mkdir()
(global_dir / ".env").write_text(
"DEEPAGENTS_CODE_READ_PROJECT_DOTENV=false\nGLOBAL_ONLY_KEY=global-value\n"
)
monkeypatch.setattr(
"deepagents_code.config._GLOBAL_DOTENV_PATH", global_dir / ".env"
)
for key in (
"DEEPAGENTS_CODE_READ_PROJECT_DOTENV",
"PROJECT_ONLY_KEY",
"GLOBAL_ONLY_KEY",
):
monkeypatch.delenv(key, raising=False)
_load_dotenv(start_path=project_dir)
assert "PROJECT_ONLY_KEY" not in os.environ
# The toggle's env var is denied from every `.env`, so the global file's
# own copy is consumed for the decision but not injected into os.environ.
assert "DEEPAGENTS_CODE_READ_PROJECT_DOTENV" not in os.environ
# The global file's other values still load.
assert os.environ["GLOBAL_ONLY_KEY"] == "global-value"
def test_preview_dotenv_skipped_when_read_project_dotenv_false(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Preview mirrors the loader: a disabled project `.env` is not reported.
The preview drives the user-facing cwd-switch prompt
(`_preview_project_settings_change`); if it still read the project file
while the runtime loader skipped it, the app would warn about settings
changes that a real reload would never apply.
"""
from deepagents_code.config import _preview_dotenv_environ
(tmp_path / ".env").write_text("PROJECT_ONLY_KEY=project-value\n")
monkeypatch.setattr(
"deepagents_code.config._GLOBAL_DOTENV_PATH",
tmp_path / "nonexistent" / ".env",
)
monkeypatch.delenv("PROJECT_ONLY_KEY", raising=False)
monkeypatch.setattr(
"deepagents_code.config_manifest.resolve_read_project_dotenv",
lambda **_kw: False,
)
env = _preview_dotenv_environ(start_path=tmp_path)
assert "PROJECT_ONLY_KEY" not in env
def test_project_dotenv_cannot_set_mcp_trust_lists(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None: