mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user