Merge remote-tracking branch 'origin/feature/new-colony-credentials' into feature/new-colony
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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", {}),
|
||||
};
|
||||
|
||||
@@ -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'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'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)}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user