Merge remote-tracking branch 'origin/feature/new-colony-credentials' into feature/new-colony

This commit is contained in:
Richard Tang
2026-04-10 12:15:11 -07:00
15 changed files with 1027 additions and 110 deletions
+39 -5
View File
@@ -226,6 +226,44 @@ def _is_context_too_large_error(exc: BaseException) -> bool:
return bool(_CONTEXT_TOO_LARGE_RE.search(str(exc)))
def _build_tool_error_result(tc: Any, exc: BaseException) -> ToolResult:
"""Convert a tool exception into a ToolResult for the model.
Special-cases ``CredentialExpiredError`` so the agent receives a
structured ``credential_expired`` payload (with credential_id, provider,
alias, reauth_url) instead of an opaque error string. The agent's
behavior block recognizes this shape and prompts the user to reauthorize.
"""
try:
from framework.credentials.models import CredentialExpiredError
except ImportError:
CredentialExpiredError = None # type: ignore[assignment]
if CredentialExpiredError is not None and isinstance(exc, CredentialExpiredError):
payload: dict[str, Any] = {
"error": "credential_expired",
"credential_id": exc.credential_id,
"message": str(exc),
}
if exc.provider:
payload["provider"] = exc.provider
if exc.alias:
payload["alias"] = exc.alias
if exc.help_url:
payload["reauth_url"] = exc.help_url
return ToolResult(
tool_use_id=tc.tool_use_id,
content=json.dumps(payload),
is_error=True,
)
return ToolResult(
tool_use_id=tc.tool_use_id,
content=f"Tool '{tc.tool_name}' raised: {exc}",
is_error=True,
)
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
@@ -2635,11 +2673,7 @@ class AgentLoop(AgentProtocol):
"duration_s": _dur_s,
}
if isinstance(raw, BaseException):
result = ToolResult(
tool_use_id=tc.tool_use_id,
content=f"Tool '{tc.tool_name}' raised: {raw}",
is_error=True,
)
result = _build_tool_error_result(tc, raw)
else:
result = raw
results_by_id[tc.tool_use_id] = self._truncate_tool_result(result, tc.tool_name)
+31 -16
View File
@@ -285,27 +285,42 @@ Present a short **Framework Fit Assessment**:
- **Gaps/Deal-breakers**: Only list genuinely missing capabilities after checking \
both list_agent_tools() and built-in features like GCU
### Credential Check (MANDATORY)
### Credential Check
The summary from list_agent_tools() includes `credentials_required` and \
`credentials_available` per provider. **Before designing the layout**, check \
which providers the design will need and whether credentials are available.
Your **Connected integrations** block (in your system prompt above) is the \
authoritative list of credentials currently connected for this user. It is \
refreshed on every turn you do not need to call list_credentials to \
discover what is available. Treat the block as ground truth for connectivity.
For each provider whose tools you plan to use and where \
`credentials_available` is false:
- Tell the user which credential is missing and what it's needed for
- Ask if they have access to set it up (e.g., API key, OAuth, service account)
- If they don't have access, adjust the design to work without that provider \
or suggest alternatives
**Important:** the block shows connectivity only, not liveness. OAuth tokens \
can expire between turns. The framework refreshes tokens automatically when \
a tool is called. If a refresh fails, the tool result you receive will be a \
structured payload of the form:
**Do NOT proceed to the design step with tools that require unavailable \
credentials without the user acknowledging it.** Finding out at runtime that \
credentials are missing wastes everyone's time. Surface this early.
```
{"error": "credential_expired", "credential_id": "...", "provider": "...", \
"alias": "...", "reauth_url": "..."}
```
When you see this:
1. Stop the branch of work that needed that credential do **not** retry.
2. Tell the user which integration needs reauthorization (use the alias if \
present) and surface the `reauth_url` so they can fix it.
3. Wait for the user to confirm they have reauthorized before retrying.
**Before designing the layout**, cross-check which providers your design \
needs against the Connected integrations block. If a provider is missing \
entirely (not just expired), tell the user and ask whether they can connect \
it or whether you should design around it.
Example:
> "The design needs Google Sheets tools, but the `google` credential isn't \
configured yet. Do you have a Google service account or OAuth credentials \
you can set up? If not, I can use CSV file output instead."
> "The design needs Google Sheets, but I don't see a `google` integration \
in your connected integrations. Can you connect one, or should I use CSV \
file output instead?"
`list_credentials` is still available as a diagnostic tool for inspecting \
specific credentials by id, but it is no longer part of the planning happy \
path the ambient block already gives you everything you need.
## 3: Design flowchart
+2
View File
@@ -51,6 +51,7 @@ from .key_storage import (
from .models import (
CredentialDecryptionError,
CredentialError,
CredentialExpiredError,
CredentialKey,
CredentialKeyNotFoundError,
CredentialNotFoundError,
@@ -136,6 +137,7 @@ __all__ = [
"CredentialNotFoundError",
"CredentialKeyNotFoundError",
"CredentialRefreshError",
"CredentialExpiredError",
"CredentialValidationError",
"CredentialDecryptionError",
# Key storage (bootstrap credentials)
+23
View File
@@ -333,6 +333,29 @@ class CredentialRefreshError(CredentialError):
pass
class CredentialExpiredError(CredentialError):
"""Raised when a credential is expired and refresh has failed.
Carries the metadata an agent (or the tool runner) needs to surface a
reauth request to the user without having to look anything else up.
"""
def __init__(
self,
credential_id: str,
message: str,
*,
provider: str | None = None,
alias: str | None = None,
help_url: str | None = None,
):
self.credential_id = credential_id
self.provider = provider
self.alias = alias
self.help_url = help_url
super().__init__(message)
class CredentialValidationError(CredentialError):
"""Raised when credential validation fails."""
+155 -26
View File
@@ -161,6 +161,14 @@ class EncryptedFileStorage(CredentialStorage):
self._fernet = Fernet(self._key)
# Rebuild the metadata index from disk if it's missing or older than
# the current index schema. The index is a developer-readable JSON
# snapshot of the encrypted store; the .enc files remain authoritative.
try:
self._maybe_rebuild_index()
except Exception:
logger.debug("Initial index rebuild failed (non-fatal)", exc_info=True)
def _ensure_dirs(self) -> None:
"""Create directory structure."""
(self.base_path / "credentials").mkdir(parents=True, exist_ok=True)
@@ -186,8 +194,8 @@ class EncryptedFileStorage(CredentialStorage):
with open(cred_path, "wb") as f:
f.write(encrypted)
# Update index
self._update_index(credential.id, "save", credential.credential_type.value)
# Update developer-readable index
self._index_upsert(credential)
logger.debug(f"Saved encrypted credential '{credential.id}'")
def load(self, credential_id: str) -> CredentialObject | None:
@@ -217,7 +225,7 @@ class EncryptedFileStorage(CredentialStorage):
cred_path = self._cred_path(credential_id)
if cred_path.exists():
cred_path.unlink()
self._update_index(credential_id, "delete")
self._index_remove(credential_id)
logger.debug(f"Deleted credential '{credential_id}'")
return True
return False
@@ -258,33 +266,154 @@ class EncryptedFileStorage(CredentialStorage):
return CredentialObject.model_validate(data)
def _update_index(
self,
credential_id: str,
operation: str,
credential_type: str | None = None,
) -> None:
"""Update the metadata index."""
index_path = self.base_path / "metadata" / "index.json"
# ------------------------------------------------------------------
# Developer-readable metadata index
#
# The index lives at ``<base_path>/metadata/index.json`` and mirrors what
# is in the encrypted store at a glance: credential id, provider, alias,
# identity, key names, timestamps, and earliest expiry. It contains NO
# secret values and is safe to share when filing a bug report. The .enc
# files remain authoritative — the index is purely for human inspection
# and for cheap ``list_all()`` enumeration.
#
# Schema version is bumped whenever the entry shape changes; the store
# rebuilds the index from the encrypted files on load when the on-disk
# version is older.
# ------------------------------------------------------------------
if index_path.exists():
with open(index_path, encoding="utf-8-sig") as f:
index = json.load(f)
else:
index = {"credentials": {}, "version": "1.0"}
INDEX_VERSION = "2.0"
INDEX_INTERNAL_KEY_NAMES = ("_alias", "_integration_type")
if operation == "save":
index["credentials"][credential_id] = {
"updated_at": datetime.now(UTC).isoformat(),
"type": credential_type,
}
elif operation == "delete":
index["credentials"].pop(credential_id, None)
def _index_path(self) -> Path:
return self.base_path / "metadata" / "index.json"
index["last_modified"] = datetime.now(UTC).isoformat()
def _read_index(self) -> dict[str, Any]:
"""Read the index from disk; return an empty skeleton if missing."""
path = self._index_path()
if not path.exists():
return {"version": self.INDEX_VERSION, "credentials": {}}
try:
with open(path, encoding="utf-8-sig") as f:
return json.load(f)
except Exception:
logger.debug("Failed to read credential index, starting fresh", exc_info=True)
return {"version": self.INDEX_VERSION, "credentials": {}}
with open(index_path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=2)
def _write_index(self, index: dict[str, Any]) -> None:
"""Write the index to disk with consistent envelope fields."""
index["version"] = self.INDEX_VERSION
index["store_path"] = str(self.base_path)
index["generated_at"] = datetime.now(UTC).isoformat()
path = self._index_path()
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=2, sort_keys=False, default=str)
def _index_entry_for(self, credential: CredentialObject) -> dict[str, Any]:
"""Build a single index entry from a CredentialObject (no secrets)."""
# Visible key names: drop internal markers like _alias / _integration_type
# / _identity_* so the entry shows what's actually a credential key.
visible_keys = [
name
for name in credential.keys.keys()
if name not in self.INDEX_INTERNAL_KEY_NAMES
and not name.startswith("_identity_")
]
# Earliest expiry across all keys (most likely the access_token).
earliest_expiry: datetime | None = None
for key in credential.keys.values():
if key.expires_at is None:
continue
if earliest_expiry is None or key.expires_at < earliest_expiry:
earliest_expiry = key.expires_at
return {
"credential_type": credential.credential_type.value,
"provider": credential.provider_type,
"alias": credential.alias,
"identity": credential.identity.to_dict(),
"key_names": sorted(visible_keys),
"created_at": credential.created_at.isoformat() if credential.created_at else None,
"updated_at": credential.updated_at.isoformat() if credential.updated_at else None,
"last_refreshed": (
credential.last_refreshed.isoformat() if credential.last_refreshed else None
),
"expires_at": earliest_expiry.isoformat() if earliest_expiry else None,
"auto_refresh": credential.auto_refresh,
"tags": list(credential.tags),
}
def _index_upsert(self, credential: CredentialObject) -> None:
"""Insert or update one credential entry in the index."""
try:
index = self._read_index()
if index.get("version") != self.INDEX_VERSION:
# Old schema — rebuild from disk so we don't blend formats.
self._rebuild_index()
return
credentials = index.setdefault("credentials", {})
credentials[credential.id] = self._index_entry_for(credential)
self._write_index(index)
except Exception:
logger.debug("Index upsert failed (non-fatal)", exc_info=True)
def _index_remove(self, credential_id: str) -> None:
"""Remove one credential entry from the index."""
try:
index = self._read_index()
if index.get("version") != self.INDEX_VERSION:
self._rebuild_index()
return
credentials = index.setdefault("credentials", {})
credentials.pop(credential_id, None)
self._write_index(index)
except Exception:
logger.debug("Index remove failed (non-fatal)", exc_info=True)
def _maybe_rebuild_index(self) -> None:
"""Rebuild the index if it's missing, malformed, or on an old schema.
Called once at startup. The check is cheap read the version field
and bail out if it matches. Encrypted files remain authoritative; this
only refreshes the developer-facing snapshot.
"""
path = self._index_path()
if path.exists():
try:
with open(path, encoding="utf-8-sig") as f:
index = json.load(f)
if index.get("version") == self.INDEX_VERSION:
return
except Exception:
pass # fall through to rebuild
self._rebuild_index()
def _rebuild_index(self) -> None:
"""Walk the encrypted credentials directory and rewrite a fresh index."""
cred_dir = self.base_path / "credentials"
if not cred_dir.is_dir():
return
entries: dict[str, Any] = {}
for cred_file in sorted(cred_dir.glob("*.enc")):
credential_id = cred_file.stem
try:
cred = self.load(credential_id)
except Exception:
logger.debug(
"Failed to load %s during index rebuild — skipping",
credential_id,
exc_info=True,
)
continue
if cred is None:
continue
entries[cred.id] = self._index_entry_for(cred)
index = {"credentials": entries}
self._write_index(index)
logger.info("Rebuilt credential index with %d entries", len(entries))
class EnvVarStorage(CredentialStorage):
+59 -8
View File
@@ -19,6 +19,7 @@ from typing import Any
from pydantic import SecretStr
from .models import (
CredentialExpiredError,
CredentialKey,
CredentialObject,
CredentialRefreshError,
@@ -177,6 +178,8 @@ class CredentialStore:
self,
credential_id: str,
refresh_if_needed: bool = True,
*,
raise_on_refresh_failure: bool = False,
) -> CredentialObject | None:
"""
Get a credential by ID.
@@ -184,6 +187,11 @@ class CredentialStore:
Args:
credential_id: The credential identifier
refresh_if_needed: If True, refresh expired credentials
raise_on_refresh_failure: If True, raise ``CredentialExpiredError``
when refresh fails instead of silently returning the stale
credential. Tool-execution call sites should pass True so the
agent gets a structured "reauth needed" signal rather than a
later 401 from the provider.
Returns:
CredentialObject or None if not found
@@ -193,7 +201,9 @@ class CredentialStore:
cached = self._get_from_cache(credential_id)
if cached is not None:
if refresh_if_needed and self._should_refresh(cached):
return self._refresh_credential(cached)
return self._refresh_credential(
cached, raise_on_failure=raise_on_refresh_failure
)
return cached
# Load from storage
@@ -203,30 +213,46 @@ class CredentialStore:
# Refresh if needed
if refresh_if_needed and self._should_refresh(credential):
credential = self._refresh_credential(credential)
credential = self._refresh_credential(
credential, raise_on_failure=raise_on_refresh_failure
)
# Cache
self._add_to_cache(credential)
return credential
def get_key(self, credential_id: str, key_name: str) -> str | None:
def get_key(
self,
credential_id: str,
key_name: str,
*,
raise_on_refresh_failure: bool = False,
) -> str | None:
"""
Convenience method to get a specific key value.
Args:
credential_id: The credential identifier
key_name: The key within the credential
raise_on_refresh_failure: See ``get_credential``.
Returns:
The key value or None if not found
"""
credential = self.get_credential(credential_id)
credential = self.get_credential(
credential_id, raise_on_refresh_failure=raise_on_refresh_failure
)
if credential is None:
return None
return credential.get_key(key_name)
def get(self, credential_id: str) -> str | None:
def get(
self,
credential_id: str,
*,
raise_on_refresh_failure: bool = False,
) -> str | None:
"""
Legacy compatibility: get the primary key value.
@@ -235,11 +261,14 @@ class CredentialStore:
Args:
credential_id: The credential identifier
raise_on_refresh_failure: See ``get_credential``.
Returns:
The primary key value or None
"""
credential = self.get_credential(credential_id)
credential = self.get_credential(
credential_id, raise_on_refresh_failure=raise_on_refresh_failure
)
if credential is None:
return None
return credential.get_default_key()
@@ -510,8 +539,20 @@ class CredentialStore:
return provider.should_refresh(credential)
def _refresh_credential(self, credential: CredentialObject) -> CredentialObject:
"""Refresh a credential using its provider."""
def _refresh_credential(
self,
credential: CredentialObject,
*,
raise_on_failure: bool = False,
) -> CredentialObject:
"""Refresh a credential using its provider.
When ``raise_on_failure`` is True, a refresh failure raises
``CredentialExpiredError`` carrying provider/alias/help_url metadata
for the caller (typically the tool runner) to surface a reauth
request. Otherwise, the stale credential is returned to preserve
legacy best-effort behavior.
"""
provider = self.get_provider_for_credential(credential)
if provider is None:
logger.warning(f"No provider found for credential '{credential.id}'")
@@ -530,6 +571,16 @@ class CredentialStore:
except CredentialRefreshError as e:
logger.error(f"Failed to refresh credential '{credential.id}': {e}")
if raise_on_failure:
raise CredentialExpiredError(
credential_id=credential.id,
message=(
f"OAuth token for '{credential.id}' is expired and "
f"refresh failed: {e}. Reauthorization required."
),
provider=credential.provider_type,
alias=credential.alias,
) from e
return credential
def refresh_credential(self, credential_id: str) -> CredentialObject | None:
+59 -40
View File
@@ -68,23 +68,50 @@ def build_accounts_prompt(
tool_provider_map: dict[str, str] | None = None,
node_tool_names: list[str] | None = None,
) -> str:
"""Build a prompt section describing connected accounts."""
"""Build a prompt section describing connected accounts.
Format: a ``# Connected integrations`` heading, then one block per
provider. Each provider header names the tools that accept an
``account=`` argument; each account is listed alias-first with the
alias wrapped in double quotes so the model treats it as a literal
identifier (not prose). Single-account providers collapse to a
two-line block. Pure data behavioral guidance lives in the node's
planning_knowledge section, not here.
"""
if not accounts:
return ""
def _format_identity(acct: dict[str, Any]) -> str:
identity = acct.get("identity", {})
parts = [str(v) for v in identity.values() if v]
return f" ({', '.join(parts)})" if parts else ""
def _format_account_line(acct: dict[str, Any]) -> str:
alias = acct.get("alias", "unknown")
source_tag = " [local]" if acct.get("source") == "local" else ""
return f'- "{alias}"{_format_identity(acct)}{source_tag}'
provider_accounts: dict[str, list[dict[str, Any]]] = {}
for acct in accounts:
provider_accounts.setdefault(acct.get("provider", "unknown"), []).append(acct)
# Appended (only when any rendered provider has >1 account) so the model
# knows to disambiguate instead of silently picking one.
multi_account_note = (
"\nWhen a provider below has multiple accounts, ask the user which "
"one to use and list the options — do not guess."
)
# Simple path: no tool map — just group accounts by provider.
if tool_provider_map is None:
lines = [
"Connected accounts (use the alias as the `account` parameter "
"when calling tools to target a specific account):"
]
for acct in accounts:
provider = acct.get("provider", "unknown")
alias = acct.get("alias", "unknown")
identity = acct.get("identity", {})
detail_parts = [f"{k}: {v}" for k, v in identity.items() if v]
detail = f" ({', '.join(detail_parts)})" if detail_parts else ""
lines.append(f"- {provider}/{alias}{detail}")
return "\n".join(lines)
sections: list[str] = ["# Connected integrations"]
for provider, acct_list in provider_accounts.items():
sections.append(f"\n{provider}")
for acct in acct_list:
sections.append(_format_account_line(acct))
if any(len(acct_list) > 1 for acct_list in provider_accounts.values()):
sections.append(multi_account_note)
return "\n".join(sections)
provider_tools: dict[str, list[str]] = {}
for tool_name, provider in tool_provider_map.items():
@@ -92,46 +119,38 @@ def build_accounts_prompt(
node_tool_set = set(node_tool_names) if node_tool_names else None
provider_accounts: dict[str, list[dict[str, Any]]] = {}
for acct in accounts:
provider = acct.get("provider", "unknown")
provider_accounts.setdefault(provider, []).append(acct)
sections: list[str] = ["Connected accounts:"]
sections = ["# Connected integrations"]
has_multi_account = False
for provider, acct_list in provider_accounts.items():
tools_for_provider = sorted(provider_tools.get(provider, []))
if node_tool_set is not None:
relevant_tools = [
tool_name for tool_name in tools_for_provider if tool_name in node_tool_set
]
if not relevant_tools:
tools_for_provider = [t for t in tools_for_provider if t in node_tool_set]
if not tools_for_provider:
continue
tools_for_provider = relevant_tools
all_local = all(acct.get("source") == "local" for acct in acct_list)
display_name = provider.replace("_", " ").title()
if tools_for_provider and not all_local:
tools_str = ", ".join(tools_for_provider)
sections.append(f'\n{display_name} (use account="<alias>" with: {tools_str}):')
elif tools_for_provider and all_local:
tools_str = ", ".join(tools_for_provider)
sections.append(f"\n{display_name} (tools: {tools_str}):")
else:
sections.append(f"\n{display_name}:")
tools_str = ", ".join(tools_for_provider)
if tools_for_provider and not all_local:
header_suffix = f' (use account="<alias>" with: {tools_str})'
elif tools_for_provider and all_local:
header_suffix = f" (tools: {tools_str})"
else:
header_suffix = ""
sections.append(f"\n{provider}{header_suffix}")
for acct in acct_list:
alias = acct.get("alias", "unknown")
identity = acct.get("identity", {})
detail_parts = [f"{k}: {v}" for k, v in identity.items() if v]
detail = f" ({', '.join(detail_parts)})" if detail_parts else ""
source_tag = " [local]" if acct.get("source") == "local" else ""
sections.append(f" - {provider}/{alias}{detail}{source_tag}")
sections.append(_format_account_line(acct))
if len(acct_list) > 1:
has_multi_account = True
if len(sections) <= 1:
return ""
if has_multi_account:
sections.append(multi_account_note)
return "\n".join(sections)
@@ -153,6 +153,60 @@ def install_worker_escalation_routing(
)
# Cache TTL for the ambient credentials block. The block is rebuilt at most
# once per this interval; routes_credentials.invalidate_credentials_cache()
# forces an immediate rebuild on save/delete.
_CREDENTIALS_BLOCK_TTL_SECONDS = 30.0
def _build_credentials_provider() -> Any:
"""Return a closure that renders the ambient credentials block.
The closure snapshots connected accounts via CredentialStoreAdapter and
feeds them to build_accounts_prompt(). Output is connectivity-only
provider, alias, identity. No status / valid / expires_at fields, since
those mislead the Queen the moment they go stale (liveness is enforced
at tool-call time via CredentialExpiredError instead).
"""
import time
state: dict[str, Any] = {"cached": "", "cached_at": 0.0}
def _provider() -> str:
now = time.monotonic()
if (
state["cached"]
and (now - state["cached_at"]) < _CREDENTIALS_BLOCK_TTL_SECONDS
):
return state["cached"]
try:
from aden_tools.credentials.store_adapter import CredentialStoreAdapter
from framework.orchestrator.prompting import build_accounts_prompt
adapter = CredentialStoreAdapter.default()
accounts = adapter.get_all_account_info()
tool_provider_map = adapter.get_tool_provider_map()
rendered = build_accounts_prompt(
accounts,
tool_provider_map=tool_provider_map,
node_tool_names=None,
)
except Exception:
logger.debug("Failed to render ambient credentials block", exc_info=True)
rendered = ""
state["cached"] = rendered
state["cached_at"] = now
return rendered
def _invalidate() -> None:
state["cached_at"] = 0.0
_provider.invalidate = _invalidate # type: ignore[attr-defined]
return _provider
async def create_queen(
session: Session,
session_manager: Any,
@@ -255,6 +309,14 @@ async def create_queen(
phase_state = QueenPhaseState(phase=effective_phase, event_bus=session.event_bus)
session.phase_state = phase_state
# ---- Ambient credentials provider --------------------------------
# Renders the "Connected integrations" block injected into every Queen
# phase prompt so the Queen always knows which credentials are connected
# without having to call list_credentials. Cached briefly to keep the
# per-iteration prompt rebuild cheap; invalidated by routes_credentials
# when the user adds/removes an integration.
phase_state.credentials_prompt_provider = _build_credentials_provider()
# ---- Track ask rounds during planning ----------------------------
# Increment planning_ask_rounds each time the queen requests user
# input (ask_user or ask_user_multiple) while in the planning phase.
+110
View File
@@ -18,6 +18,36 @@ def _get_store(request: web.Request) -> CredentialStore:
return request.app["credential_store"]
def _invalidate_queen_credentials_cache(request: web.Request) -> None:
"""Force every live Queen session to rebuild its ambient credentials block.
Called after credential save/delete so newly added or removed integrations
appear in the Queen's prompt on her next turn instead of waiting for the
cache TTL to expire.
"""
manager = request.app.get("manager")
if manager is None:
return
sessions = getattr(manager, "_sessions", None)
if not sessions:
return
for session in sessions.values():
phase_state = getattr(session, "phase_state", None)
if phase_state is None:
continue
provider = getattr(phase_state, "credentials_prompt_provider", None)
invalidate = getattr(provider, "invalidate", None)
if callable(invalidate):
try:
invalidate()
except Exception:
logger.debug(
"Credentials cache invalidate failed for session %s",
getattr(session, "id", "?"),
exc_info=True,
)
def _credential_to_dict(cred: CredentialObject) -> dict:
"""Serialize a CredentialObject to JSON — never include secret values."""
return {
@@ -86,6 +116,7 @@ async def handle_save_credential(request: web.Request) -> web.Response:
except Exception as exc:
logger.warning("Aden token sync after key save failed: %s", exc)
_invalidate_queen_credentials_cache(request)
return web.json_response({"saved": "aden_api_key"}, status=201)
store = _get_store(request)
@@ -94,6 +125,7 @@ async def handle_save_credential(request: web.Request) -> web.Response:
keys={k: CredentialKey(name=k, value=SecretStr(v)) for k, v in keys.items()},
)
store.save_credential(cred)
_invalidate_queen_credentials_cache(request)
return web.json_response({"saved": credential_id}, status=201)
@@ -113,6 +145,7 @@ async def handle_delete_credential(request: web.Request) -> web.Response:
deleted = store.delete_credential(credential_id)
if not deleted:
return web.json_response({"error": f"Credential '{credential_id}' not found"}, status=404)
_invalidate_queen_credentials_cache(request)
return web.json_response({"deleted": True})
@@ -207,6 +240,77 @@ def _status_to_dict(c) -> dict:
}
def _collect_accounts_by_provider() -> dict[str, list[dict]]:
"""Snapshot connected accounts grouped by provider (credential_id).
Returns a dict mapping provider list of account dicts with the
fields the frontend needs to render per-account rows. Best-effort
returns {} if the adapter cannot be built.
"""
try:
from aden_tools.credentials.store_adapter import CredentialStoreAdapter
adapter = CredentialStoreAdapter.default()
grouped: dict[str, list[dict]] = {}
for acct in adapter.get_all_account_info():
provider = acct.get("provider", "")
if not provider:
continue
grouped.setdefault(provider, []).append({
"provider": provider,
"alias": acct.get("alias", ""),
"identity": acct.get("identity", {}) or {},
"source": acct.get("source", "aden"),
"credential_id": acct.get("credential_id", provider),
})
return grouped
except Exception:
logger.debug("Failed to collect accounts for specs response", exc_info=True)
return {}
async def handle_resync_credentials(request: web.Request) -> web.Response:
"""POST /api/credentials/resync — force-resync Aden OAuth tokens.
Called by the frontend after the user completes an OAuth flow on
hive.adenhq.com so the new account appears in Hive without waiting
for a cache TTL. Returns the current connected-accounts snapshot so
the caller can diff against what it had before opening the Aden tab.
"""
try:
from aden_tools.credentials import CREDENTIAL_SPECS
from framework.credentials.validation import _presync_aden_tokens, ensure_credential_key_env
ensure_credential_key_env()
if not os.environ.get("ADEN_API_KEY"):
return web.json_response(
{"error": "Aden API key not configured", "accounts_by_provider": {}},
status=400,
)
loop = asyncio.get_running_loop()
# _presync_aden_tokens makes blocking HTTP calls to the Aden server.
await loop.run_in_executor(
None, lambda: _presync_aden_tokens(CREDENTIAL_SPECS, force=True)
)
_invalidate_queen_credentials_cache(request)
accounts_by_provider = _collect_accounts_by_provider()
return web.json_response({
"synced": True,
"accounts_by_provider": accounts_by_provider,
})
except Exception as exc:
logger.exception("Error during credential resync: %s", exc)
return web.json_response(
{"error": "Internal server error during resync"},
status=500,
)
async def handle_list_specs(request: web.Request) -> web.Response:
"""GET /api/credentials/specs — list ALL credential specs with availability."""
try:
@@ -234,6 +338,10 @@ async def handle_list_specs(request: web.Request) -> web.Response:
storage = env_storage
store = CredentialStore(storage=storage)
# Snapshot accounts once — the adapter walks the same specs internally
# and hits both Aden and local stores, so we reuse it for every row.
accounts_by_provider = _collect_accounts_by_provider()
specs = []
any_aden = False
for name, spec in CREDENTIAL_SPECS.items():
@@ -253,6 +361,7 @@ async def handle_list_specs(request: web.Request) -> web.Response:
"credential_key": spec.credential_key,
"credential_group": spec.credential_group,
"available": store.is_available(cred_id),
"accounts": accounts_by_provider.get(cred_id, []),
})
# Include aden_api_key synthetic row if any spec uses Aden
@@ -286,6 +395,7 @@ def register_routes(app: web.Application) -> None:
# specs and check-agent must be registered BEFORE the {credential_id} wildcard
app.router.add_get("/api/credentials/specs", handle_list_specs)
app.router.add_post("/api/credentials/check-agent", handle_check_agent)
app.router.add_post("/api/credentials/resync", handle_resync_credentials)
app.router.add_get("/api/credentials", handle_list_credentials)
app.router.add_post("/api/credentials", handle_save_credential)
app.router.add_get("/api/credentials/{credential_id}", handle_get_credential)
@@ -62,6 +62,22 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _render_credentials_block(provider: Any) -> str:
"""Call a credentials_prompt_provider safely and return its output.
Returns "" if no provider is set or if it raises (the Queen prompt must
never fail to render because credential discovery hit a hiccup).
"""
if provider is None:
return ""
try:
result = provider()
except Exception:
logger.debug("credentials_prompt_provider raised", exc_info=True)
return ""
return result or ""
@dataclass
class WorkerSessionAdapter:
"""Adapter for TUI compatibility.
@@ -108,6 +124,12 @@ class QueenPhaseState:
# Community skills catalog (XML) — appended after protocols
skills_catalog_prompt: str = ""
# Provider for the ambient "Connected integrations" block. The orchestrator
# wires this to a function that snapshots CredentialStoreAdapter accounts
# and renders them via build_accounts_prompt(). Called on every prompt
# rebuild so newly added/deleted credentials show up without restart.
credentials_prompt_provider: Any = None # Callable[[], str] | None
# Queen identity (set once at session start by queen identity hook,
# persisted here so it survives dynamic prompt refreshes across iterations).
queen_id: str | None = None
@@ -144,6 +166,9 @@ class QueenPhaseState:
if self.queen_identity_prompt:
parts.append(self.queen_identity_prompt)
parts.append(base)
credentials_block = _render_credentials_block(self.credentials_prompt_provider)
if credentials_block:
parts.append(credentials_block)
if self.skills_catalog_prompt:
parts.append(self.skills_catalog_prompt)
if self.protocols_prompt:
@@ -249,6 +274,10 @@ class QueenPhaseState:
# Community skills catalog (XML) — appended after protocols
skills_catalog_prompt: str = ""
# Provider for the ambient "Connected integrations" block. See
# docstring on the simpler QueenPhaseState above.
credentials_prompt_provider: Any = None # Callable[[], str] | None
# Queen identity (set once at session start by queen identity hook,
# persisted here so it survives dynamic prompt refreshes across iterations).
queen_id: str | None = None
@@ -293,6 +322,9 @@ class QueenPhaseState:
if self.queen_identity_prompt:
parts.append(self.queen_identity_prompt)
parts.append(base)
credentials_block = _render_credentials_block(self.credentials_prompt_provider)
if credentials_block:
parts.append(credentials_block)
if self.skills_catalog_prompt:
parts.append(self.skills_catalog_prompt)
if self.protocols_prompt:
+17
View File
@@ -8,6 +8,14 @@ export interface CredentialInfo {
updated_at: string | null;
}
export interface CredentialAccount {
provider: string;
alias: string;
identity: Record<string, string>;
source: "aden" | "local" | string;
credential_id: string;
}
export interface CredentialSpec {
credential_name: string;
credential_id: string;
@@ -21,6 +29,12 @@ export interface CredentialSpec {
credential_key: string;
credential_group: string;
available: boolean;
accounts: CredentialAccount[];
}
export interface ResyncResponse {
synced: boolean;
accounts_by_provider: Record<string, CredentialAccount[]>;
}
export interface AgentCredentialRequirement {
@@ -64,4 +78,7 @@ export const credentialsApi = {
"/credentials/check-agent",
{ agent_path: agentPath },
),
resync: () =>
api.post<ResyncResponse>("/credentials/resync", {}),
};
+396 -10
View File
@@ -9,8 +9,14 @@ import {
Link2,
Info,
X,
Check,
Plus,
} from "lucide-react";
import { credentialsApi, type CredentialSpec } from "@/api/credentials";
import {
credentialsApi,
type CredentialSpec,
type CredentialAccount,
} from "@/api/credentials";
import SettingsModal from "@/components/SettingsModal";
// Icon map for known credentials (credential_id → emoji/symbol)
@@ -175,6 +181,23 @@ export default function CredentialsPage() {
rect: DOMRect;
spec: CredentialSpec;
} | null>(null);
const [authorizeModal, setAuthorizeModal] = useState<{
spec: CredentialSpec;
// Aliases that existed for this provider *before* the user clicked Authorize.
// Used to detect which rows in `currentAccounts` are brand-new.
snapshotAliases: string[];
// Latest accounts list for this provider, refreshed on every resync.
// Starts equal to the snapshot at open time.
currentAccounts: CredentialAccount[];
status: "waiting" | "checking" | "not_found" | "success";
newAccount: CredentialAccount | null;
// Orthogonal to `status`: true while a background (focus-triggered)
// probe is in flight. Drives the inline "Checking…" indicator.
probing: boolean;
// Human-readable result of the last background probe, shown inline
// for a short time. null = nothing to show.
probeResult: string | null;
} | null>(null);
const lastFocusFetch = useRef(0);
const fetchSpecs = useCallback(async () => {
@@ -239,21 +262,127 @@ export default function CredentialsPage() {
};
const handleConnect = (spec: CredentialSpec) => {
if (spec.credential_id === "aden_api_key" || spec.aden_supported) {
if (spec.aden_supported && !spec.direct_api_key_supported) {
window.open("https://hive.adenhq.com/", "_blank", "noopener");
return;
}
if (spec.credential_id === "aden_api_key") {
window.open("https://hive.adenhq.com/", "_blank", "noopener");
// Also allow pasting key — fall through to edit mode
}
// Pure-OAuth provider: open Aden and show the blocking modal that waits
// for the user to finish authorizing before we resync and close.
if (spec.aden_supported && !spec.direct_api_key_supported) {
window.open("https://hive.adenhq.com/", "_blank", "noopener");
const initial = spec.accounts ?? [];
setAuthorizeModal({
spec,
snapshotAliases: initial.map((a) => a.alias),
currentAccounts: initial,
status: "waiting",
newAccount: null,
probing: false,
probeResult: null,
});
return;
}
if (spec.credential_id === "aden_api_key") {
// Aden platform key — open tab and also allow pasting the key below.
window.open("https://hive.adenhq.com/", "_blank", "noopener");
}
setEditingId(spec.credential_id);
setInputValue("");
setDeletingId(null);
};
// Called from the AuthorizeModal: force-resync and check whether a new
// account for this provider has shown up since the modal opened.
//
// `silent=true` is used by the focus-based auto-probe: a miss stays in
// "waiting" (the user may still be mid-flow on Aden) so we don't shout
// "couldn't find a new account" every time they tab back. Only an
// explicit button click (silent=false) can surface the "not_found" state.
const runResyncCheck = useCallback(
async (silent: boolean = false) => {
setAuthorizeModal((prev) => {
if (!prev) return prev;
if (silent) {
return { ...prev, probing: true, probeResult: null };
}
return { ...prev, status: "checking" };
});
try {
const resp = await credentialsApi.resync();
setAuthorizeModal((prev) => {
if (!prev) return prev;
const provider = prev.spec.credential_id;
const current = resp.accounts_by_provider[provider] ?? [];
const before = new Set(prev.snapshotAliases);
const fresh = current.find((a) => !before.has(a.alias)) ?? null;
if (fresh) {
return {
...prev,
currentAccounts: current,
status: "success",
newAccount: fresh,
probing: false,
probeResult: null,
};
}
if (silent) {
// Stay in "waiting" — user may still be mid-authorization.
// Surface a short-lived inline result so they know we checked.
return {
...prev,
currentAccounts: current,
probing: false,
probeResult: "No new account yet",
};
}
return {
...prev,
currentAccounts: current,
status: "not_found",
newAccount: null,
probing: false,
probeResult: null,
};
});
// Refresh the page data so any new accounts render behind the modal.
await fetchSpecs();
} catch {
setAuthorizeModal((prev) => {
if (!prev) return prev;
if (silent) {
return {
...prev,
probing: false,
probeResult: "Check failed",
};
}
return { ...prev, status: "not_found", probing: false };
});
}
},
[fetchSpecs]
);
// Clear the short-lived probe result after a couple seconds so the inline
// "No new account yet" message doesn't linger forever.
useEffect(() => {
if (!authorizeModal?.probeResult) return;
const t = setTimeout(() => {
setAuthorizeModal((prev) =>
prev && prev.probeResult ? { ...prev, probeResult: null } : prev
);
}, 2500);
return () => clearTimeout(t);
}, [authorizeModal?.probeResult]);
// When the modal is open and the user tabs back into Hive, opportunistically
// resync in silent mode — if the new account is there we auto-close; if not,
// we stay in "waiting" so the user isn't scolded for alt-tabbing mid-flow.
useEffect(() => {
if (!authorizeModal || authorizeModal.status === "checking") return;
const onFocus = () => {
runResyncCheck(true);
};
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [authorizeModal, runResyncCheck]);
// Filtered specs
const filtered = useMemo(() => {
if (!searchQuery.trim()) return specs;
@@ -302,6 +431,15 @@ export default function CredentialsPage() {
const isEditing = group.specs.some((s) => editingId === s.credential_id);
const isDeleting = group.specs.some((s) => deletingId === s.credential_id);
const hasInstructions = primary.api_key_instructions || primary.help_url;
// Pure-OAuth providers (Aden-backed, no API key paste path) render each
// connected account as its own row and expose an "Add another account"
// affordance. Accounts are sourced from Aden, so lifecycle (add/remove)
// happens on hive.adenhq.com; Hive just mirrors what it sees.
const isPureOAuth =
primary.aden_supported &&
!primary.direct_api_key_supported &&
primary.credential_id !== "aden_api_key";
const accounts = primary.accounts ?? [];
return (
<div
@@ -411,6 +549,37 @@ export default function CredentialsPage() {
</div>
) : null
)
) : isConnected && isPureOAuth && accounts.length > 0 ? (
<div className="flex flex-col gap-1.5">
{accounts.map((acct) => {
const email = acct.identity?.email || "";
const label = email || acct.alias || "connected";
const sub = email && acct.alias && acct.alias !== email ? acct.alias : "";
return (
<div
key={`${acct.provider}:${acct.alias}:${acct.credential_id}`}
className="flex items-center justify-between text-xs"
>
<div className="flex items-center gap-1.5 text-muted-foreground min-w-0">
<KeyRound className="w-3 h-3 flex-shrink-0" />
<span className="truncate text-foreground">{label}</span>
{sub && (
<span className="truncate text-muted-foreground/60">
· {sub}
</span>
)}
</div>
</div>
);
})}
<button
onClick={() => handleConnect(primary)}
className="flex items-center gap-1 text-[11px] font-medium text-primary hover:text-primary/80 transition-colors mt-0.5 self-start"
>
<Plus className="w-3 h-3" />
Add another account
</button>
</div>
) : isConnected ? (
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
@@ -644,6 +813,223 @@ export default function CredentialsPage() {
</>
)}
{/* Authorize modal — blocks while the user finishes OAuth on Aden. */}
{authorizeModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-[420px] max-w-[92vw] rounded-2xl border border-border/60 bg-card shadow-xl overflow-hidden">
<div className="flex items-start justify-between px-5 pt-5 pb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-muted/40 flex items-center justify-center text-xl">
{getCredIcon(authorizeModal.spec.credential_id)}
</div>
<div>
<h3 className="text-sm font-semibold text-foreground">
Connect {authorizeModal.spec.credential_name}
</h3>
<p className="text-[11px] text-muted-foreground mt-0.5">
Authorization happens on the Aden platform
</p>
</div>
</div>
{authorizeModal.status !== "checking" && (
<button
onClick={() => setAuthorizeModal(null)}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="px-5 pb-5">
{/* Detected accounts list — always shown so the user can see
exactly what Hive currently sees on Aden's side. New rows
(not in the open-time snapshot) are badged. */}
{(() => {
const before = new Set(authorizeModal.snapshotAliases);
const rows = authorizeModal.currentAccounts;
return (
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Detected {authorizeModal.spec.credential_name} accounts
</div>
{authorizeModal.probing ? (
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin" />
Checking
</div>
) : authorizeModal.probeResult ? (
<div className="text-[10px] text-muted-foreground">
{authorizeModal.probeResult}
</div>
) : null}
</div>
{rows.length === 0 ? (
<div className="text-xs text-muted-foreground italic px-3 py-2.5 rounded-lg border border-border/40 bg-muted/20">
None yet
</div>
) : (
<ul className="flex flex-col gap-1.5">
{rows.map((acct) => {
const isNew = !before.has(acct.alias);
const email = acct.identity?.email || "";
const label = email || acct.alias || "connected";
const sub =
email && acct.alias && acct.alias !== email
? acct.alias
: "";
return (
<li
key={`${acct.provider}:${acct.alias}:${acct.credential_id}`}
className={`flex items-center justify-between gap-2 px-3 py-2 rounded-lg border text-xs ${
isNew
? "border-emerald-500/40 bg-emerald-500/5"
: "border-border/40 bg-muted/20"
}`}
>
<div className="flex items-center gap-2 min-w-0">
<KeyRound
className={`w-3 h-3 flex-shrink-0 ${
isNew
? "text-emerald-600"
: "text-muted-foreground"
}`}
/>
<span className="truncate text-foreground">
{label}
</span>
{sub && (
<span className="truncate text-muted-foreground/60">
· {sub}
</span>
)}
</div>
{isNew && (
<span className="text-[10px] font-semibold uppercase tracking-wider text-emerald-600 flex-shrink-0">
New
</span>
)}
</li>
);
})}
</ul>
)}
</div>
);
})()}
{authorizeModal.status === "waiting" && (
<>
<p className="text-xs text-muted-foreground mb-4">
Finish signing in to{" "}
{authorizeModal.spec.credential_name} in the Aden tab that
just opened, then click <em>I&apos;ve authorized</em> to
sync.
</p>
<div className="flex items-center gap-2 justify-end">
<button
onClick={() => setAuthorizeModal(null)}
className="px-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted transition-colors"
>
Cancel
</button>
<button
onClick={() =>
window.open(
"https://hive.adenhq.com/",
"_blank",
"noopener"
)
}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium border border-border/60 text-foreground hover:bg-muted/50 transition-colors"
>
<ExternalLink className="w-3 h-3" />
Reopen Aden
</button>
<button
onClick={() => runResyncCheck(false)}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
I&apos;ve authorized
</button>
</div>
</>
)}
{authorizeModal.status === "checking" && (
<div className="flex items-center gap-3 py-4 text-xs text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
Syncing from Aden
</div>
)}
{authorizeModal.status === "not_found" && (
<>
<p className="text-xs text-muted-foreground mb-4">
No new account detected yet. Please finish the
authentication step in the other tab, then retry.
</p>
<div className="flex items-center gap-2 justify-end">
<button
onClick={() => setAuthorizeModal(null)}
className="px-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted transition-colors"
>
Cancel
</button>
<button
onClick={() =>
window.open(
"https://hive.adenhq.com/",
"_blank",
"noopener"
)
}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium border border-border/60 text-foreground hover:bg-muted/50 transition-colors"
>
<ExternalLink className="w-3 h-3" />
Reopen Aden
</button>
<button
onClick={() => runResyncCheck(false)}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Retry
</button>
</div>
</>
)}
{authorizeModal.status === "success" && authorizeModal.newAccount && (
<>
<div className="flex items-center gap-2 mb-4 text-xs">
<div className="w-5 h-5 rounded-full bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
<Check className="w-3 h-3 text-emerald-600" />
</div>
<span className="text-foreground">
Connected as{" "}
<strong>
{authorizeModal.newAccount.identity?.email ||
authorizeModal.newAccount.alias}
</strong>
</span>
</div>
<div className="flex items-center justify-end">
<button
onClick={() => setAuthorizeModal(null)}
className="flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold bg-emerald-600 text-white hover:bg-emerald-600/90 transition-colors"
>
<Check className="w-3 h-3" />
Finish
</button>
</div>
</>
)}
</div>
</div>
</div>
)}
<SettingsModal
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
+1 -1
View File
@@ -22,7 +22,7 @@ DISCORD_CREDENTIALS = {
startup_required=False,
help_url="https://discord.com/developers/applications",
description="Discord Bot Token",
aden_supported=True,
aden_supported=False,
aden_provider_name="discord",
direct_api_key_supported=True,
api_key_instructions="""To get a Discord Bot Token:
+1 -1
View File
@@ -21,7 +21,7 @@ POSTGRES_CREDENTIALS = {
startup_required=False,
help_url="https://www.postgresql.org/docs/current/libpq-connect.html",
description="PostgreSQL connection string (postgresql://user:pass@host:port/db)",
aden_supported=True,
aden_supported=False,
aden_provider_name="postgres",
direct_api_key_supported=False,
api_key_instructions="""Provide a PostgreSQL connection string:
@@ -104,6 +104,9 @@ class CredentialStoreAdapter:
Raises:
KeyError: If the credential name is not in specs
CredentialExpiredError: If the credential is expired and refresh failed.
Tool runners catch this and emit a structured ``credential_expired``
tool result so the agent can ask the user to reauthorize.
"""
if name not in self._specs:
raise KeyError(f"Unknown credential '{name}'. Available: {list(self._specs.keys())}")
@@ -118,7 +121,19 @@ class CredentialStoreAdapter:
except Exception:
pass # Fall through to standard store lookup
return self._store.get(name)
try:
return self._store.get(name, raise_on_refresh_failure=True)
except Exception as exc:
# CredentialExpiredError must propagate for the tool runner to
# convert into a structured result. Only enrich help_url here
# so the runner does not need to import specs.
from framework.credentials.models import CredentialExpiredError
if isinstance(exc, CredentialExpiredError) and exc.help_url is None:
spec = self._specs.get(name)
if spec is not None:
exc.help_url = spec.help_url
raise
def get_spec(self, name: str) -> CredentialSpec:
"""Get the spec for a credential."""
@@ -331,9 +346,31 @@ class CredentialStoreAdapter:
return dict(self._tool_to_cred)
def get_by_alias(self, provider_name: str, alias: str) -> str | None:
"""Resolve a specific account's token by alias."""
"""Resolve a specific account's token by alias.
Raises:
CredentialExpiredError: If the matched credential is expired and
refresh failed.
"""
cred = self._store.get_credential_by_alias(provider_name, alias)
return cred.get_default_key() if cred else None
if cred is None:
return None
# Re-fetch through get_credential so refresh-on-access fires with
# raise_on_refresh_failure semantics. Aliased lookups otherwise skip
# the refresh path.
try:
refreshed = self._store.get_credential(
cred.id, raise_on_refresh_failure=True
)
except Exception as exc:
from framework.credentials.models import CredentialExpiredError
if isinstance(exc, CredentialExpiredError) and exc.help_url is None:
spec = self._specs.get(provider_name)
if spec is not None:
exc.help_url = spec.help_url
raise
return refreshed.get_default_key() if refreshed else None
def get_by_identity(self, provider_name: str, label: str) -> str | None:
"""Alias for get_by_alias (backward compat)."""