mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
fix(code): surface /auth-stored credentials in config show/get (#4258)
`dcode config show`/`config get` now report API keys stored via `/auth`, labeled with a `stored` source. --- `dcode config show` and `config get` resolved credentials only through environment variables and `config.toml`, so a key entered via `/auth` (persisted in `auth.json`) rendered as `not configured` even though the agent actually sends it at runtime. This made a real 401-causing trap invisible: stored keys take precedence over env vars (`resolve_provider_credential`), so a stale stored key silently overrides a known-good `ANTHROPIC_API_KEY`, and the diagnostic command gave no hint. `_resolve` now consults the credential store first for `Credentials` options, mirroring the runtime precedence, and reports a `stored` source so the effective credential — and any precedence surprise — is visible. A corrupt store is logged and treated as absent so the command degrades to env/`config.toml` resolution instead of erroring. Made by [Open SWE](https://openswe.vercel.app/agents/cb055d71-831a-f788-02a0-308c83974f4d) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -144,7 +144,11 @@ def _read_raw() -> dict | None:
|
||||
"Check the file permissions on the parent directory."
|
||||
)
|
||||
raise RuntimeError(msg) from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
# `UnicodeDecodeError` (a `ValueError`, not an `OSError`) escapes the
|
||||
# handler above when the file holds non-UTF-8 bytes; treat a decode
|
||||
# failure as corruption so callers get the same `RuntimeError` hint
|
||||
# instead of an unhandled traceback.
|
||||
msg = (
|
||||
f"Failed to parse credential file {path}: {exc}. "
|
||||
"Delete the file and re-add credentials via /auth if it is corrupt."
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
`config list` prints the static manifest (every tunable option, its type,
|
||||
default, and where it can be set). `config show` resolves each option against
|
||||
the live environment and `config.toml`, reporting the effective value and which
|
||||
source provided it. `config get <key>` does the same for a single option.
|
||||
`config path` prints the on-disk config locations.
|
||||
the app credential store (for credentials), the live environment, and
|
||||
`config.toml`, reporting the effective value and which source provided it.
|
||||
`config get <key>` does the same for a single option. `config path` prints the
|
||||
on-disk config locations.
|
||||
|
||||
Secret-flagged options (API keys and other credentials) are never printed by
|
||||
value — `config show`/`config get` report only whether they are set and from
|
||||
@@ -21,7 +22,9 @@ from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from deepagents_code.output import write_json
|
||||
@@ -130,22 +133,121 @@ def setup_config_parser(
|
||||
# --- Resolution -------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve(option: ConfigOption, toml_data: dict[str, Any]) -> tuple[bool, str, Any]:
|
||||
"""Resolve an option via the shared manifest resolver.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _StoredCredentialView:
|
||||
"""Snapshot of the `/auth` credential store for one command invocation.
|
||||
|
||||
Delegates to `config_manifest.resolve_scalar` so `config show`/`get`
|
||||
report exactly what the runtime reads.
|
||||
Built once per `config show`/`get` so the store is read and parsed a single
|
||||
time rather than once per credential option.
|
||||
"""
|
||||
|
||||
keys: dict[str, str] = field(repr=False)
|
||||
"""Provider/service name to stored API key, for `api_key` entries only.
|
||||
|
||||
`repr=False` keeps the secret key values out of the dataclass repr, so an
|
||||
accidental log/`%r` of the view can't leak them.
|
||||
"""
|
||||
|
||||
error: str | None = None
|
||||
"""Secret-free remediation message when the store was unreadable, else `None`.
|
||||
|
||||
Never holds the underlying exception text (which can echo file bytes) or any
|
||||
key value.
|
||||
"""
|
||||
|
||||
|
||||
_STORE_UNREADABLE_HINT = (
|
||||
"credential store unreadable; showing env/config.toml resolution instead. "
|
||||
"Re-add keys via /auth (or delete a corrupt auth.json)."
|
||||
)
|
||||
"""Fixed, secret-free notice surfaced when `auth.json` cannot be read."""
|
||||
|
||||
|
||||
def _load_stored_credentials() -> _StoredCredentialView:
|
||||
"""Read every `/auth`-stored API key once, degrading a corrupt store to empty.
|
||||
|
||||
Reading the store a single time (rather than once per credential option)
|
||||
keeps `config show` to one `auth.json` parse and one warning. A corrupt
|
||||
store is logged once and reported via the returned `error`, so resolution
|
||||
degrades to env/`config.toml` instead of failing the command — and the
|
||||
corruption stays visible in the output rather than masquerading as an empty
|
||||
store.
|
||||
|
||||
Returns:
|
||||
A `_StoredCredentialView` whose `keys` map holds stored `api_key` values
|
||||
by provider, and whose `error` is set only when the store was unreadable.
|
||||
"""
|
||||
from deepagents_code import auth_store
|
||||
|
||||
try:
|
||||
creds = auth_store.load_credentials()
|
||||
except RuntimeError:
|
||||
# Omit the exception text on purpose: it can echo file contents, and the
|
||||
# remediation is identical regardless of the specific parse failure.
|
||||
logger.warning("Could not read stored credentials; treating as absent")
|
||||
return _StoredCredentialView(keys={}, error=_STORE_UNREADABLE_HINT)
|
||||
keys = {
|
||||
provider: entry["key"]
|
||||
for provider, entry in creds.items()
|
||||
if entry["type"] == "api_key" and entry["key"]
|
||||
}
|
||||
return _StoredCredentialView(keys=keys)
|
||||
|
||||
|
||||
def _resolve(
|
||||
option: ConfigOption,
|
||||
toml_data: dict[str, Any],
|
||||
*,
|
||||
stored: _StoredCredentialView | None = None,
|
||||
) -> tuple[bool, str, Any]:
|
||||
"""Resolve an option for display, reporting what the runtime actually reads.
|
||||
|
||||
Credential options follow runtime precedence: a present `DEEPAGENTS_CODE_`
|
||||
env override wins (the model factory reads it via `resolve_env_var` even
|
||||
after `apply_stored_credentials` bridges a stored key onto the canonical
|
||||
var), then a key stored via `/auth`, then the canonical env/`config.toml`.
|
||||
Everything else delegates straight to `config_manifest.resolve_scalar`.
|
||||
|
||||
Args:
|
||||
option: The option to resolve.
|
||||
toml_data: Parsed `config.toml` contents.
|
||||
stored: Pre-loaded credential-store snapshot. When `None`, the store is
|
||||
read on demand — fine for one-off calls, but callers resolving many
|
||||
options should load it once and pass it so `auth.json` is parsed a
|
||||
single time.
|
||||
|
||||
Returns:
|
||||
`(is_set, source, value)`, where `is_set` is `False` when the value
|
||||
came from the typed default.
|
||||
"""
|
||||
from deepagents_code.config_manifest import resolve_scalar
|
||||
from deepagents_code.model_config import ProviderAuthSource
|
||||
|
||||
if (
|
||||
option.group == "Credentials"
|
||||
and option.provider is not None
|
||||
and not _has_prefixed_env_override(option)
|
||||
):
|
||||
if stored is None:
|
||||
stored = _load_stored_credentials()
|
||||
key = stored.keys.get(option.provider)
|
||||
if key is not None:
|
||||
return True, ProviderAuthSource.STORED.value, key
|
||||
|
||||
value, source = resolve_scalar(option, toml_data=toml_data)
|
||||
return source != "default", source, value
|
||||
|
||||
|
||||
def _has_prefixed_env_override(option: ConfigOption) -> bool:
|
||||
"""Return whether an option's `DEEPAGENTS_CODE_` env var is present."""
|
||||
if option.env_var is None:
|
||||
return False
|
||||
prefix = "DEEPAGENTS_CODE_"
|
||||
if option.env_var.startswith(prefix):
|
||||
return False
|
||||
return f"{prefix}{option.env_var}" in os.environ
|
||||
|
||||
|
||||
def _display_value(option: ConfigOption, *, is_set: bool, value: object) -> str:
|
||||
"""Render an option value for human output, redacting secrets.
|
||||
|
||||
@@ -172,15 +274,27 @@ def _display_value(option: ConfigOption, *, is_set: bool, value: object) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _source_label(source: str) -> str:
|
||||
def _source_label(source: str, *, option: ConfigOption | None = None) -> str:
|
||||
"""Render the source column for human output.
|
||||
|
||||
Returns:
|
||||
Source label for the value's origin.
|
||||
"""
|
||||
if option is not None and option.group == "Credentials":
|
||||
env = _env_source_name(source)
|
||||
if env is not None and env.startswith("DEEPAGENTS_CODE_"):
|
||||
return f"{source}; session override"
|
||||
return source
|
||||
|
||||
|
||||
def _env_source_name(source: str) -> str | None:
|
||||
"""Return the env var name from an `env (...)` source label, if present."""
|
||||
prefix = "env ("
|
||||
if not source.startswith(prefix) or not source.endswith(")"):
|
||||
return None
|
||||
return source[len(prefix) : -1]
|
||||
|
||||
|
||||
def _with_availability(option: ConfigOption, text: str) -> str:
|
||||
"""Append provider availability to a credential display value when needed.
|
||||
|
||||
@@ -212,6 +326,36 @@ def _missing_extra_hint(option: ConfigOption) -> bool:
|
||||
# --- Commands ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _show_json_row(
|
||||
option: ConfigOption,
|
||||
*,
|
||||
is_set: bool,
|
||||
source: str,
|
||||
value: object,
|
||||
store_error: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build one `config show --json` row, redacting secrets and flagging store errors.
|
||||
|
||||
Returns:
|
||||
A JSON-serializable row. Redacted options report presence only (`value`
|
||||
is `None`); a `store_error` key is added to credential rows when
|
||||
the `/auth` store was unreadable, so a corrupt store is
|
||||
distinguishable from an empty one in the bug-report artifact.
|
||||
"""
|
||||
row: dict[str, Any] = {
|
||||
"key": option.key,
|
||||
"group": option.group,
|
||||
"source": source,
|
||||
"set": is_set,
|
||||
"redacted": option.redacted,
|
||||
# Redact secret values: report presence only.
|
||||
"value": None if option.redacted else value,
|
||||
}
|
||||
if store_error and option.group == "Credentials":
|
||||
row["store_error"] = store_error
|
||||
return row
|
||||
|
||||
|
||||
def _run_show(output_format: OutputFormat) -> int:
|
||||
"""Resolve every option and print its effective value and source.
|
||||
|
||||
@@ -225,23 +369,24 @@ def _run_show(output_format: OutputFormat) -> int:
|
||||
# app actually reads, not just shell exports.
|
||||
_ensure_bootstrap()
|
||||
toml_data = load_config_toml()
|
||||
# Read the credential store once; `_resolve` reuses this snapshot rather than
|
||||
# re-parsing `auth.json` per credential option.
|
||||
stored = _load_stored_credentials()
|
||||
|
||||
options = get_config_options()
|
||||
resolved = [(opt, *_resolve(opt, toml_data)) for opt in options]
|
||||
resolved = [(opt, *_resolve(opt, toml_data, stored=stored)) for opt in options]
|
||||
|
||||
if output_format == "json":
|
||||
write_json(
|
||||
"config show",
|
||||
[
|
||||
{
|
||||
"key": opt.key,
|
||||
"group": opt.group,
|
||||
"source": source,
|
||||
"set": is_set,
|
||||
"redacted": opt.redacted,
|
||||
# Redact secret values: report presence only.
|
||||
"value": None if opt.redacted else value,
|
||||
}
|
||||
_show_json_row(
|
||||
opt,
|
||||
is_set=is_set,
|
||||
source=source,
|
||||
value=value,
|
||||
store_error=stored.error,
|
||||
)
|
||||
for opt, is_set, source, value in resolved
|
||||
],
|
||||
)
|
||||
@@ -253,13 +398,18 @@ def _run_show(output_format: OutputFormat) -> int:
|
||||
from deepagents_code.config_manifest import iter_groups
|
||||
|
||||
console.print()
|
||||
if stored.error:
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] {escape(stored.error)}", highlight=False
|
||||
)
|
||||
console.print()
|
||||
for group in iter_groups(options):
|
||||
console.print(f"[bold]{group}[/bold]")
|
||||
for opt, is_set, source, value in resolved:
|
||||
if opt.group != group:
|
||||
continue
|
||||
display = _display_value(opt, is_set=is_set, value=value)
|
||||
source_label = _source_label(source)
|
||||
source_label = _source_label(source, option=opt)
|
||||
# `display` and `source_label` may contain Rich markup from env/TOML
|
||||
# or terminal metadata; escape them so values can't break rendering.
|
||||
display_text = escape(display)
|
||||
@@ -342,19 +492,23 @@ def _run_get(key: str, output_format: OutputFormat) -> int:
|
||||
|
||||
_ensure_bootstrap()
|
||||
toml_data = load_config_toml()
|
||||
is_set, source, value = _resolve(option, toml_data)
|
||||
# Only credential options consult the store, so skip the read (and its
|
||||
# warning) for everything else.
|
||||
stored = _load_stored_credentials() if option.group == "Credentials" else None
|
||||
is_set, source, value = _resolve(option, toml_data, stored=stored)
|
||||
store_error = stored.error if stored is not None else None
|
||||
|
||||
if output_format == "json":
|
||||
write_json(
|
||||
"config get",
|
||||
{
|
||||
"key": option.key,
|
||||
"source": source,
|
||||
"set": is_set,
|
||||
"redacted": option.redacted,
|
||||
"value": None if option.redacted else value,
|
||||
},
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"key": option.key,
|
||||
"source": source,
|
||||
"set": is_set,
|
||||
"redacted": option.redacted,
|
||||
"value": None if option.redacted else value,
|
||||
}
|
||||
if store_error:
|
||||
payload["store_error"] = store_error
|
||||
write_json("config get", payload)
|
||||
return 0
|
||||
|
||||
from rich.markup import escape
|
||||
@@ -362,11 +516,15 @@ def _run_get(key: str, output_format: OutputFormat) -> int:
|
||||
from deepagents_code.config import console
|
||||
|
||||
display = _display_value(option, is_set=is_set, value=value)
|
||||
source_label = _source_label(source)
|
||||
source_label = _source_label(source, option=option)
|
||||
console.print(
|
||||
f"{option.key} = {escape(display)} [dim]({escape(source_label)})[/dim]",
|
||||
highlight=False,
|
||||
)
|
||||
if store_error:
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] {escape(store_error)}", highlight=False
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -210,6 +210,16 @@ class ConfigOption:
|
||||
install_extra: str | None = None
|
||||
"""Optional `deepagents-code[...]` extra that provides `dependency_module`."""
|
||||
|
||||
provider: str | None = None
|
||||
"""Provider/service name a credential option authenticates, or `None`.
|
||||
|
||||
Set only for `Credentials`-group options (e.g. `"anthropic"`, `"tavily"`),
|
||||
where it is the key `/auth` stores the credential under and the name passed
|
||||
to `model_config.is_service`. Carrying it as a structured field lets
|
||||
`config show`/`get` look up the stored credential without re-parsing it out
|
||||
of `key`. `None` for every other option.
|
||||
"""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject a `default` that contradicts `kind` at construction time.
|
||||
|
||||
@@ -740,6 +750,7 @@ def _credential_options() -> tuple[ConfigOption, ...]:
|
||||
kind=OptionKind.STR,
|
||||
env_var=env_var,
|
||||
redacted=redacted,
|
||||
provider=name,
|
||||
settings_field=_CREDENTIAL_SETTINGS_FIELD.get(env_var),
|
||||
dependency_module=dependency[0] if dependency else None,
|
||||
install_extra=dependency[1] if dependency else None,
|
||||
|
||||
@@ -264,6 +264,14 @@ class TestCorruption:
|
||||
with pytest.raises(RuntimeError, match="Delete the file"):
|
||||
auth_store.load_credentials()
|
||||
|
||||
def test_non_utf8_raises(self, fake_home: Path) -> None:
|
||||
"""A non-UTF-8 file surfaces the deletion hint, not a `UnicodeDecodeError`."""
|
||||
path = _auth_file(fake_home)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(b"\xff\xfe not utf-8")
|
||||
with pytest.raises(RuntimeError, match="Delete the file"):
|
||||
auth_store.load_credentials()
|
||||
|
||||
def test_unknown_version_raises(self, fake_home: Path) -> None:
|
||||
"""A future schema version is rejected."""
|
||||
path = _auth_file(fake_home)
|
||||
|
||||
@@ -194,6 +194,30 @@ def test_missing_extra_hint_checks_provider_dependency(monkeypatch) -> None:
|
||||
assert _source_label("default") == "default"
|
||||
|
||||
|
||||
def test_source_label_marks_prefixed_credential_env_as_session_override() -> None:
|
||||
"""Only prefixed credential env sources get the session-override note."""
|
||||
credential = get_option("credentials.anthropic")
|
||||
assert credential is not None
|
||||
display = get_option("display.theme")
|
||||
assert display is not None
|
||||
|
||||
assert (
|
||||
_source_label(
|
||||
"env (DEEPAGENTS_CODE_ANTHROPIC_API_KEY)",
|
||||
option=credential,
|
||||
)
|
||||
== "env (DEEPAGENTS_CODE_ANTHROPIC_API_KEY); session override"
|
||||
)
|
||||
assert (
|
||||
_source_label("env (ANTHROPIC_API_KEY)", option=credential)
|
||||
== "env (ANTHROPIC_API_KEY)"
|
||||
)
|
||||
assert (
|
||||
_source_label("env (DEEPAGENTS_CODE_THEME)", option=display)
|
||||
== "env (DEEPAGENTS_CODE_THEME)"
|
||||
)
|
||||
|
||||
|
||||
def test_run_get_json_omits_secret_value(monkeypatch, capsys) -> None:
|
||||
"""JSON output for a secret option reports presence but never the value."""
|
||||
import json
|
||||
@@ -205,6 +229,368 @@ def test_run_get_json_omits_secret_value(monkeypatch, capsys) -> None:
|
||||
assert payload["data"]["value"] is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stored_auth_dir(tmp_path, monkeypatch):
|
||||
"""Redirect the credential store into a temp dir so `/auth` keys are isolated."""
|
||||
state_dir = tmp_path / ".deepagents" / ".state"
|
||||
monkeypatch.setattr("deepagents_code.model_config.DEFAULT_STATE_DIR", state_dir)
|
||||
return state_dir
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("stored_auth_dir")
|
||||
def test_resolve_credential_prefers_stored_over_env(monkeypatch):
|
||||
"""A `/auth`-stored key wins over an env var, matching runtime precedence."""
|
||||
from deepagents_code import auth_store
|
||||
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "from-env")
|
||||
auth_store.set_stored_key("anthropic", "from-store")
|
||||
option = get_option("credentials.anthropic")
|
||||
assert option is not None
|
||||
is_set, source, value = _resolve(option, {})
|
||||
assert is_set is True
|
||||
assert source == "stored"
|
||||
assert value == "from-store"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("stored_auth_dir")
|
||||
def test_resolve_credential_prefers_prefixed_env_over_stored(monkeypatch):
|
||||
"""A prefixed credential env var stays authoritative over a stored key."""
|
||||
from deepagents_code import auth_store
|
||||
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_ANTHROPIC_API_KEY", "from-prefix")
|
||||
auth_store.set_stored_key("anthropic", "from-store")
|
||||
option = get_option("credentials.anthropic")
|
||||
assert option is not None
|
||||
is_set, source, value = _resolve(option, {})
|
||||
assert is_set is True
|
||||
assert source == "env (DEEPAGENTS_CODE_ANTHROPIC_API_KEY)"
|
||||
assert value == "from-prefix"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("stored_auth_dir")
|
||||
def test_resolve_empty_prefixed_credential_blocks_stored(monkeypatch):
|
||||
"""An empty prefixed credential suppresses the stored key like the runtime."""
|
||||
from deepagents_code import auth_store
|
||||
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_ANTHROPIC_API_KEY", "")
|
||||
auth_store.set_stored_key("anthropic", "from-store")
|
||||
option = get_option("credentials.anthropic")
|
||||
assert option is not None
|
||||
is_set, source, value = _resolve(option, {})
|
||||
assert is_set is False
|
||||
assert source == "default"
|
||||
assert value is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("stored_auth_dir")
|
||||
def test_resolve_credential_uses_stored_when_env_unset(monkeypatch):
|
||||
"""A stored key is surfaced even with no env var set."""
|
||||
from deepagents_code import auth_store
|
||||
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.delenv("DEEPAGENTS_CODE_ANTHROPIC_API_KEY", raising=False)
|
||||
auth_store.set_stored_key("anthropic", "from-store")
|
||||
option = get_option("credentials.anthropic")
|
||||
assert option is not None
|
||||
is_set, source, _ = _resolve(option, {})
|
||||
assert is_set is True
|
||||
assert source == "stored"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("stored_auth_dir")
|
||||
def test_resolve_credential_falls_back_to_env_without_stored(monkeypatch):
|
||||
"""With no stored key, resolution falls through to the env var."""
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "from-env")
|
||||
option = get_option("credentials.anthropic")
|
||||
assert option is not None
|
||||
is_set, source, value = _resolve(option, {})
|
||||
assert is_set is True
|
||||
assert source == "env (ANTHROPIC_API_KEY)"
|
||||
assert value == "from-env"
|
||||
|
||||
|
||||
def test_resolve_credential_corrupt_store_falls_back_to_env(
|
||||
stored_auth_dir, monkeypatch, caplog
|
||||
):
|
||||
"""A corrupt `auth.json` is logged and treated as absent, not a hard error."""
|
||||
import logging
|
||||
|
||||
stored_auth_dir.mkdir(parents=True, exist_ok=True)
|
||||
(stored_auth_dir / "auth.json").write_text("{ not json", encoding="utf-8")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "from-env")
|
||||
option = get_option("credentials.anthropic")
|
||||
assert option is not None
|
||||
with caplog.at_level(logging.WARNING):
|
||||
is_set, source, value = _resolve(option, {})
|
||||
assert is_set is True
|
||||
assert source == "env (ANTHROPIC_API_KEY)"
|
||||
assert value == "from-env"
|
||||
assert "treating as absent" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("stored_auth_dir")
|
||||
def test_run_get_text_reports_stored_source(capsys):
|
||||
"""`config get` shows a stored credential as configured from the store."""
|
||||
from deepagents_code import auth_store
|
||||
|
||||
auth_store.set_stored_key("anthropic", "from-store")
|
||||
assert _run_get("credentials.anthropic", "text") == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "configured" in out
|
||||
assert "stored" in out
|
||||
assert "from-store" not in out
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("stored_auth_dir")
|
||||
def test_run_get_text_reports_prefixed_env_as_session_override(monkeypatch, capsys):
|
||||
"""`config get` labels a visible prefixed credential env var as session-scoped."""
|
||||
from deepagents_code import auth_store
|
||||
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_ANTHROPIC_API_KEY", "from-prefix")
|
||||
auth_store.set_stored_key("anthropic", "from-store")
|
||||
assert _run_get("credentials.anthropic", "text") == 0
|
||||
out = capsys.readouterr().out
|
||||
compact = " ".join(out.split())
|
||||
assert "configured" in out
|
||||
assert "env (DEEPAGENTS_CODE_ANTHROPIC_API_KEY); session override" in compact
|
||||
assert "from-prefix" not in out
|
||||
assert "from-store" not in out
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("stored_auth_dir")
|
||||
def test_run_get_json_redacts_stored_secret_value(capsys):
|
||||
"""`config get --json` reports a stored credential as set but never its value."""
|
||||
import json
|
||||
|
||||
from deepagents_code import auth_store
|
||||
|
||||
auth_store.set_stored_key("anthropic", "from-store")
|
||||
assert _run_get("credentials.anthropic", "json") == 0
|
||||
raw = capsys.readouterr().out
|
||||
payload = json.loads(raw)["data"]
|
||||
assert payload["set"] is True
|
||||
assert payload["source"] == "stored"
|
||||
assert payload["value"] is None
|
||||
assert "from-store" not in raw
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("stored_auth_dir")
|
||||
def test_run_show_json_redacts_stored_secret_value(capsys):
|
||||
"""`config show --json` redacts a stored secret on the aggregate path too."""
|
||||
import json
|
||||
|
||||
from deepagents_code import auth_store
|
||||
|
||||
auth_store.set_stored_key("anthropic", "from-store")
|
||||
args = argparse.Namespace(config_command="show", output_format="json")
|
||||
assert run_config_command(args) == 0
|
||||
raw = capsys.readouterr().out
|
||||
rows = json.loads(raw)["data"]
|
||||
row = next(r for r in rows if r["key"] == "credentials.anthropic")
|
||||
assert row["set"] is True
|
||||
assert row["source"] == "stored"
|
||||
assert row["value"] is None
|
||||
assert "from-store" not in raw
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("stored_auth_dir")
|
||||
def test_run_show_text_reports_stored_source(capsys):
|
||||
"""`config show` (aggregate text path) shows a stored credential as configured."""
|
||||
from deepagents_code import auth_store
|
||||
|
||||
auth_store.set_stored_key("anthropic", "from-store")
|
||||
args = argparse.Namespace(config_command="show", output_format="text")
|
||||
assert run_config_command(args) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "configured" in out
|
||||
assert "stored" in out
|
||||
assert "from-store" not in out
|
||||
|
||||
|
||||
def test_resolve_empty_stored_key_falls_back_to_env(stored_auth_dir, monkeypatch):
|
||||
"""A stored entry with a blank key does not mask a working env var."""
|
||||
import json
|
||||
|
||||
stored_auth_dir.mkdir(parents=True, exist_ok=True)
|
||||
(stored_auth_dir / "auth.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"credentials": {
|
||||
"anthropic": {"type": "api_key", "key": "", "added_at": ""}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "from-env")
|
||||
option = get_option("credentials.anthropic")
|
||||
assert option is not None
|
||||
is_set, source, value = _resolve(option, {})
|
||||
assert is_set is True
|
||||
assert source == "env (ANTHROPIC_API_KEY)"
|
||||
assert value == "from-env"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("stored_auth_dir")
|
||||
def test_resolve_non_credential_ignores_store():
|
||||
"""Non-credential options never consult the `/auth` store."""
|
||||
from deepagents_code import auth_store
|
||||
|
||||
auth_store.set_stored_key("anthropic", "from-store")
|
||||
option = get_option("display.show_header")
|
||||
assert option is not None
|
||||
_, source, _ = _resolve(option, {})
|
||||
assert source != "stored"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("stored_auth_dir")
|
||||
def test_resolve_tavily_service_prefers_stored(monkeypatch):
|
||||
"""A stored key for the tavily *service* resolves with a stored source.
|
||||
|
||||
Guards that the credential branch keys on group membership (and the
|
||||
`provider` field), not on model-provider-registry membership.
|
||||
"""
|
||||
from deepagents_code import auth_store
|
||||
|
||||
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
|
||||
monkeypatch.delenv("DEEPAGENTS_CODE_TAVILY_API_KEY", raising=False)
|
||||
auth_store.set_stored_key("tavily", "from-store")
|
||||
option = get_option("credentials.tavily")
|
||||
assert option is not None
|
||||
is_set, source, value = _resolve(option, {})
|
||||
assert is_set is True
|
||||
assert source == "stored"
|
||||
assert value == "from-store"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("stored_auth_dir")
|
||||
def test_resolve_tavily_prefixed_env_overrides_stored(monkeypatch):
|
||||
"""A prefixed tavily env var wins over the stored key, matching runtime."""
|
||||
from deepagents_code import auth_store
|
||||
|
||||
monkeypatch.setenv("DEEPAGENTS_CODE_TAVILY_API_KEY", "from-prefix")
|
||||
auth_store.set_stored_key("tavily", "from-store")
|
||||
option = get_option("credentials.tavily")
|
||||
assert option is not None
|
||||
is_set, source, value = _resolve(option, {})
|
||||
assert is_set is True
|
||||
assert source == "env (DEEPAGENTS_CODE_TAVILY_API_KEY)"
|
||||
assert value == "from-prefix"
|
||||
|
||||
|
||||
def test_run_get_json_flags_unreadable_store(stored_auth_dir, capsys):
|
||||
"""`config get --json` for a credential surfaces a store-read failure in-band."""
|
||||
import json
|
||||
|
||||
stored_auth_dir.mkdir(parents=True, exist_ok=True)
|
||||
(stored_auth_dir / "auth.json").write_text("{ not json", encoding="utf-8")
|
||||
assert _run_get("credentials.anthropic", "json") == 0
|
||||
payload = json.loads(capsys.readouterr().out)["data"]
|
||||
assert "store_error" in payload
|
||||
# Redaction still holds even when the store is unreadable.
|
||||
assert payload["value"] is None
|
||||
|
||||
|
||||
def test_run_get_json_non_credential_omits_store_error(stored_auth_dir, capsys):
|
||||
"""A non-credential `config get --json` never carries a `store_error` key."""
|
||||
import json
|
||||
|
||||
stored_auth_dir.mkdir(parents=True, exist_ok=True)
|
||||
(stored_auth_dir / "auth.json").write_text("{ not json", encoding="utf-8")
|
||||
assert _run_get("display.show_header", "json") == 0
|
||||
payload = json.loads(capsys.readouterr().out)["data"]
|
||||
assert "store_error" not in payload
|
||||
|
||||
|
||||
def test_run_show_json_flags_unreadable_store(stored_auth_dir, capsys):
|
||||
"""`config show --json` marks credential rows when the store is unreadable."""
|
||||
import json
|
||||
|
||||
stored_auth_dir.mkdir(parents=True, exist_ok=True)
|
||||
(stored_auth_dir / "auth.json").write_text("{ not json", encoding="utf-8")
|
||||
args = argparse.Namespace(config_command="show", output_format="json")
|
||||
assert run_config_command(args) == 0
|
||||
rows = json.loads(capsys.readouterr().out)["data"]
|
||||
cred_rows = [r for r in rows if r["group"] == "Credentials"]
|
||||
assert cred_rows
|
||||
assert all("store_error" in r for r in cred_rows)
|
||||
assert all("store_error" not in r for r in rows if r["group"] != "Credentials")
|
||||
|
||||
|
||||
def test_run_get_text_warns_on_unreadable_store(stored_auth_dir, capsys):
|
||||
"""`config get` text output shows a warning banner for an unreadable store."""
|
||||
stored_auth_dir.mkdir(parents=True, exist_ok=True)
|
||||
(stored_auth_dir / "auth.json").write_text("{ not json", encoding="utf-8")
|
||||
assert _run_get("credentials.anthropic", "text") == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Warning" in out
|
||||
assert "unreadable" in out
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("stored_auth_dir")
|
||||
def test_run_show_reads_store_once(monkeypatch):
|
||||
"""`config show` parses the credential store once, not once per option."""
|
||||
from deepagents_code import auth_store
|
||||
|
||||
calls = 0
|
||||
real_load = auth_store.load_credentials
|
||||
|
||||
def _counting_load() -> dict:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return real_load()
|
||||
|
||||
monkeypatch.setattr(auth_store, "load_credentials", _counting_load)
|
||||
args = argparse.Namespace(config_command="show", output_format="json")
|
||||
assert run_config_command(args) == 0
|
||||
# One read for the whole command, regardless of how many credential options
|
||||
# exist — guards the single-snapshot design against a per-option regression.
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("stored_auth_dir")
|
||||
def test_resolve_non_redacted_credential_shows_stored_value(monkeypatch):
|
||||
"""A non-redacted stored credential (the Vertex project) shows its value."""
|
||||
from deepagents_code import auth_store
|
||||
|
||||
monkeypatch.delenv("GOOGLE_CLOUD_PROJECT", raising=False)
|
||||
monkeypatch.delenv("DEEPAGENTS_CODE_GOOGLE_CLOUD_PROJECT", raising=False)
|
||||
auth_store.set_stored_key("google_vertexai", "my-project")
|
||||
option = get_option("credentials.google_vertexai")
|
||||
assert option is not None
|
||||
assert option.redacted is False
|
||||
is_set, source, value = _resolve(option, {})
|
||||
assert is_set is True
|
||||
assert source == "stored"
|
||||
assert value == "my-project"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("stored_auth_dir")
|
||||
def test_run_get_json_shows_non_redacted_stored_value(capsys):
|
||||
"""`config get --json` surfaces a non-redacted stored value (not `None`)."""
|
||||
import json
|
||||
|
||||
from deepagents_code import auth_store
|
||||
|
||||
auth_store.set_stored_key("google_vertexai", "my-project")
|
||||
assert _run_get("credentials.google_vertexai", "json") == 0
|
||||
payload = json.loads(capsys.readouterr().out)["data"]
|
||||
assert payload["source"] == "stored"
|
||||
assert payload["redacted"] is False
|
||||
assert payload["value"] == "my-project"
|
||||
|
||||
|
||||
def test_run_get_non_utf8_store_does_not_crash(stored_auth_dir, capsys):
|
||||
"""A non-UTF-8 `auth.json` degrades to a warning banner, not a traceback."""
|
||||
stored_auth_dir.mkdir(parents=True, exist_ok=True)
|
||||
(stored_auth_dir / "auth.json").write_bytes(b"\xff\xfe not utf-8")
|
||||
assert _run_get("credentials.anthropic", "text") == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Warning" in out
|
||||
assert "unreadable" in out
|
||||
|
||||
|
||||
def test_charset_auto_display_value_includes_effective_glyph_mode() -> None:
|
||||
"""The charset auto value says which glyph mode is actually being used."""
|
||||
option = get_option("display.charset")
|
||||
|
||||
Reference in New Issue
Block a user