mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-27 10:51:26 -04:00
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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user