mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
feat(code): toast on saved /auth API key (#4558)
The `/auth` prompt now shows a confirmation toast when an API key is saved. --- Pressing Enter in the `/auth` key prompt persisted the credential and silently closed the modal, giving no confirmation. This adds a "Successfully saved key for PROVIDER" toast on a successful save so the action has visible feedback. The provider label reuses `provider_display_name`, so it honors configured/built-in display names. Made by [Open SWE](https://openswe.vercel.app/agents/cd83cc67-e5bc-6845-8bbe-dc0c8fc8d4bc) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -14507,8 +14507,13 @@ class DeepAgentsApp(App):
|
||||
)
|
||||
if result == AuthResult.SAVED:
|
||||
self._notice_registry.remove(entry.key)
|
||||
# The modal's own success toast already confirms the save and names
|
||||
# the provider. This path can't activate the key in-session (unlike
|
||||
# the Tavily flow, which calls `apply_stored_service_credentials`),
|
||||
# so surface only the restart hint the modal can't — repeating the
|
||||
# "saved" confirmation here would just stack a duplicate toast.
|
||||
self.notify(
|
||||
f"Saved {service} API key. Restart to apply.",
|
||||
"Restart to apply your new key.",
|
||||
severity="information",
|
||||
timeout=6,
|
||||
markup=False,
|
||||
|
||||
@@ -110,6 +110,39 @@ class WriteOutcome:
|
||||
"""User-visible warning strings (e.g., chmod failures). Empty on success."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeleteOutcome:
|
||||
"""Result of a credential delete that may have warnings to surface.
|
||||
|
||||
A delete rewrites the whole store, so it can hit the same chmod failures as
|
||||
a write. `warnings` carries them symmetrically with `WriteOutcome` so a
|
||||
caller's "removed" confirmation doesn't paper over a store the delete
|
||||
failed to lock down to owner-only.
|
||||
"""
|
||||
|
||||
removed: bool
|
||||
"""`True` if a credential was removed, `False` if none was stored."""
|
||||
|
||||
warnings: tuple[str, ...] = field(default_factory=tuple)
|
||||
"""chmod-failure warnings from the rewrite. Always empty for a no-op delete
|
||||
(`removed=False`), which performs no write."""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject the one field combination the docstring forbids.
|
||||
|
||||
A no-op delete performs no write, so it can raise no chmod warnings.
|
||||
Enforcing it here makes "no warnings when nothing was removed" a
|
||||
construction-time guarantee rather than a producer-side convention a
|
||||
future edit (or a second producer) could quietly break.
|
||||
|
||||
Raises:
|
||||
ValueError: If `warnings` is non-empty while `removed` is `False`.
|
||||
"""
|
||||
if self.warnings and not self.removed:
|
||||
msg = "DeleteOutcome cannot carry warnings when removed=False"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def auth_path() -> Path:
|
||||
"""Return the resolved path to the credential store (`auth.json`).
|
||||
|
||||
@@ -473,14 +506,16 @@ def set_stored_key(
|
||||
return WriteOutcome(warnings=warnings)
|
||||
|
||||
|
||||
def delete_stored_key(provider: str) -> bool:
|
||||
def delete_stored_key(provider: str) -> DeleteOutcome:
|
||||
"""Remove a stored credential for `provider`.
|
||||
|
||||
Args:
|
||||
provider: Provider identifier.
|
||||
|
||||
Returns:
|
||||
`True` if a credential was removed, `False` if none was stored.
|
||||
A `DeleteOutcome` whose `removed` flag reports whether a credential was
|
||||
present, and whose `warnings` tuple lists chmod failures from the
|
||||
rewrite (empty for a no-op delete, which performs no write).
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the credential file is corrupt and cannot be read, or
|
||||
@@ -489,16 +524,16 @@ def delete_stored_key(provider: str) -> bool:
|
||||
""" # noqa: DOC502 - re-raised from `_read_raw`/`_write_raw_or_raise`
|
||||
data = _read_raw()
|
||||
if data is None:
|
||||
return False
|
||||
return DeleteOutcome(removed=False)
|
||||
creds = data.get("credentials")
|
||||
if not isinstance(creds, dict) or provider not in creds:
|
||||
return False
|
||||
return DeleteOutcome(removed=False)
|
||||
del creds[provider]
|
||||
data["version"] = _STORAGE_VERSION
|
||||
data["credentials"] = creds
|
||||
_write_raw_or_raise(data)
|
||||
warnings = _write_raw_or_raise(data)
|
||||
logger.debug("Deleted credential for provider %s", provider)
|
||||
return True
|
||||
return DeleteOutcome(removed=True, warnings=warnings)
|
||||
|
||||
|
||||
def list_configured_providers() -> list[str]:
|
||||
|
||||
@@ -18,8 +18,8 @@ Security notes:
|
||||
reading from stdin so the key never lands in shell history or argv, and it
|
||||
refuses an interactive TTY (use `--from-env` instead) so an accidental
|
||||
invocation cannot hang waiting on input.
|
||||
- `set` routes through `auth_store.set_stored_key`, so chmod warnings from the
|
||||
same `WriteOutcome` path the TUI uses are surfaced on stderr.
|
||||
- `set` and `remove` route through `auth_store`, so chmod warnings from the
|
||||
store rewrite (the same path the TUI uses) are surfaced on stderr.
|
||||
|
||||
Help rendering for a bare `auth` invocation is served by `ui.show_auth_help`,
|
||||
which does not import this module. The heavy `model_config` imports here are
|
||||
@@ -515,11 +515,14 @@ def _run_remove(provider: str) -> int:
|
||||
from deepagents_code import auth_store
|
||||
|
||||
try:
|
||||
removed = auth_store.delete_stored_key(provider)
|
||||
result = auth_store.delete_stored_key(provider)
|
||||
except RuntimeError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr) # noqa: T201
|
||||
return 1
|
||||
if removed:
|
||||
# Surface chmod warnings on the rewritten store, symmetric with `set`.
|
||||
for warning in result.warnings:
|
||||
print(f"Warning: {warning}", file=sys.stderr) # noqa: T201
|
||||
if result.removed:
|
||||
print(f"Removed stored credential for {provider}.") # noqa: T201
|
||||
else:
|
||||
print(f"No stored credential for {provider}.") # noqa: T201
|
||||
|
||||
@@ -1328,6 +1328,20 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
|
||||
if self._is_langsmith:
|
||||
apply_stored_langsmith_auth(replace_project=True)
|
||||
clear_caches()
|
||||
if not outcome.warnings:
|
||||
# Only claim a clean save when the store locked the file down. When
|
||||
# chmod warnings fired above, they *are* the outcome message — an
|
||||
# extra "success" toast on top would compete with (and visually
|
||||
# bury) the one signal that the key isn't secured.
|
||||
provider_label = provider_display_name(self._provider, self._config)
|
||||
# `markup=False`: a configured display name can contain markup
|
||||
# metacharacters (e.g. `[`) that must not be interpreted here. The
|
||||
# same guard applies to every interpolated toast below.
|
||||
self.app.notify(
|
||||
f"Successfully saved key for {provider_label}.",
|
||||
severity="information",
|
||||
markup=False,
|
||||
)
|
||||
self.dismiss(AuthResult.SAVED)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
@@ -1420,23 +1434,41 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
|
||||
if not confirmed:
|
||||
return
|
||||
try:
|
||||
removed = auth_store.delete_stored_key(self._provider)
|
||||
result = auth_store.delete_stored_key(self._provider)
|
||||
except RuntimeError as exc:
|
||||
logger.warning(
|
||||
"Failed to delete credential for %s: %s", self._provider, exc
|
||||
)
|
||||
self._show_error("Could not delete credential: $exc", exc=str(exc))
|
||||
return
|
||||
if not removed:
|
||||
for warning in result.warnings:
|
||||
# The rewritten store still holds other providers' secrets, so a
|
||||
# chmod failure here is the same security regression as on save —
|
||||
# surface it rather than dropping it on the floor.
|
||||
self.app.notify(warning, severity="warning", markup=False)
|
||||
# Toast after `clear_caches` (like the save path) so the confirmation
|
||||
# reflects fully-settled state rather than firing before the cache is
|
||||
# invalidated.
|
||||
clear_caches()
|
||||
if not result.removed:
|
||||
# The entry was gone — likely a concurrent delete from another
|
||||
# app instance. Surface that fact so "delete" UX doesn't lie when
|
||||
# nothing actually happened on disk.
|
||||
provider_label = provider_display_name(self._provider, self._config)
|
||||
self.app.notify(
|
||||
f"No stored credential for {self._provider} — already removed.",
|
||||
f"No stored credential for {provider_label} — already removed.",
|
||||
severity="information",
|
||||
markup=False,
|
||||
)
|
||||
elif not result.warnings:
|
||||
# Mirror the save path: a silent successful delete gives no
|
||||
# confirmation, and the toast is suppressed when warnings fired.
|
||||
provider_label = provider_display_name(self._provider, self._config)
|
||||
self.app.notify(
|
||||
f"Successfully removed key for {provider_label}.",
|
||||
severity="information",
|
||||
markup=False,
|
||||
)
|
||||
clear_caches()
|
||||
self.dismiss(AuthResult.DELETED)
|
||||
|
||||
def _show_error(self, template: str, /, **substitutions: str) -> None:
|
||||
|
||||
@@ -548,6 +548,29 @@ class TestRemove:
|
||||
assert code == 0
|
||||
assert "No stored credential for anthropic." in capsys.readouterr().out
|
||||
|
||||
def test_remove_surfaces_chmod_warning(
|
||||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A chmod failure on the delete rewrite is surfaced on stderr, like `set`."""
|
||||
auth_store.set_stored_key("anthropic", "sk-ant")
|
||||
original_chmod = Path.chmod
|
||||
|
||||
def _deny_chmod(self: Path, mode: int) -> None:
|
||||
if self.name == "auth.json":
|
||||
msg = "simulated chmod denial"
|
||||
raise OSError(msg)
|
||||
original_chmod(self, mode)
|
||||
|
||||
# Deny chmod only for the delete rewrite, not the seeding write above.
|
||||
monkeypatch.setattr(Path, "chmod", _deny_chmod)
|
||||
code = run_auth_command(_ns(auth_command="remove", provider="anthropic"))
|
||||
assert code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert auth_store.get_stored_key("anthropic") is None
|
||||
assert "Warning:" in captured.err
|
||||
assert "world-readable" in captured.err
|
||||
assert "Removed stored credential for anthropic." in captured.out
|
||||
|
||||
def test_remove_corrupt_store_errors(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
|
||||
@@ -15507,9 +15507,12 @@ class TestNotificationCenterIntegration:
|
||||
assert isinstance(screen, AuthPromptScreen)
|
||||
assert screen._provider == "tavily"
|
||||
assert screen._env_var == "TAVILY_API_KEY"
|
||||
# ... and on save the stale notice is gone and the user is told to restart.
|
||||
# ... and on save the stale notice is gone and the user is told to
|
||||
# restart. The modal owns the "saved" confirmation now, so this path
|
||||
# emits only the restart hint — no duplicate "Saved ... API key" toast.
|
||||
assert app._notice_registry.get("dep:tavily") is None
|
||||
assert any("Restart to apply." in m for m in messages)
|
||||
assert any(m == "Restart to apply your new key." for m in messages)
|
||||
assert not any("Saved" in m and "API key" in m for m in messages)
|
||||
|
||||
async def test_enter_api_key_unknown_service_is_a_noop(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
|
||||
@@ -182,14 +182,22 @@ class TestRoundTrip:
|
||||
assert any("malformed base_url" in r.getMessage() for r in caplog.records)
|
||||
|
||||
def test_delete_returns_true_when_removed(self) -> None:
|
||||
"""Deleting an existing entry returns `True` and clears the value."""
|
||||
"""Deleting an existing entry reports `removed=True` and clears the value."""
|
||||
auth_store.set_stored_key("openai", "k")
|
||||
assert auth_store.delete_stored_key("openai") is True
|
||||
assert auth_store.delete_stored_key("openai").removed is True
|
||||
assert auth_store.get_stored_key("openai") is None
|
||||
|
||||
def test_delete_missing_returns_false(self) -> None:
|
||||
"""Deleting an unknown provider is a no-op."""
|
||||
assert auth_store.delete_stored_key("anthropic") is False
|
||||
"""Deleting an unknown provider is a no-op that reports `removed=False`."""
|
||||
outcome = auth_store.delete_stored_key("anthropic")
|
||||
assert outcome.removed is False
|
||||
# A no-op performs no write, so it can carry no chmod warnings.
|
||||
assert outcome.warnings == ()
|
||||
|
||||
def test_delete_outcome_rejects_warnings_without_removal(self) -> None:
|
||||
"""The type refuses the illegal `removed=False` + warnings combination."""
|
||||
with pytest.raises(ValueError, match="cannot carry warnings"):
|
||||
auth_store.DeleteOutcome(removed=False, warnings=("boom",))
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("fake_home")
|
||||
@@ -251,6 +259,37 @@ class TestPermissions:
|
||||
outcome = auth_store.set_stored_key("anthropic", "k")
|
||||
assert outcome.warnings == ()
|
||||
|
||||
def test_delete_chmod_failure_returned_as_warning(
|
||||
self,
|
||||
fake_home: Path, # noqa: ARG002 - fixture activates the temp state dir
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A chmod that can't lock down the delete rewrite shows up in DeleteOutcome."""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
auth_store.set_stored_key("anthropic", "k")
|
||||
original_chmod = _Path.chmod
|
||||
|
||||
def deny_file_chmod(self: _Path, mode: int) -> None:
|
||||
if self.name == "auth.json":
|
||||
msg = "simulated chmod denial"
|
||||
raise OSError(msg)
|
||||
original_chmod(self, mode)
|
||||
|
||||
# Deny chmod only for the delete rewrite, not the seeding write above.
|
||||
monkeypatch.setattr(_Path, "chmod", deny_file_chmod)
|
||||
outcome = auth_store.delete_stored_key("anthropic")
|
||||
assert outcome.removed is True
|
||||
assert any("0600" in w for w in outcome.warnings)
|
||||
assert any("simulated chmod denial" in w for w in outcome.warnings)
|
||||
|
||||
def test_clean_delete_returns_no_warnings(self, fake_home: Path) -> None: # noqa: ARG002 - fixture activates the temp state dir
|
||||
"""A successful delete reports an empty warnings tuple."""
|
||||
auth_store.set_stored_key("anthropic", "k")
|
||||
outcome = auth_store.delete_stored_key("anthropic")
|
||||
assert outcome.removed is True
|
||||
assert outcome.warnings == ()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("fake_home")
|
||||
class TestCorruption:
|
||||
|
||||
@@ -637,6 +637,86 @@ api_key_url = "javascript:alert(1)"
|
||||
assert app.prompt_result is AuthResult.SAVED
|
||||
assert auth_store.get_stored_key("anthropic") == "sk-ant-test-12345"
|
||||
|
||||
async def test_successful_save_notifies_with_provider_name(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A successful save surfaces a confirmation toast naming the provider."""
|
||||
# `openai` resolves to "OpenAI" via the built-in display-name map but
|
||||
# would title-case to "Openai" via the bare fallback, so asserting on it
|
||||
# proves the label is resolved through `provider_display_name` rather
|
||||
# than the raw provider key.
|
||||
notices: list[tuple[str, str | None, bool]] = []
|
||||
|
||||
def _capture_notify(
|
||||
message: str,
|
||||
*_args: object,
|
||||
severity: str | None = None,
|
||||
markup: bool = True,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
notices.append((str(message), severity, markup))
|
||||
|
||||
app = _AuthHostApp()
|
||||
async with app.run_test() as pilot:
|
||||
monkeypatch.setattr(app, "notify", _capture_notify)
|
||||
app.show_prompt("openai", "OPENAI_API_KEY")
|
||||
await pilot.pause()
|
||||
app.screen.query_one("#auth-prompt-input", Input).value = "sk-test"
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert app.prompt_result is AuthResult.SAVED
|
||||
# `markup=False` is load-bearing: a user-configured display name can
|
||||
# contain Textual markup metacharacters (e.g. `[`), so the toast must not
|
||||
# interpret its interpolated label as markup.
|
||||
assert (
|
||||
"Successfully saved key for OpenAI.",
|
||||
"information",
|
||||
False,
|
||||
) in notices
|
||||
|
||||
async def test_langsmith_save_notifies_with_service_label(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The save toast fires on the service path with the service's label."""
|
||||
notices: list[tuple[str, str | None, bool]] = []
|
||||
|
||||
def _capture_notify(
|
||||
message: str,
|
||||
*_args: object,
|
||||
severity: str | None = None,
|
||||
markup: bool = True,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
notices.append((str(message), severity, markup))
|
||||
|
||||
# Stub the tracing activation so the test stays hermetic (no process
|
||||
# env mutation); the toast runs immediately after it on the LangSmith
|
||||
# branch, so the branch is still exercised end to end.
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.tui.widgets.auth.apply_stored_langsmith_auth",
|
||||
lambda **_kwargs: None,
|
||||
)
|
||||
|
||||
app = _AuthHostApp()
|
||||
async with app.run_test() as pilot:
|
||||
monkeypatch.setattr(app, "notify", _capture_notify)
|
||||
app.show_prompt("langsmith", "LANGSMITH_API_KEY")
|
||||
await pilot.pause()
|
||||
app.screen.query_one("#auth-prompt-input", Input).value = "lsv2_live"
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert app.prompt_result is AuthResult.SAVED
|
||||
# "LangSmith (tracing)" carries a parenthetical the bare provider key
|
||||
# never would, proving the service branch resolves its label through
|
||||
# `provider_display_name` rather than echoing "langsmith".
|
||||
assert (
|
||||
"Successfully saved key for LangSmith (tracing).",
|
||||
"information",
|
||||
False,
|
||||
) in notices
|
||||
|
||||
async def test_langsmith_submit_applies_tracing_env_immediately(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -1086,6 +1166,12 @@ api_key_url = "javascript:alert(1)"
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A failed credential write shows an inline error and keeps the modal."""
|
||||
notices: list[tuple[str, str | None]] = []
|
||||
|
||||
def _capture_notify(
|
||||
message: str, *_args: object, severity: str | None = None, **_kwargs: object
|
||||
) -> None:
|
||||
notices.append((str(message), severity))
|
||||
|
||||
def _raise(*_args: object, **_kwargs: object) -> auth_store.WriteOutcome:
|
||||
msg = "credential store is not writable"
|
||||
@@ -1095,6 +1181,7 @@ api_key_url = "javascript:alert(1)"
|
||||
|
||||
app = _AuthHostApp()
|
||||
async with app.run_test() as pilot:
|
||||
monkeypatch.setattr(app, "notify", _capture_notify)
|
||||
app.show_prompt("tavily", "TAVILY_API_KEY", allow_empty_submit=True)
|
||||
await pilot.pause()
|
||||
app.screen.query_one("#auth-prompt-input", Input).value = "tvly-key"
|
||||
@@ -1108,6 +1195,9 @@ api_key_url = "javascript:alert(1)"
|
||||
assert "Could not save credential" in error_text
|
||||
assert "credential store is not writable" in error_text
|
||||
assert app.prompt_dismissed is False
|
||||
# The success toast must never fire when the write raised — it would
|
||||
# tell the user the key was saved when it wasn't.
|
||||
assert not any(msg.startswith("Successfully saved key") for msg, _ in notices)
|
||||
|
||||
async def test_optional_prompt_notifies_store_warnings(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -1136,6 +1226,9 @@ api_key_url = "javascript:alert(1)"
|
||||
|
||||
assert ("credential file is not private", "warning") in notices
|
||||
assert app.prompt_result is AuthResult.SAVED
|
||||
# When the store warns, the plain "success" toast is suppressed so it
|
||||
# can't visually bury (or seem to contradict) the security warning.
|
||||
assert not any(msg.startswith("Successfully saved key") for msg, _ in notices)
|
||||
|
||||
async def test_escape_cancels(self) -> None:
|
||||
"""Escape dismisses with `CANCELLED` and writes nothing."""
|
||||
@@ -1414,6 +1507,188 @@ api_key_url = "javascript:alert(1)"
|
||||
assert app.prompt_result is AuthResult.DELETED
|
||||
assert auth_store.get_stored_key("openai") is None
|
||||
|
||||
async def test_successful_delete_notifies_with_provider_name(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A completed delete surfaces a confirmation toast naming the provider."""
|
||||
from deepagents_code.tui.widgets.auth import DeleteCredentialConfirmScreen
|
||||
|
||||
# `openai` resolves to "OpenAI" via the built-in display-name map but
|
||||
# would title-case to "Openai" via the bare fallback, so asserting on it
|
||||
# proves the label is resolved through `provider_display_name` rather
|
||||
# than the raw provider key.
|
||||
notices: list[tuple[str, str | None, bool]] = []
|
||||
|
||||
def _capture_notify(
|
||||
message: str,
|
||||
*_args: object,
|
||||
severity: str | None = None,
|
||||
markup: bool = True,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
notices.append((str(message), severity, markup))
|
||||
|
||||
auth_store.set_stored_key("openai", "to-be-removed")
|
||||
app = _AuthHostApp()
|
||||
async with app.run_test() as pilot:
|
||||
monkeypatch.setattr(app, "notify", _capture_notify)
|
||||
app.show_prompt("openai", "OPENAI_API_KEY")
|
||||
await pilot.pause()
|
||||
await pilot.press("ctrl+d")
|
||||
await pilot.pause()
|
||||
assert isinstance(app.screen, DeleteCredentialConfirmScreen)
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert app.prompt_result is AuthResult.DELETED
|
||||
# `markup=False` is load-bearing: a user-configured display name can
|
||||
# contain Textual markup metacharacters (e.g. `[`).
|
||||
assert (
|
||||
"Successfully removed key for OpenAI.",
|
||||
"information",
|
||||
False,
|
||||
) in notices
|
||||
|
||||
async def test_delete_failure_does_not_notify_success(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A failed delete shows an inline error and fires no success toast."""
|
||||
from deepagents_code.tui.widgets.auth import DeleteCredentialConfirmScreen
|
||||
|
||||
notices: list[tuple[str, str | None]] = []
|
||||
|
||||
def _capture_notify(
|
||||
message: str, *_args: object, severity: str | None = None, **_kwargs: object
|
||||
) -> None:
|
||||
notices.append((str(message), severity))
|
||||
|
||||
def _raise(*_args: object, **_kwargs: object) -> auth_store.DeleteOutcome:
|
||||
msg = "credential store is not writable"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
# Seed a key so Ctrl+D opens the confirm modal, then fail the delete
|
||||
# itself — the mirror of `test_optional_prompt_surfaces_save_failure`.
|
||||
auth_store.set_stored_key("openai", "to-be-removed")
|
||||
monkeypatch.setattr(auth_store, "delete_stored_key", _raise)
|
||||
|
||||
app = _AuthHostApp()
|
||||
async with app.run_test() as pilot:
|
||||
monkeypatch.setattr(app, "notify", _capture_notify)
|
||||
app.show_prompt("openai", "OPENAI_API_KEY")
|
||||
await pilot.pause()
|
||||
await pilot.press("ctrl+d")
|
||||
await pilot.pause()
|
||||
assert isinstance(app.screen, DeleteCredentialConfirmScreen)
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
err = app.screen.query_one("#auth-prompt-error", Static)
|
||||
error_text = str(err.content)
|
||||
|
||||
# Surfaced in-modal; the prompt stays open rather than dismissing
|
||||
# DELETED as if the removal had happened.
|
||||
assert "Could not delete credential" in error_text
|
||||
assert "credential store is not writable" in error_text
|
||||
assert app.prompt_dismissed is False
|
||||
# The success toast must never fire when the delete raised — it would
|
||||
# claim a removal that did not occur.
|
||||
assert not any(msg.startswith("Successfully removed key") for msg, _ in notices)
|
||||
|
||||
async def test_noop_delete_notifies_already_removed(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A delete that finds nothing surfaces only the 'already removed' toast."""
|
||||
from deepagents_code.tui.widgets.auth import DeleteCredentialConfirmScreen
|
||||
|
||||
notices: list[tuple[str, str | None, bool]] = []
|
||||
|
||||
def _capture_notify(
|
||||
message: str,
|
||||
*_args: object,
|
||||
severity: str | None = None,
|
||||
markup: bool = True,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
notices.append((str(message), severity, markup))
|
||||
|
||||
# The key exists when the modal opens (so Ctrl+D confirms) but is gone
|
||||
# by the time the delete runs — e.g. a concurrent delete from another
|
||||
# app instance. `removed=False` drives the "already removed" branch.
|
||||
auth_store.set_stored_key("openai", "to-be-removed")
|
||||
monkeypatch.setattr(
|
||||
auth_store,
|
||||
"delete_stored_key",
|
||||
lambda *_a, **_k: auth_store.DeleteOutcome(removed=False),
|
||||
)
|
||||
|
||||
app = _AuthHostApp()
|
||||
async with app.run_test() as pilot:
|
||||
monkeypatch.setattr(app, "notify", _capture_notify)
|
||||
app.show_prompt("openai", "OPENAI_API_KEY")
|
||||
await pilot.pause()
|
||||
await pilot.press("ctrl+d")
|
||||
await pilot.pause()
|
||||
assert isinstance(app.screen, DeleteCredentialConfirmScreen)
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
# The no-op path still dismisses DELETED (shared with the success path),
|
||||
# surfaces the resolved label, and never claims a real removal.
|
||||
assert app.prompt_result is AuthResult.DELETED
|
||||
assert (
|
||||
"No stored credential for OpenAI — already removed.",
|
||||
"information",
|
||||
False,
|
||||
) in notices
|
||||
assert not any(
|
||||
msg.startswith("Successfully removed key") for msg, _, _ in notices
|
||||
)
|
||||
|
||||
async def test_delete_store_warning_suppresses_success(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Store warnings on the delete rewrite fire and suppress the success toast."""
|
||||
from deepagents_code.tui.widgets.auth import DeleteCredentialConfirmScreen
|
||||
|
||||
notices: list[tuple[str, str | None, bool]] = []
|
||||
|
||||
def _capture_notify(
|
||||
message: str,
|
||||
*_args: object,
|
||||
severity: str | None = None,
|
||||
markup: bool = True,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
notices.append((str(message), severity, markup))
|
||||
|
||||
warning = "Could not set mode 0600 on auth.json: denied. World-readable."
|
||||
auth_store.set_stored_key("openai", "to-be-removed")
|
||||
monkeypatch.setattr(
|
||||
auth_store,
|
||||
"delete_stored_key",
|
||||
lambda *_a, **_k: auth_store.DeleteOutcome(
|
||||
removed=True, warnings=(warning,)
|
||||
),
|
||||
)
|
||||
|
||||
app = _AuthHostApp()
|
||||
async with app.run_test() as pilot:
|
||||
monkeypatch.setattr(app, "notify", _capture_notify)
|
||||
app.show_prompt("openai", "OPENAI_API_KEY")
|
||||
await pilot.pause()
|
||||
await pilot.press("ctrl+d")
|
||||
await pilot.pause()
|
||||
assert isinstance(app.screen, DeleteCredentialConfirmScreen)
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert app.prompt_result is AuthResult.DELETED
|
||||
# The security warning is surfaced...
|
||||
assert (warning, "warning", False) in notices
|
||||
# ...and the clean "success" toast is suppressed so it can't bury it.
|
||||
assert not any(
|
||||
msg.startswith("Successfully removed key") for msg, _, _ in notices
|
||||
)
|
||||
|
||||
async def test_ctrl_d_then_escape_keeps_credential(self) -> None:
|
||||
"""Esc on the confirm modal returns to the prompt without deleting."""
|
||||
from deepagents_code.tui.widgets.auth import DeleteCredentialConfirmScreen
|
||||
|
||||
Reference in New Issue
Block a user