diff --git a/hermes_cli/nous_account.py b/hermes_cli/nous_account.py index 926bba25b4..04adfb464c 100644 --- a/hermes_cli/nous_account.py +++ b/hermes_cli/nous_account.py @@ -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")), ) diff --git a/tests/tools/test_connectors_gateway_merge.py b/tests/tools/test_connectors_gateway_merge.py index d9b82df07e..b184373b70 100644 --- a/tests/tools/test_connectors_gateway_merge.py +++ b/tests/tools/test_connectors_gateway_merge.py @@ -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 diff --git a/tests/tools/test_connectors_tool.py b/tests/tools/test_connectors_tool.py index 8ab2b6476f..39bf8624f7 100644 --- a/tests/tools/test_connectors_tool.py +++ b/tests/tools/test_connectors_tool.py @@ -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"})) diff --git a/tools/connectors/gateway/config.py b/tools/connectors/gateway/config.py index 8b5432ea32..6850730897 100644 --- a/tools/connectors/gateway/config.py +++ b/tools/connectors/gateway/config.py @@ -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) diff --git a/tools/connectors/tool.py b/tools/connectors/tool.py index 7ce7805abe..82ca38dd37 100644 --- a/tools/connectors/tool.py +++ b/tools/connectors/tool.py @@ -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="🔗", ) diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index 02eb7ccc6f..49cff5f2a0 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -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 ` / `hermes mcp login ` 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