fix(connectors): manage_connections is absent for accounts the portal has not enabled (#111238)
A signed-in, paid Nous account that the portal had not enabled for connectors got `manage_connections` in its schema and a raw "tool gateway request failed with status 404" back from every call. The gateway answers 404 for any such account by design, and Hermes gated the tool on paid access or a free tool pool, which says nothing about that. The gate now reads the portal's own answer: a `managed_tools` token claim, plus the existing free-tier leg. The gate is also the tool's check_fn, so a session without the claim never sees the tool and the model has no 404 to narrate. A token without the claim reads as not enabled.
This commit is contained in:
@@ -105,6 +105,9 @@ class NousPortalAccountInfo:
|
||||
error: Optional[str] = None
|
||||
# NAS account tier claim; ``"anonymous"`` is the free tier (no Nous account behind it).
|
||||
account_tier: Optional[str] = None
|
||||
# Portal ``managed_tools`` (JWT claim and account API): the portal has enabled connectors for
|
||||
# this account. ``None`` = the portal did not say (a token minted before the claim shipped).
|
||||
managed_tools: Optional[bool] = None
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
@@ -129,6 +132,11 @@ class NousPortalAccountInfo:
|
||||
ta = self.tool_access
|
||||
return self.paid_service_access is True or bool(ta and ta.enabled and ta.coverage.get(category) is True)
|
||||
|
||||
@property
|
||||
def managed_tools_rolled_out(self) -> bool:
|
||||
"""Only a literal ``true`` from the portal counts; absent and unknown both read as out."""
|
||||
return self.managed_tools is True
|
||||
|
||||
|
||||
def nous_portal_billing_url(account_info: Optional[NousPortalAccountInfo] = None) -> str:
|
||||
"""Return the billing URL for a normalized Nous account snapshot."""
|
||||
@@ -503,6 +511,7 @@ def _info_from_valid_jwt(
|
||||
tool_access=_tool_access_from_value(claims.get("tool_access")),
|
||||
raw_claims=dict(claims),
|
||||
account_tier=_coerce_str(claims.get("account_tier")) or _coerce_str(state.get("account_tier")),
|
||||
managed_tools=_coerce_bool(claims.get("managed_tools")),
|
||||
)
|
||||
|
||||
|
||||
@@ -533,6 +542,7 @@ def _info_from_account_payload(
|
||||
raw_account=dict(payload),
|
||||
account_tier=_coerce_str(payload.get("account_tier")) or _coerce_str(user.get("account_tier"))
|
||||
or _coerce_str(state.get("account_tier")),
|
||||
managed_tools=_coerce_bool(payload.get("managed_tools")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -304,3 +304,33 @@ def test_connectors_available_requires_both_legs_and_fails_closed():
|
||||
|
||||
assert connectors_available(config_loader=on, entitlement_check=boom) is False
|
||||
assert connectors_available(config_loader=boom, entitlement_check=lambda: True) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"claims, rolled_out",
|
||||
[
|
||||
# Paid access alone does not enable connectors: the gateway 404s this account.
|
||||
({"paid_access": True, "tool_access": {"enabled": True, "coverage": {}}}, False),
|
||||
({"paid_access": True, "managed_tools": True}, True),
|
||||
({"paid_access": False, "managed_tools": True}, True),
|
||||
({"managed_tools": False}, False),
|
||||
({"managed_tools": "true"}, False), # only a literal boolean counts
|
||||
],
|
||||
)
|
||||
def test_account_gate_reads_the_portal_claim_not_entitlement(monkeypatch, claims, rolled_out):
|
||||
"""The account leg mirrors the gateway's own gate: the ``managed_tools`` claim the portal
|
||||
mints. A token without it is not enabled however entitled it is."""
|
||||
import time
|
||||
|
||||
from hermes_cli import nous_account
|
||||
from tools.connectors.gateway.config import managed_tools_rolled_out
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._decode_jwt_claims", lambda token: {"exp": time.time() + 3600, **claims})
|
||||
account = nous_account._info_from_valid_jwt("tok", {}, None, 60)
|
||||
assert account is not None and account.logged_in
|
||||
|
||||
monkeypatch.setattr(nous_account, "get_nous_portal_account_info", lambda **kw: account)
|
||||
assert managed_tools_rolled_out() is rolled_out
|
||||
assert connectors_available(config_loader=lambda: ConnectorConfig(enabled=True),
|
||||
entitlement_check=managed_tools_rolled_out) is rolled_out
|
||||
|
||||
@@ -225,9 +225,11 @@ def test_focus_mode_coding_posture_gets_the_tool(monkeypatch):
|
||||
assert "manage_connections" in _session_tool_names(selection, connectors=True)
|
||||
|
||||
|
||||
def test_signed_out_session_keeps_the_tool_but_the_managed_leg_refuses(tmp_path, monkeypatch):
|
||||
"""The portal gate moved from check_fn into the managed leg: local MCP approvals need no
|
||||
sign-in, so the schema stays; a managed action in a signed-out session is a plain error."""
|
||||
def test_session_the_portal_has_not_enabled_never_receives_the_tool(tmp_path, monkeypatch):
|
||||
"""The portal gate is the tool's check_fn: a session whose account the portal has not enabled
|
||||
for connectors does not get ``manage_connections`` in its schema on any surface, so the
|
||||
model cannot call it and read the gateway's 404 back to the user. The handler keeps the same
|
||||
gate for the direct RPC path."""
|
||||
from hermes_cli.tools_config import _get_platform_tools
|
||||
from tools.registry import registry
|
||||
from tui_gateway.server import _load_enabled_toolsets
|
||||
@@ -237,10 +239,11 @@ def test_signed_out_session_keeps_the_tool_but_the_managed_leg_refuses(tmp_path,
|
||||
selections = [
|
||||
sorted(_get_platform_tools({}, "cli", include_default_mcp_servers=True)),
|
||||
_load_enabled_toolsets("tui"),
|
||||
_load_enabled_toolsets("desktop"),
|
||||
["coding"],
|
||||
]
|
||||
for selection in selections:
|
||||
assert "manage_connections" in _session_tool_names(selection, connectors=False), selection
|
||||
assert "manage_connections" not in _session_tool_names(selection, connectors=False), selection
|
||||
|
||||
with patch("tools.connectors.gateway.config.connectors_available", return_value=False):
|
||||
out = json.loads(registry.dispatch("manage_connections", {"action": "status"}))
|
||||
|
||||
@@ -65,11 +65,30 @@ def load_config() -> ConnectorConfig:
|
||||
return ConnectorConfig.from_raw(None)
|
||||
|
||||
|
||||
def managed_tools_rolled_out() -> bool:
|
||||
"""The portal has enabled connectors for this account.
|
||||
|
||||
The gateway answers 404 to every ``/v1/connectors`` route for an account the portal has not
|
||||
enabled, and 404 is indistinguishable from "dark" by design. Paid access or a free tool pool
|
||||
says nothing about that, so entitlement is the wrong predicate here: the portal mints its
|
||||
answer onto the token as ``managed_tools`` and this reads only that. A token minted before
|
||||
the claim existed carries none and reads as not enabled."""
|
||||
from hermes_cli.nous_account import get_nous_portal_account_info
|
||||
|
||||
account_info = get_nous_portal_account_info()
|
||||
return bool(account_info.logged_in) and account_info.managed_tools_rolled_out
|
||||
|
||||
|
||||
def connectors_available(
|
||||
config_loader: Optional[Callable[[], ConnectorConfig]] = None,
|
||||
entitlement_check: Optional[Callable[[], bool]] = None,
|
||||
) -> bool:
|
||||
"""Fail closed so availability failures do not become model-visible errors."""
|
||||
"""Fail closed so availability failures do not become model-visible errors.
|
||||
|
||||
The one gate for the connectors surface, and the tool's ``check_fn``: outside it the tool is
|
||||
not in the schema at all, so the model never narrates a gateway 404 to a user the portal has
|
||||
not enabled. Free-tier identities are always in; accounts are in only when the portal says so
|
||||
via the token claim."""
|
||||
try:
|
||||
resolved_loader = config_loader or load_config
|
||||
if not resolved_loader().enabled:
|
||||
@@ -77,13 +96,12 @@ def connectors_available(
|
||||
if entitlement_check is None:
|
||||
from hermes_cli.anon_auth import is_guest_state
|
||||
from tools.managed_tool_gateway import _read_nous_provider_state
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||
|
||||
# Availability must not mint or refresh an identity.
|
||||
if is_guest_state(_read_nous_provider_state()):
|
||||
return True
|
||||
|
||||
entitlement_check = managed_nous_tools_enabled
|
||||
entitlement_check = managed_tools_rolled_out
|
||||
return bool(entitlement_check())
|
||||
except Exception as e:
|
||||
logger.debug("Connector availability check failed: %s", e)
|
||||
|
||||
@@ -112,10 +112,14 @@ registry.register(
|
||||
name="manage_connections",
|
||||
toolset="connections",
|
||||
schema=MANAGE_CONNECTIONS_SCHEMA,
|
||||
# Keep the portal gate in the handler so signed-out sessions retain MCP approvals.
|
||||
# Read the module attribute so tests patch ``gateway.config.connectors_available`` at one seam.
|
||||
# The portal gate decides schema presence: an account the portal has not enabled for
|
||||
# connectors never sees the tool, so the model cannot call it and read the gateway's
|
||||
# 404 back to them. The handler runs the same gate so the RPC path (methods_connectors) and
|
||||
# a cached schema agree. Read as a module attribute so tests patch
|
||||
# ``gateway.config.connectors_available`` at one seam.
|
||||
handler=lambda args, **kw: manage_connections(
|
||||
args, session_id=kw.get("session_id"), connectors_available=gateway_config.connectors_available,
|
||||
),
|
||||
check_fn=lambda: gateway_config.connectors_available(),
|
||||
emoji="🔗",
|
||||
)
|
||||
|
||||
@@ -67,8 +67,9 @@ One tool for both kinds of external app. A target is a managed connector (`"gmai
|
||||
| `manage_connections` | Managed actions: `status`, `connect`, `reconnect` (repairs only what is not connected; `force: true` restarts a working one). MCP actions, for `mcp: true` targets only: `install` a catalog entry, `enable` a disabled configured server, `authorize` (OAuth). On the desktop every action shows a card and blocks until each target is connected, skipped, or the deadline passes; the result lists targets as `connected`, `skipped` or `not_connected` and carries no link. On surfaces with no card (CLI, TUI, messaging) managed targets return a `connect_url` per app for the user to open, and MCP targets return `unavailable` with the `hermes mcp install <name>` / `hermes mcp login <name>` commands. Cannot disconnect or revoke an account. | — |
|
||||
|
||||
The deadline for one call is five minutes, fixed by the backend when the call starts;
|
||||
reopening the chat or restarting the desktop never extends it. Managed actions additionally
|
||||
need the portal sign-in the managed tools use; MCP approvals do not.
|
||||
reopening the chat or restarting the desktop never extends it. The tool is present only when the
|
||||
Nous Portal has enabled connectors for the signed-in account (the `managed_tools` claim on its
|
||||
token). Other sessions do not see it.
|
||||
|
||||
## `code_execution` toolset
|
||||
|
||||
|
||||
Reference in New Issue
Block a user