fix(code): redact LangSmith trace secrets by default (#4356)

`dcode` now opts its LangSmith tracing client into the SDK secret
anonymizer before agent traces are uploaded. Redaction defaults on, can
be disabled explicitly, and fails closed by turning tracing off if the
redacting client cannot be installed.

<img width="609" height="95" alt="Screenshot 5"
src="https://github.com/user-attachments/assets/6a96f4d2-b115-41eb-9df0-ad8e7bf652c9"
/>

<img width="1013" height="390" alt="Screenshot 8"
src="https://github.com/user-attachments/assets/c9251d0d-f7a9-4251-b811-76be9d1d0beb"
/>

## Changes
- Add the `tracing.langsmith_redact` config option and
`DEEPAGENTS_CODE_LANGSMITH_REDACT` env var, defaulting to enabled. This
controls whether `dcode` installs the LangSmith SDK
[`Client(anonymizer=...)`](https://docs.langchain.com/langsmith/mask-inputs-outputs#rule-based-masking-of-inputs-and-outputs)
option with the curated
[`create_secret_anonymizer()`](https://reference.langchain.com/python/langsmith/anonymizer/create_secret_anonymizer)
preset.
- In practice, users get secret redaction without changing their setup.
- Users who need to opt out for controlled local debugging can set
`DEEPAGENTS_CODE_LANGSMITH_REDACT=false` or the matching `config.toml`
value.
- Install a `Client(anonymizer=create_secret_anonymizer())` in the
process that emits the main `dcode` agent traces.
- `dcode` runs the terminal UI in one process and the agent graph in a
LangGraph server subprocess.
- The server subprocess is the important path for this rollout: it runs
the agent graph, so it emits the main LangSmith traces for model calls,
tool calls, and agent turns.
- The app process still configures the redacting client during bootstrap
and `/auth` changes as a defensive consistency measure for any
in-process SDK tracing, but that does not replace the server-side setup.
- Treat redaction setup failures as fail-closed by disabling tracing
rather than allowing unredacted trace uploads.
- In practice, if `dcode` sees tracing is active but cannot create or
install the redacting LangSmith client, it calls
`langsmith.configure(enabled=False)`.
- If even that fails, it clears the tracing-enable environment variables
as a last-resort barrier.
- The user loses tracing for that run instead of uploading prompts, tool
I/O, shell commands, file contents, metadata, or model outputs without
secret redaction.
- Preserve support for LangSmith profile credentials, custom endpoints,
replica tracing, and legacy `LANGCHAIN_API_KEY` while configuring the
redacting client.
- For example, a user with only `LANGSMITH_PROFILE` /
`LANGSMITH_CONFIG_FILE` still gets a redacting client without forwarding
an env API key.
- For example, a local or self-hosted endpoint set with
`LANGSMITH_ENDPOINT=http://localhost:1984` is passed through as
`api_url` on the redacting client.
- For example, `LANGSMITH_RUNS_ENDPOINTS` replica uploads also trigger
redaction setup, even without a top-level API key.
- For example,
`DEEPAGENTS_CODE_LANGSMITH_REPLICA_PROJECTS=personal-traces` still
routes through the existing replica path; the primary client used for
the run is now redacting.
- For example, environments that still provide `LANGCHAIN_API_KEY`
instead of `LANGSMITH_API_KEY` continue to configure tracing with that
key.
- Cover the default, opt-out, keyless endpoint, profile-only,
fail-closed, and server-startup paths in unit tests.
  - Default: redaction resolves to enabled with no user setting.
- Opt-out: `DEEPAGENTS_CODE_LANGSMITH_REDACT=false` and `[tracing]
langsmith_redact = false` skip client installation.
- Keyless endpoint: tracing with a custom endpoint but no API key still
installs an anonymizer.
- Profile-only: profile credentials still install an anonymizer without
an env key.
- Fail-closed: setup errors disable tracing, and the fallback clears
tracing env vars if SDK disable also fails.
- Server-startup: the LangGraph server graph factory calls the redaction
setup before constructing the agent graph.
This commit is contained in:
Mason Daugherty
2026-06-29 01:21:19 -04:00
committed by GitHub
parent 92272baf2b
commit 5e01fec72d
7 changed files with 565 additions and 10 deletions
+3
View File
@@ -122,6 +122,9 @@ KITTY_KEYBOARD = "DEEPAGENTS_CODE_KITTY_KEYBOARD"
LANGSMITH_PROJECT = "DEEPAGENTS_CODE_LANGSMITH_PROJECT"
"""Override LangSmith project name for agent traces."""
LANGSMITH_REDACT = "DEEPAGENTS_CODE_LANGSMITH_REDACT"
"""Toggle LangSmith secret redaction for agent traces (defaults to on)."""
LANGSMITH_REPLICA_PROJECTS = "DEEPAGENTS_CODE_LANGSMITH_REPLICA_PROJECTS"
"""Comma-separated LangSmith project names to *also* write agent traces to.
+177 -8
View File
@@ -365,6 +365,12 @@ _TRACING_API_KEY_ENV_VARS = ("LANGSMITH_API_KEY", "LANGCHAIN_API_KEY")
_TRACING_ENDPOINT_ENV_VARS = ("LANGSMITH_ENDPOINT", "LANGCHAIN_ENDPOINT")
"""Env vars that point tracing at a non-default (self-hosted/proxied) endpoint."""
_TRACING_RUNS_ENDPOINTS_ENV_VARS = (
"LANGSMITH_RUNS_ENDPOINTS",
"LANGCHAIN_RUNS_ENDPOINTS",
)
"""Env vars the LangSmith SDK parses into replica trace ingestion targets."""
class _LangSmithProfileConfig(Protocol):
"""Subset of LangSmith profile client config fields used at bootstrap."""
@@ -522,21 +528,26 @@ def _disable_orphaned_tracing() -> None:
resolvable, unset the flags so tracing never starts.
A custom endpoint (`LANGSMITH_ENDPOINT`/`LANGCHAIN_ENDPOINT`, or a profile
`api_url`) signals a self-hosted or proxied LangSmith that may ingest without
an API key, so an explicitly configured endpoint is trusted and left alone
rather than risk disabling a working keyless setup. The SDK loggers are
quieted separately by `_quiet_sdk_tracing_logging`, so any residual ingest
errors stay off the TUI.
`api_url`) or replica endpoints (`LANGSMITH_RUNS_ENDPOINTS`/
`LANGCHAIN_RUNS_ENDPOINTS`) signal tracing can upload without a top-level
API key, so those explicitly configured targets are trusted and left alone.
The SDK loggers are quieted separately by `_quiet_sdk_tracing_logging`, so
any residual ingest errors stay off the TUI.
"""
global _orphaned_tracing_disabled_notice # noqa: PLW0603
if not _tracing_enabled():
return
env = dict(os.environ)
has_custom_endpoint = any(
(os.environ.get(var) or "").strip() for var in _TRACING_ENDPOINT_ENV_VARS
(env.get(var) or "").strip() for var in _TRACING_ENDPOINT_ENV_VARS
)
if has_custom_endpoint or _has_langsmith_profile_custom_endpoint():
if (
has_custom_endpoint
or _has_langsmith_profile_custom_endpoint()
or _has_langsmith_runs_endpoints_from(env)
):
return
has_key = any(
@@ -576,7 +587,7 @@ def _apply_default_langsmith_project() -> None:
def apply_stored_langsmith_auth(*, replace_project: bool = False) -> None:
"""Apply a `/auth`-stored LangSmith key and tracing settings now.
"""Apply a `/auth`-stored LangSmith key, tracing, and redaction now.
Args:
replace_project: Whether the stored LangSmith project should replace
@@ -591,6 +602,7 @@ def apply_stored_langsmith_auth(*, replace_project: bool = False) -> None:
_apply_stored_langsmith_tracing(replace_project=replace_project)
_disable_orphaned_tracing()
_apply_default_langsmith_project()
configure_langsmith_secret_redaction()
def _apply_stored_langsmith_tracing(*, replace_project: bool = False) -> None:
@@ -2621,6 +2633,107 @@ def get_langsmith_project_name() -> str | None:
)
def is_langsmith_redaction_enabled() -> bool:
"""Return whether LangSmith secret redaction is enabled for agent traces."""
from deepagents_code.config_manifest import (
get_option,
load_config_toml,
resolve_scalar,
)
option = get_option("tracing.langsmith_redact")
if option is None:
return True
value, _ = resolve_scalar(option, toml_data=load_config_toml())
return bool(value)
def configure_langsmith_secret_redaction() -> bool:
"""Install the LangSmith SDK secret anonymizer for active agent tracing.
This is a fail-closed security control: when redaction is requested but the
redacting client cannot be installed, tracing is disabled rather than risk
uploading unredacted secrets to LangSmith.
Returns:
`True` when a redacting LangSmith client was configured, `False` when
tracing is inactive, has no upload target, redaction is disabled, or the
redacting client could not be installed (tracing is then disabled).
"""
from deepagents_code._env_vars import LANGSMITH_REDACT
env = dict(os.environ)
# Cheap env-var checks first so the common (tracing-off) startup path skips
# the TOML read in `is_langsmith_redaction_enabled`.
if not (_tracing_enabled_from(env) and _tracing_can_upload_from(env)):
return False
if not is_langsmith_redaction_enabled():
logger.warning(
"LangSmith tracing is active but secret redaction is disabled via "
"%s; secrets may be uploaded to traces unredacted.",
LANGSMITH_REDACT,
)
return False
try:
from langsmith import Client, configure
from langsmith.anonymizer import create_secret_anonymizer
api_key = _resolve_env_var_from(
env,
"LANGSMITH_API_KEY",
) or _resolve_env_var_from(env, "LANGCHAIN_API_KEY")
api_url = _tracing_endpoint_from(env)
kwargs: dict[str, Any] = {"anonymizer": create_secret_anonymizer()}
if api_key:
kwargs["api_key"] = api_key
if api_url:
kwargs["api_url"] = api_url
# Reinstall the redacting client on every call rather than caching it:
# callers such as `/auth` re-authentication may rotate credentials, and
# a cached client could leave a stale or non-redacting client in place —
# a fail-open risk this control exists to prevent.
configure(client=Client(**kwargs))
except Exception:
logger.exception(
"Failed to install LangSmith secret redaction; disabling tracing so "
"unredacted secrets are not uploaded.",
)
_fail_closed_disable_tracing()
return False
logger.info("LangSmith secret redaction enabled for agent traces.")
return True
def _fail_closed_disable_tracing() -> None:
"""Best-effort disable LangSmith tracing after a redaction setup failure.
Tries the SDK's global tracing switch first. If even that call fails, the
canonical tracing-enable env vars (and their `DEEPAGENTS_CODE_`-prefixed
forms) are cleared as a last-resort barrier: the LangChain tracer consults
the global switch first but falls back to these env vars, so removing them
prevents an unredacted upload when the global switch could not be set.
"""
try:
from langsmith import configure
configure(enabled=False)
except Exception:
logger.exception(
"Failed to disable LangSmith tracing via the SDK after a redaction "
"setup failure; clearing tracing env vars as a fallback.",
)
else:
return
from deepagents_code.model_config import _ENV_PREFIX
for var in _TRACING_ENABLE_ENV_VARS:
os.environ.pop(var, None)
os.environ.pop(f"{_ENV_PREFIX}{var}", None)
def get_langsmith_replica_projects() -> list[str]:
"""Extra LangSmith project names to dual-write agent traces to.
@@ -2790,6 +2903,62 @@ def _tracing_has_credentials_from(env: dict[str, str]) -> bool:
return has_key or _has_langsmith_profile_credentials(env)
def _has_langsmith_runs_endpoints_from(env: dict[str, str]) -> bool:
"""Return whether replica trace ingestion targets are configured.
Mirrors the LangSmith SDK's accepted `LANGSMITH_RUNS_ENDPOINTS` shapes: a
JSON list of `{"api_url": "...", "api_key": "..."}` objects, or a JSON
object mapping URL to API key. Invalid entries are ignored because the SDK
ignores them too.
Args:
env: Environment mapping to read.
"""
raw = next(
(
env[var]
for var in _TRACING_RUNS_ENDPOINTS_ENV_VARS
if (env.get(var) or "").strip()
),
None,
)
if raw is None:
return False
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return False
if isinstance(parsed, list):
return any(
isinstance(item, dict)
and isinstance(item.get("api_url"), str)
and isinstance(item.get("api_key"), str)
for item in parsed
)
if isinstance(parsed, dict):
return any(isinstance(value, str) for value in parsed.values())
return False
def _tracing_can_upload_from(env: dict[str, str]) -> bool:
"""Return whether tracing has credentials or an ingestion endpoint.
Custom and replica endpoints are supported as keyless ingestion targets, so
redaction must be configured whenever tracing could still upload without an
API key.
Args:
env: Environment mapping to read.
"""
return (
_tracing_has_credentials_from(env)
or _tracing_endpoint_from(env) is not None
or _has_langsmith_runs_endpoints_from(env)
)
def _tracing_endpoint_from(env: dict[str, str]) -> str | None:
"""Return a custom tracing endpoint (env or active profile), if configured.
@@ -886,6 +886,15 @@ _STATIC_OPTIONS: tuple[ConfigOption, ...] = (
fallback_env_vars=("LANGSMITH_PROJECT",),
settings_field="deepagents_langchain_project",
),
ConfigOption(
key="tracing.langsmith_redact",
group="Tracing",
summary="Redact detected secrets from LangSmith agent traces before upload.",
kind=OptionKind.BOOL,
default=True,
env_var=_env_vars.LANGSMITH_REDACT,
toml_keys=("tracing", "langsmith_redact"),
),
ConfigOption(
key="tracing.user_id",
group="Tracing",
+6 -1
View File
@@ -158,10 +158,15 @@ async def _make_graph() -> Any: # noqa: ANN401
project_context = get_server_project_context()
from deepagents_code.agent import create_cli_agent, load_async_subagents
from deepagents_code.config import create_model, settings
from deepagents_code.config import (
configure_langsmith_secret_redaction,
create_model,
settings,
)
if project_context is not None:
settings.reload_from_environment(start_path=project_context.user_cwd)
configure_langsmith_secret_redaction()
# Offload to a worker thread: `create_model` does blocking disk IO for some
# providers (e.g. the `openai_codex` token store currently acquires a file
+3
View File
@@ -89,8 +89,11 @@ def _clear_langsmith_env(monkeypatch: pytest.MonkeyPatch) -> None:
"LANGCHAIN_API_KEY",
"LANGSMITH_TRACING",
"LANGCHAIN_TRACING_V2",
"LANGSMITH_ENDPOINT",
"LANGCHAIN_ENDPOINT",
"LANGSMITH_PROJECT",
"DEEPAGENTS_CODE_LANGSMITH_PROJECT",
"DEEPAGENTS_CODE_LANGSMITH_REDACT",
"DEEPAGENTS_CODE_LANGSMITH_API_KEY",
"DEEPAGENTS_CODE_LANGCHAIN_API_KEY",
"DEEPAGENTS_CODE_LANGSMITH_TRACING",
+363 -1
View File
@@ -33,6 +33,7 @@ from deepagents_code.config import (
_resolve_retry_param_name,
apply_stored_langsmith_auth,
build_langsmith_thread_url,
configure_langsmith_secret_redaction,
consume_orphaned_tracing_disabled_notice,
create_model,
detect_mode_prefix,
@@ -40,6 +41,7 @@ from deepagents_code.config import (
fetch_langsmith_project_url,
fetch_langsmith_project_url_or_raise,
get_langsmith_project_name,
is_langsmith_redaction_enabled,
newline_shortcut,
parse_shell_allow_list,
reset_langsmith_url_cache,
@@ -1925,6 +1927,337 @@ class TestGetLangsmithProjectName:
)
class TestLangsmithSecretRedaction:
"""Tests for LangSmith trace secret redaction configuration."""
def test_redaction_enabled_by_default(self) -> None:
"""LangSmith trace redaction defaults to enabled."""
with patch("deepagents_code.config_manifest.load_config_toml", return_value={}):
assert is_langsmith_redaction_enabled() is True
def test_redaction_can_be_disabled_by_env(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The redaction env var can opt out for local debugging."""
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_REDACT", "false")
with patch("deepagents_code.config_manifest.load_config_toml", return_value={}):
assert is_langsmith_redaction_enabled() is False
def test_configures_langsmith_client_with_secret_anonymizer(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Active tracing installs a client whose anonymizer scrubs secrets."""
client = object()
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_API_KEY", "lsv2_test")
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_TRACING", "true")
# Exercise the real `create_secret_anonymizer` (network-free) so the test
# fails if the installed anonymizer does not actually redact secrets.
with (
patch("deepagents_code.config_manifest.load_config_toml", return_value={}),
patch("langsmith.Client", return_value=client) as client_cls,
patch("langsmith.configure") as configure,
):
assert configure_langsmith_secret_redaction() is True
configure.assert_called_once_with(client=client)
_, kwargs = client_cls.call_args
assert kwargs["api_key"] == "lsv2_test"
assert "api_url" not in kwargs
secret = "sk-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijkl"
redacted = str(kwargs["anonymizer"]([{"text": f"key={secret}"}]))
assert secret not in redacted
assert "[SECRET_DETECTED]" in redacted
def test_skips_client_configuration_when_redaction_disabled(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Opting out leaves the LangSmith client untouched."""
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_API_KEY", "lsv2_test")
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_TRACING", "true")
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_REDACT", "false")
with (
patch("deepagents_code.config_manifest.load_config_toml", return_value={}),
patch("langsmith.Client") as client_cls,
patch("langsmith.configure") as configure,
):
assert configure_langsmith_secret_redaction() is False
client_cls.assert_not_called()
configure.assert_not_called()
def test_skips_when_tracing_disabled(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Redaction is skipped when tracing is inactive, even if credentialed."""
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_API_KEY", "lsv2_test")
with (
patch("deepagents_code.config_manifest.load_config_toml", return_value={}),
patch("langsmith.Client") as client_cls,
patch("langsmith.configure") as configure,
):
assert configure_langsmith_secret_redaction() is False
client_cls.assert_not_called()
configure.assert_not_called()
def test_skips_when_no_credentials(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Redaction is skipped when tracing is active but uncredentialed."""
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_TRACING", "true")
with (
patch("deepagents_code.config_manifest.load_config_toml", return_value={}),
# No env key is set; ignore any LangSmith profile on the dev machine
# so the no-credentials branch is exercised hermetically.
patch(
"deepagents_code.config._has_langsmith_profile_credentials",
return_value=False,
),
patch("langsmith.Client") as client_cls,
patch("langsmith.configure") as configure,
):
assert configure_langsmith_secret_redaction() is False
client_cls.assert_not_called()
configure.assert_not_called()
def test_falls_back_to_langchain_api_key(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The legacy LANGCHAIN_API_KEY is used when LANGSMITH_API_KEY is absent."""
monkeypatch.setenv("DEEPAGENTS_CODE_LANGCHAIN_API_KEY", "lsv2_legacy")
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_TRACING", "true")
with (
patch("deepagents_code.config_manifest.load_config_toml", return_value={}),
patch("langsmith.Client", return_value=object()) as client_cls,
patch("langsmith.configure"),
):
assert configure_langsmith_secret_redaction() is True
_, kwargs = client_cls.call_args
assert kwargs["api_key"] == "lsv2_legacy"
def test_forwards_custom_endpoint_as_api_url(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A custom tracing endpoint is forwarded to the client as `api_url`."""
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_API_KEY", "lsv2_test")
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_TRACING", "true")
monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://eu.smith.example.com")
with (
patch("deepagents_code.config_manifest.load_config_toml", return_value={}),
patch("langsmith.Client", return_value=object()) as client_cls,
patch("langsmith.configure"),
):
assert configure_langsmith_secret_redaction() is True
_, kwargs = client_cls.call_args
assert kwargs["api_url"] == "https://eu.smith.example.com"
def test_configures_client_for_keyless_custom_endpoint(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Keyless custom endpoints still get the secret anonymizer installed."""
client = object()
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_TRACING", "true")
monkeypatch.setenv("LANGSMITH_ENDPOINT", "http://localhost:1984")
with (
patch("deepagents_code.config_manifest.load_config_toml", return_value={}),
patch(
"deepagents_code.config._has_langsmith_profile_credentials",
return_value=False,
),
patch("langsmith.Client", return_value=client) as client_cls,
patch("langsmith.configure") as configure,
):
assert configure_langsmith_secret_redaction() is True
configure.assert_called_once_with(client=client)
_, kwargs = client_cls.call_args
assert "api_key" not in kwargs
assert kwargs["api_url"] == "http://localhost:1984"
assert "anonymizer" in kwargs
def test_configures_client_for_runs_endpoints(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Replica trace endpoints still get the secret anonymizer installed."""
client = object()
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_TRACING", "true")
monkeypatch.setenv(
"LANGSMITH_RUNS_ENDPOINTS",
'[{"api_url":"https://replica.example.com","api_key":"lsv2_replica"}]',
)
with (
patch("deepagents_code.config_manifest.load_config_toml", return_value={}),
patch(
"deepagents_code.config._has_langsmith_profile_credentials",
return_value=False,
),
patch("langsmith.Client", return_value=client) as client_cls,
patch("langsmith.configure") as configure,
):
assert configure_langsmith_secret_redaction() is True
configure.assert_called_once_with(client=client)
_, kwargs = client_cls.call_args
assert "api_key" not in kwargs
assert "api_url" not in kwargs
assert "anonymizer" in kwargs
@pytest.mark.parametrize(
"value",
["[]", '[{"api_url":"https://replica.example.com"}]', "not json"],
)
def test_skips_invalid_runs_endpoints(
self,
value: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Only valid replica endpoint configs count as upload targets."""
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_TRACING", "true")
monkeypatch.setenv("LANGSMITH_RUNS_ENDPOINTS", value)
with (
patch("deepagents_code.config_manifest.load_config_toml", return_value={}),
patch(
"deepagents_code.config._has_langsmith_profile_credentials",
return_value=False,
),
patch("langsmith.Client") as client_cls,
patch("langsmith.configure") as configure,
):
assert configure_langsmith_secret_redaction() is False
client_cls.assert_not_called()
configure.assert_not_called()
def test_fails_closed_by_disabling_tracing_on_setup_error(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A redaction setup failure disables tracing to avoid leaking secrets."""
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_API_KEY", "lsv2_test")
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_TRACING", "true")
with (
patch("deepagents_code.config_manifest.load_config_toml", return_value={}),
patch("langsmith.Client", side_effect=RuntimeError("boom")),
patch("langsmith.configure") as configure,
):
assert configure_langsmith_secret_redaction() is False
configure.assert_called_once_with(enabled=False)
def test_configures_client_with_profile_only_credentials(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Profile-only credentials (no env key) still install the anonymizer."""
client = object()
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_TRACING", "true")
with (
patch("deepagents_code.config_manifest.load_config_toml", return_value={}),
# Credentials come only from an active LangSmith profile, not the env.
# No endpoint either, so the profile credentials are the sole reason
# the upload gate passes. The SDK client self-resolves that profile
# auth when no key is forwarded.
patch(
"deepagents_code.config._has_langsmith_profile_credentials",
return_value=True,
),
patch("deepagents_code.config._tracing_endpoint_from", return_value=None),
patch("langsmith.Client", return_value=client) as client_cls,
patch("langsmith.configure") as configure,
):
assert configure_langsmith_secret_redaction() is True
configure.assert_called_once_with(client=client)
_, kwargs = client_cls.call_args
assert "api_key" not in kwargs
assert "anonymizer" in kwargs
def test_redaction_can_be_disabled_by_toml(self) -> None:
"""A `[tracing] langsmith_redact = false` in config.toml opts out."""
with patch(
"deepagents_code.config_manifest.load_config_toml",
return_value={"tracing": {"langsmith_redact": False}},
):
assert is_langsmith_redaction_enabled() is False
def test_env_redaction_toggle_overrides_toml(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The redaction env var takes precedence over a conflicting config.toml."""
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_REDACT", "true")
with patch(
"deepagents_code.config_manifest.load_config_toml",
return_value={"tracing": {"langsmith_redact": False}},
):
assert is_langsmith_redaction_enabled() is True
def test_fail_closed_clears_env_when_sdk_disable_also_fails(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When the SDK cannot disable tracing, enable env vars are cleared.
This is the worst-case fail-closed path: redaction setup raised and the
SDK's `configure(enabled=False)` raised too, so the only remaining barrier
is removing the env vars the LangChain tracer falls back to.
"""
import os
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
monkeypatch.setenv("LANGSMITH_TRACING", "true")
with (
patch("deepagents_code.config_manifest.load_config_toml", return_value={}),
patch("langsmith.Client", side_effect=RuntimeError("boom")),
patch("langsmith.configure", side_effect=RuntimeError("nope")),
):
assert configure_langsmith_secret_redaction() is False
assert "LANGSMITH_TRACING" not in os.environ
def test_reconfigures_on_each_call(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Each call reinstalls the redacting client (no fail-open caching)."""
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_API_KEY", "lsv2_test")
monkeypatch.setenv("DEEPAGENTS_CODE_LANGSMITH_TRACING", "true")
with (
patch("deepagents_code.config_manifest.load_config_toml", return_value={}),
patch("langsmith.Client", return_value=object()),
patch("langsmith.configure") as configure,
):
assert configure_langsmith_secret_redaction() is True
assert configure_langsmith_secret_redaction() is True
assert configure.call_count == 2
class TestDisableOrphanedTracing:
"""Tests for _disable_orphaned_tracing()."""
@@ -1937,6 +2270,8 @@ class TestDisableOrphanedTracing:
"LANGCHAIN_API_KEY",
"LANGSMITH_ENDPOINT",
"LANGCHAIN_ENDPOINT",
"LANGSMITH_RUNS_ENDPOINTS",
"LANGCHAIN_RUNS_ENDPOINTS",
"LANGSMITH_CONFIG_FILE",
"LANGSMITH_PROFILE",
)
@@ -2006,6 +2341,20 @@ class TestDisableOrphanedTracing:
# Nothing was disabled, so no startup notice should be staged.
assert consume_orphaned_tracing_disabled_notice() is None
def test_preserves_tracing_when_runs_endpoints_set(self) -> None:
"""Replica endpoints are trusted upload targets even without a top-level key."""
env = self._clean_env()
env["LANGCHAIN_TRACING_V2"] = "true"
env["LANGSMITH_RUNS_ENDPOINTS"] = (
'[{"api_url":"https://replica.example.com","api_key":"lsv2_replica"}]'
)
with patch.dict("os.environ", env, clear=False):
_disable_orphaned_tracing()
import os
assert os.environ["LANGCHAIN_TRACING_V2"] == "true"
assert consume_orphaned_tracing_disabled_notice() is None
def test_preserves_tracing_when_profile_custom_endpoint_set(
self, tmp_path: Path
) -> None:
@@ -2276,12 +2625,25 @@ class TestApplyStoredLangSmithTracing:
from deepagents_code import auth_store
from deepagents_code.config_manifest import LANGSMITH_PROJECT_DEFAULT
redaction_env: list[dict[str, str]] = []
def capture_redaction_env() -> bool:
redaction_env.append(dict(os.environ))
return True
monkeypatch.setenv("LANGSMITH_PROJECT", "old-project")
monkeypatch.delenv("LANGSMITH_TRACING", raising=False)
auth_store.set_stored_key("langsmith", "lsv2_test")
apply_stored_langsmith_auth(replace_project=True)
with patch(
"deepagents_code.config.configure_langsmith_secret_redaction",
side_effect=capture_redaction_env,
) as configure_redaction:
apply_stored_langsmith_auth(replace_project=True)
assert os.environ["LANGSMITH_PROJECT"] == LANGSMITH_PROJECT_DEFAULT
assert os.environ["LANGSMITH_TRACING"] == "true"
configure_redaction.assert_called_once_with()
assert redaction_env[0]["LANGSMITH_PROJECT"] == LANGSMITH_PROJECT_DEFAULT
assert redaction_env[0]["LANGSMITH_TRACING"] == "true"
def test_corrupt_store_warns_and_leaves_env_untouched(
self,
@@ -103,8 +103,10 @@ class TestServerGraph:
model=model_obj,
apply_to_settings=MagicMock(),
)
configure_redaction = MagicMock()
config_module = _module_with_attrs(
"deepagents_code.config",
configure_langsmith_secret_redaction=configure_redaction,
create_model=MagicMock(side_effect=create_model_side_effect),
settings=SimpleNamespace(
has_tavily=False,
@@ -169,6 +171,7 @@ class TestServerGraph:
):
assert await module.make_graph() is graph_obj
configure_redaction.assert_called_once_with()
resolve_mcp_tools.assert_awaited_once()
assert warm_import_thread_ids
assert warm_import_thread_ids[0] != loop_thread_id
@@ -273,6 +276,7 @@ class TestServerGraph:
)
config_module = _module_with_attrs(
"deepagents_code.config",
configure_langsmith_secret_redaction=MagicMock(),
create_model=MagicMock(
return_value=SimpleNamespace(
model=model_obj,