fix(code): restore caller's LangSmith API key in shell subprocess env (#4458)

Related: #4436

Fixed a credential leak where shell subprocesses spawned by Deep Agents
Code inherited the agent's internal LangSmith API key override instead
of the caller's original key. Subprocess environments now restore the
caller's `LANGSMITH_API_KEY` (and LangChain alias) to the value they had
before bootstrap, or remove the key entirely if the caller never set
one.

---

## Why

When a user launches Deep Agents Code with
`DEEPAGENTS_CODE_LANGSMITH_API_KEY` set, bootstrap bridges that prefixed
value onto the canonical `LANGSMITH_API_KEY` so the LangSmith SDK picks
it up for the agent's own tracing. This overwrites the caller's original
key in-process — fine for the agent itself, but the original value was
never saved. As a result, shell commands run by the agent (via
`execute`) inherited the override key, and the caller's own credential
was irrecoverable in that subprocess environment.

This is the same data-loss footgun that `restore_user_tracing_env`
already guards for tracing *flags*. The fix applies the same
save/restore pattern to tracing API *keys*: snapshot the caller's keys
during `_ensure_bootstrap`, then restore them (or drop the propagated
value if the caller had none) when preparing the shell subprocess
environment in `create_cli_agent`.
This commit is contained in:
Mason Daugherty
2026-07-02 21:13:07 -04:00
committed by GitHub
parent 4cb47491b0
commit 9293b19017
4 changed files with 284 additions and 0 deletions
+5
View File
@@ -74,6 +74,7 @@ from deepagents_code.config import (
get_default_coding_instructions,
get_glyphs,
get_langsmith_project_name,
restore_user_tracing_api_keys,
restore_user_tracing_env,
settings,
)
@@ -1663,6 +1664,7 @@ def create_cli_agent(
else:
shell_env.pop("LANGSMITH_PROJECT", None)
restore_user_tracing_env(shell_env)
restore_user_tracing_api_keys(shell_env)
# Re-apply a launch-time PYTHONPATH that was stripped from the server
# interpreter but relayed for approval-gated `execute` commands.
_apply_inherited_pythonpath(shell_env)
@@ -1673,6 +1675,9 @@ def create_cli_agent(
# `inherit_env=False`: `shell_env` is already a complete, curated
# copy of `os.environ`. Inheriting again would re-copy `os.environ`
# and resurrect the popped carrier var, leaking it into `execute`.
# `restore_user_tracing_api_keys` above depends on this too: flipping
# to `inherit_env=True` would re-copy the agent's overridden
# `LANGSMITH_API_KEY` and undo the restore, leaking it into `execute`.
backend = LocalShellBackend(
root_dir=root_dir,
virtual_mode=False,
+36
View File
@@ -58,6 +58,20 @@ class _BootstrapState:
original_tracing_env: dict[str, str | None] = dataclass_field(default_factory=dict)
"""Caller's tracing-enable env before Deep Agents Code mutates flags."""
original_tracing_api_keys: dict[str, str | None] = dataclass_field(
default_factory=dict
)
"""Caller's tracing API keys before Deep Agents Code overwrites them.
Two bootstrap steps can overwrite the canonical `LANGSMITH_API_KEY` (and
its `LANGCHAIN_API_KEY` alias): the `DEEPAGENTS_CODE_`-prefixed override and
the `/auth`-stored key bridged on by `apply_stored_langsmith_auth`. Both run
after this snapshot is captured. Without saving the originals, shell
subprocesses inherit the agent's session key and the caller's own value is
irrecoverable in-process. This mirrors the save/restore pattern used for
tracing flags (`original_tracing_env`).
"""
_bootstrap_state = _BootstrapState()
"""State captured and mutated by lazy bootstrap."""
@@ -518,6 +532,25 @@ def restore_user_tracing_env(env: dict[str, str]) -> None:
env[var] = value
def restore_user_tracing_api_keys(env: dict[str, str]) -> None:
"""Restore caller tracing API keys in an environment passed to user code.
Reverts both bootstrap overwrites of the canonical LangSmith key — the
`DEEPAGENTS_CODE_`-prefixed override and the `/auth`-stored key — so shell
subprocesses receive the caller's own key rather than the agent's session
key. See `original_tracing_api_keys` for the rationale; this mirrors
`restore_user_tracing_env`, which does the same for tracing flags.
Args:
env: Environment mapping prepared for a child/user subprocess.
"""
for var, value in _bootstrap_state.original_tracing_api_keys.items():
if value is None:
env.pop(var, None)
else:
env[var] = value
def _disable_orphaned_tracing() -> None:
"""Disable LangSmith tracing when enabled without a usable API key.
@@ -729,6 +762,9 @@ def _ensure_bootstrap() -> None:
_bootstrap_state.original_tracing_env = {
var: os.environ.get(var) for var in _TRACING_ENABLE_ENV_VARS
}
_bootstrap_state.original_tracing_api_keys = {
var: os.environ.get(var) for var in _TRACING_API_KEY_ENV_VARS
}
# CRITICAL: Override LANGSMITH_PROJECT to route agent traces to a
# separate project. LangSmith reads LANGSMITH_PROJECT at invocation
+39
View File
@@ -1958,6 +1958,45 @@ class TestCreateCliAgentProjectContext:
assert mock_shell.call_args.kwargs["env"]["LANGSMITH_PROJECT"] == user_project
def test_project_context_restores_user_shell_langsmith_api_key(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The shell env is routed through `restore_user_tracing_api_keys`.
Guards the wiring in `create_cli_agent`'s local-shell branch: the env
handed to `LocalShellBackend` must carry the caller's original
`LANGSMITH_API_KEY` (the agent's in-process override reverted) and must
drop a key the caller never set. Removing the restore call regresses
both assertions, catching the exact key leak the restore prevents.
"""
import deepagents_code.config as config_mod
original_done = config_mod._bootstrap_state.done
original_api_keys = dict(config_mod._bootstrap_state.original_tracing_api_keys)
# Simulate a completed bootstrap: the caller had their own LANGSMITH key
# (since overridden in-process) and never set a LANGCHAIN key.
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_agent_override")
monkeypatch.setenv("LANGCHAIN_API_KEY", "lc_agent_override")
config_mod._bootstrap_state.original_tracing_api_keys = {
"LANGSMITH_API_KEY": "lsv2_user_original",
"LANGCHAIN_API_KEY": None,
}
# Guard the seeded snapshot against an incidental bootstrap run.
config_mod._bootstrap_state.done = True
try:
mock_shell, _ = self._build_shell_agent(
monkeypatch, tmp_path, user_langchain_project=None
)
env = mock_shell.call_args.kwargs["env"]
# The caller's own key is restored, not the agent's override.
assert env["LANGSMITH_API_KEY"] == "lsv2_user_original"
# A key the caller never set is dropped, not leaked.
assert "LANGCHAIN_API_KEY" not in env
finally:
config_mod._bootstrap_state.done = original_done
config_mod._bootstrap_state.original_tracing_api_keys = original_api_keys
def test_cwd_sets_local_filesystem_root_dir_without_shell(
self, tmp_path: Path
) -> None:
+204
View File
@@ -4768,6 +4768,7 @@ class TestLazyModuleAttributes:
original_done = config_mod._bootstrap_state.done
original_ls = config_mod._bootstrap_state.original_langsmith_project
original_api_keys = dict(config_mod._bootstrap_state.original_tracing_api_keys)
config_mod._bootstrap_state.done = False
try:
@@ -4788,9 +4789,200 @@ class TestLazyModuleAttributes:
# Prefixed value wins — canonical is overwritten.
assert os.environ["LANGSMITH_API_KEY"] == "lsv2_override"
# The original canonical value is saved for subprocess restoration.
assert (
config_mod._bootstrap_state.original_tracing_api_keys[
"LANGSMITH_API_KEY"
]
== "lsv2_original"
)
finally:
config_mod._bootstrap_state.done = original_done
config_mod._bootstrap_state.original_langsmith_project = original_ls
config_mod._bootstrap_state.original_tracing_api_keys = original_api_keys
@pytest.mark.parametrize("canonical", ["LANGSMITH_API_KEY", "LANGCHAIN_API_KEY"])
def test_restore_user_tracing_api_keys_recovers_original_value(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, canonical: str
) -> None:
"""Shell subprocess env gets the caller's original API key, not the override.
Parametrized over both members of `_TRACING_API_KEY_ENV_VARS` so the
`LANGCHAIN_API_KEY` alias is covered, not just the primary var.
"""
import os
import deepagents_code.config as config_mod
from deepagents_code.config import (
_ensure_bootstrap,
restore_user_tracing_api_keys,
)
monkeypatch.setattr(
"deepagents_code.model_config.DEFAULT_STATE_DIR", tmp_path / ".state"
)
original_done = config_mod._bootstrap_state.done
original_ls = config_mod._bootstrap_state.original_langsmith_project
original_tracing = dict(config_mod._bootstrap_state.original_tracing_env)
original_api_keys = dict(config_mod._bootstrap_state.original_tracing_api_keys)
config_mod._bootstrap_state.done = False
try:
# Isolate the key var under test so its sibling alias cannot mask it.
for var in config_mod._TRACING_API_KEY_ENV_VARS:
monkeypatch.delenv(var, raising=False)
monkeypatch.delenv(f"DEEPAGENTS_CODE_{var}", raising=False)
monkeypatch.setenv(canonical, "lsv2_original")
monkeypatch.setenv(f"DEEPAGENTS_CODE_{canonical}", "lsv2_override")
monkeypatch.setenv("LANGSMITH_TRACING", "true")
monkeypatch.delenv("DEEPAGENTS_CODE_LANGSMITH_PROJECT", raising=False)
with (
patch("deepagents_code.config._load_dotenv"),
patch(
"deepagents_code.project_utils.get_server_project_context",
return_value=None,
),
):
_ensure_bootstrap()
# Bootstrap overwrote the canonical key with the prefixed value.
assert os.environ[canonical] == "lsv2_override"
shell_env = os.environ.copy()
restore_user_tracing_api_keys(shell_env)
# Shell subprocesses get the caller's original key back.
assert shell_env[canonical] == "lsv2_original"
finally:
config_mod._bootstrap_state.done = original_done
config_mod._bootstrap_state.original_langsmith_project = original_ls
config_mod._bootstrap_state.original_tracing_env = original_tracing
config_mod._bootstrap_state.original_tracing_api_keys = original_api_keys
def test_restore_user_tracing_api_keys_drops_unset_key(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When the caller had no canonical key, restore removes it from shell env."""
import os
import deepagents_code.config as config_mod
from deepagents_code.config import (
_ensure_bootstrap,
restore_user_tracing_api_keys,
)
monkeypatch.setattr(
"deepagents_code.model_config.DEFAULT_STATE_DIR", tmp_path / ".state"
)
original_done = config_mod._bootstrap_state.done
original_ls = config_mod._bootstrap_state.original_langsmith_project
original_tracing = dict(config_mod._bootstrap_state.original_tracing_env)
original_api_keys = dict(config_mod._bootstrap_state.original_tracing_api_keys)
config_mod._bootstrap_state.done = False
try:
monkeypatch.delenv("LANGSMITH_API_KEY", raising=False)
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_API_KEY", "lsv2_prefixed")
monkeypatch.delenv("DEEPAGENTS_CODE_LANGSMITH_PROJECT", raising=False)
with (
patch("deepagents_code.config._load_dotenv"),
patch(
"deepagents_code.project_utils.get_server_project_context",
return_value=None,
),
):
_ensure_bootstrap()
# Bootstrap propagated the prefixed key to canonical.
assert os.environ["LANGSMITH_API_KEY"] == "lsv2_prefixed"
shell_env = os.environ.copy()
restore_user_tracing_api_keys(shell_env)
# Caller had no key — the propagated value is removed from shell env.
assert "LANGSMITH_API_KEY" not in shell_env
finally:
config_mod._bootstrap_state.done = original_done
config_mod._bootstrap_state.original_langsmith_project = original_ls
config_mod._bootstrap_state.original_tracing_env = original_tracing
config_mod._bootstrap_state.original_tracing_api_keys = original_api_keys
def test_restore_user_tracing_api_keys_pops_auth_stored_key(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A `/auth`-stored key is bridged onto the env but popped for shells.
The snapshot is captured *before* `apply_stored_langsmith_auth` bridges
the stored key onto `LANGSMITH_API_KEY`, so the caller's original is
`None` and restore pops the bridged key instead of leaking the agent's
stored credential into `execute` subprocesses. Locks in the
capture-before-bridge ordering that a bootstrap refactor could break.
"""
import os
import deepagents_code.config as config_mod
from deepagents_code import auth_store
from deepagents_code.config import (
_ensure_bootstrap,
restore_user_tracing_api_keys,
)
monkeypatch.setattr(
"deepagents_code.model_config.DEFAULT_STATE_DIR", tmp_path / ".state"
)
original_done = config_mod._bootstrap_state.done
original_ls = config_mod._bootstrap_state.original_langsmith_project
original_tracing = dict(config_mod._bootstrap_state.original_tracing_env)
original_api_keys = dict(config_mod._bootstrap_state.original_tracing_api_keys)
config_mod._bootstrap_state.done = False
try:
# No env or prefixed key — only a `/auth`-stored credential.
for var in (
"LANGSMITH_API_KEY",
"LANGCHAIN_API_KEY",
"DEEPAGENTS_CODE_LANGSMITH_API_KEY",
"DEEPAGENTS_CODE_LANGCHAIN_API_KEY",
"DEEPAGENTS_CODE_LANGSMITH_PROJECT",
):
monkeypatch.delenv(var, raising=False)
auth_store.set_stored_key("langsmith", "lsv2_stored")
with (
patch("deepagents_code.config._load_dotenv"),
patch(
"deepagents_code.project_utils.get_server_project_context",
return_value=None,
),
):
_ensure_bootstrap()
# Bootstrap bridged the stored key onto the canonical env var...
assert os.environ["LANGSMITH_API_KEY"] == "lsv2_stored"
# ...but the caller had none, so the snapshot (taken before the
# bridge) records it as absent.
assert (
config_mod._bootstrap_state.original_tracing_api_keys[
"LANGSMITH_API_KEY"
]
is None
)
shell_env = os.environ.copy()
restore_user_tracing_api_keys(shell_env)
# The agent's stored credential is not leaked into shell subprocesses.
assert "LANGSMITH_API_KEY" not in shell_env
finally:
config_mod._bootstrap_state.done = original_done
config_mod._bootstrap_state.original_langsmith_project = original_ls
config_mod._bootstrap_state.original_tracing_env = original_tracing
config_mod._bootstrap_state.original_tracing_api_keys = original_api_keys
def test_bootstrap_warns_on_conflicting_override(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
@@ -4803,6 +4995,7 @@ class TestLazyModuleAttributes:
original_done = config_mod._bootstrap_state.done
original_ls = config_mod._bootstrap_state.original_langsmith_project
original_api_keys = dict(config_mod._bootstrap_state.original_tracing_api_keys)
config_mod._bootstrap_state.done = False
try:
@@ -4833,6 +5026,7 @@ class TestLazyModuleAttributes:
finally:
config_mod._bootstrap_state.done = original_done
config_mod._bootstrap_state.original_langsmith_project = original_ls
config_mod._bootstrap_state.original_tracing_api_keys = original_api_keys
def test_bootstrap_suppresses_override_warning(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
@@ -4846,6 +5040,7 @@ class TestLazyModuleAttributes:
original_done = config_mod._bootstrap_state.done
original_ls = config_mod._bootstrap_state.original_langsmith_project
original_api_keys = dict(config_mod._bootstrap_state.original_tracing_api_keys)
config_mod._bootstrap_state.done = False
try:
@@ -4874,6 +5069,7 @@ class TestLazyModuleAttributes:
finally:
config_mod._bootstrap_state.done = original_done
config_mod._bootstrap_state.original_langsmith_project = original_ls
config_mod._bootstrap_state.original_tracing_api_keys = original_api_keys
def test_bootstrap_no_warning_when_values_match(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
@@ -4887,6 +5083,7 @@ class TestLazyModuleAttributes:
original_done = config_mod._bootstrap_state.done
original_ls = config_mod._bootstrap_state.original_langsmith_project
original_api_keys = dict(config_mod._bootstrap_state.original_tracing_api_keys)
config_mod._bootstrap_state.done = False
try:
@@ -4917,6 +5114,7 @@ class TestLazyModuleAttributes:
finally:
config_mod._bootstrap_state.done = original_done
config_mod._bootstrap_state.original_langsmith_project = original_ls
config_mod._bootstrap_state.original_tracing_api_keys = original_api_keys
def test_bootstrap_propagates_empty_string(
self, monkeypatch: pytest.MonkeyPatch
@@ -5067,6 +5265,7 @@ class TestLazyModuleAttributes:
original_done = config_mod._bootstrap_state.done
original_ls = config_mod._bootstrap_state.original_langsmith_project
original_tracing = dict(config_mod._bootstrap_state.original_tracing_env)
original_api_keys = dict(config_mod._bootstrap_state.original_tracing_api_keys)
config_mod._bootstrap_state.done = False
try:
@@ -5107,6 +5306,7 @@ class TestLazyModuleAttributes:
config_mod._bootstrap_state.done = original_done
config_mod._bootstrap_state.original_langsmith_project = original_ls
config_mod._bootstrap_state.original_tracing_env = original_tracing
config_mod._bootstrap_state.original_tracing_api_keys = original_api_keys
def test_bootstrap_prefixed_langsmith_key_wins_over_stored_key(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -5125,6 +5325,7 @@ class TestLazyModuleAttributes:
original_done = config_mod._bootstrap_state.done
original_ls = config_mod._bootstrap_state.original_langsmith_project
original_tracing = dict(config_mod._bootstrap_state.original_tracing_env)
original_api_keys = dict(config_mod._bootstrap_state.original_tracing_api_keys)
config_mod._bootstrap_state.done = False
try:
@@ -5150,6 +5351,7 @@ class TestLazyModuleAttributes:
config_mod._bootstrap_state.done = original_done
config_mod._bootstrap_state.original_langsmith_project = original_ls
config_mod._bootstrap_state.original_tracing_env = original_tracing
config_mod._bootstrap_state.original_tracing_api_keys = original_api_keys
def test_scoped_tracing_opt_out_restores_user_tracing_for_shell_env(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
@@ -5168,6 +5370,7 @@ class TestLazyModuleAttributes:
original_done = config_mod._bootstrap_state.done
original_ls = config_mod._bootstrap_state.original_langsmith_project
original_tracing = dict(config_mod._bootstrap_state.original_tracing_env)
original_api_keys = dict(config_mod._bootstrap_state.original_tracing_api_keys)
config_mod._bootstrap_state.done = False
try:
@@ -5199,6 +5402,7 @@ class TestLazyModuleAttributes:
config_mod._bootstrap_state.done = original_done
config_mod._bootstrap_state.original_langsmith_project = original_ls
config_mod._bootstrap_state.original_tracing_env = original_tracing
config_mod._bootstrap_state.original_tracing_api_keys = original_api_keys
class TestApplyDefaultLangsmithProject: