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:
Siddharth Balyan
2026-09-15 03:31:32 +05:30
committed by GitHub
parent 7d7ba4ba35
commit cf35e7351e
6 changed files with 77 additions and 11 deletions

View File

@@ -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")),
)

View File

@@ -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

View File

@@ -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"}))

View File

@@ -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)

View File

@@ -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="🔗",
)

View File

@@ -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