mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
feat(connectors): a guest identity is created on the first connector use, on every surface
A user with no Nous sign-in could use connectors only inside the store desktop build, where the boot bootstrap mints a guest because HERMES_GUEST_ONBOARDING=1. On the plain CLI, TUI, messaging gateway and other desktop builds the manage_connections tool was hidden, so connectors were unreachable. Now any user with no Nous identity sees manage_connections, and the guest identity is created when they first ask for an app: - connectors_available() also admits a user with no identity who may get one (guest_identity_pending: nous.guest is not false, no bearer, no terminal mint refusal). No network, so the check_fn stays cheap. - managed.managed_client(), the real gateway client behind every hosted manage_connections action, calls ensure_guest_identity() first. The mint is synchronous and bounded by the existing 5 s budget; a failure returns the free-tier copy with its wait. - The desktop Connectors page RPCs (both account gates) create it the same way: opening the page is the user asking for apps, and the page has no rows to click without an identity. - The model's read legs (tool_search, tool_describe, connector calls) never mint. With no identity they make no request and answer not_set_up, whose hint names manage_connections. Before, the same case said "sign in to Nous again". The free model is unchanged: guest_enabled() still requires the launch gate, so a guest created for connectors never becomes the inference provider. Only the connectors path reads the new guest_allowed() (nous.guest), through managed_tool_gateway._read_nous_provider_state. Boot minting is unchanged. The setup chat's apps card no longer mints on its own; its rows come from the connectors.list RPC, which now creates the identity.
This commit is contained in:
1 parent
ff2d02f998
commit
d7830c6f42
15 files changed
+221
-66
No files matched your search
+23
-15
@@ -1,7 +1,9 @@
|
||||
"""Nous free-tier identity: the ``anonymous`` auth method of the ``nous`` provider.
|
||||
|
||||
The identity is created in exactly one place, at boot (``hermes_cli.free_tier_bootstrap``), and only
|
||||
while ``HERMES_GUEST_ONBOARDING=1`` (see ``guest_enabled``). The bootstrap mints an anonymous Nous
|
||||
The identity is created in two places: at boot (``hermes_cli.free_tier_bootstrap``) while
|
||||
``HERMES_GUEST_ONBOARDING=1`` (see ``guest_enabled``), and on the first hosted connector action for
|
||||
anyone without a Nous identity (``tools.connectors.gateway.config.ensure_guest_identity``, gated only
|
||||
by ``guest_allowed``). Either mints an anonymous Nous
|
||||
account (``POST /api/anonymous/create``); its ``anon_`` credential is later exchanged for short-lived
|
||||
JWTs (``POST /api/anonymous/token``). The result is persisted as the singleton ``providers.nous``. In
|
||||
the resolver ladder (``resolve_provider``) an existing free-tier identity sits directly above the
|
||||
@@ -155,15 +157,18 @@ def anon_failure_copy(code: str, *, retry_after: Any = None) -> str:
|
||||
|
||||
|
||||
def guest_enabled() -> bool:
|
||||
"""The free tier is on for this process: the launch gate is set AND ``nous.guest`` (default
|
||||
True) has not switched it off."""
|
||||
if (os.environ.get(GUEST_ONBOARDING_ENV) or "").strip() != "1":
|
||||
return False
|
||||
"""The free tier is on for this process: the launch gate is set AND :func:`guest_allowed`."""
|
||||
return (os.environ.get(GUEST_ONBOARDING_ENV) or "").strip() == "1" and guest_allowed()
|
||||
|
||||
|
||||
def guest_allowed() -> bool:
|
||||
"""``nous.guest`` (default True) has not switched guests off. Enough for a guest used only for
|
||||
connectors; the free model and the boot mint also need the launch gate (:func:`guest_enabled`)."""
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
nous_cfg = load_config_readonly().get("nous")
|
||||
except Exception as exc: # config unreadable: keep today's behaviour (no guest) rather than mint
|
||||
logger.debug("guest: config unreadable, treating nous.guest as false: %s", exc)
|
||||
except Exception: # config unreadable: keep today's behaviour (no guest) rather than mint
|
||||
logger.debug("guest: config unreadable, treating nous.guest as false", exc_info=True)
|
||||
return False
|
||||
if not isinstance(nous_cfg, dict):
|
||||
return True
|
||||
@@ -624,18 +629,21 @@ def _reconcile_and_provision(*, timeout_seconds: float) -> Optional[dict[str, An
|
||||
|
||||
def ensure_portal_identity(
|
||||
*, explicit: bool, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS, force: bool = False,
|
||||
for_connectors: bool = False,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Make sure this profile has a Nous identity (guest or account); mint a guest only if the shared
|
||||
store has none. Returns the ``providers.nous`` state, or None (disabled / failed once already).
|
||||
|
||||
``explicit`` is required and must be True: the only callers are the boot bootstrap
|
||||
(``free_tier_bootstrap.run_bootstrap``), the desktop's ``free_tier.provision`` retry, the setup
|
||||
chat's apps card (``setup_choose_tool._connectors_closed``, one attempt), and the
|
||||
dead-credential replacements (``auth_nous.resolve_nous_runtime_credentials``,
|
||||
``managed_tool_gateway._replace_dead_guest_token``). Nothing creates an identity as a side effect
|
||||
of reading status, resolving a provider or fetching a connector bearer (NS-845 Q1.2).
|
||||
(``free_tier_bootstrap.run_bootstrap``), the desktop's ``free_tier.provision`` retry, the first
|
||||
hosted connector action (``tools.connectors.gateway.config.ensure_guest_identity``), and the
|
||||
dead-credential replacements
|
||||
(``auth_nous.resolve_nous_runtime_credentials``, ``managed_tool_gateway._replace_dead_guest_token``).
|
||||
Nothing creates an identity as a side effect of reading status, resolving a provider or fetching a
|
||||
connector bearer (NS-845 Q1.2).
|
||||
|
||||
Order: ``guest_enabled`` gate -> reconcile with the shared store -> mint. Locks are taken profile
|
||||
Order: gate -> reconcile with the shared store -> mint. The gate is ``guest_enabled``, or only
|
||||
``guest_allowed`` with ``for_connectors=True``: a connectors guest never opens the free model. Locks are taken profile
|
||||
first, then shared, matching every other Nous path. Blocking, bounded by ``timeout_seconds``; the bootstrap puts it on its own thread.
|
||||
|
||||
A failed mint is memoised with a cooldown (``MintFailure``): until it passes, and for a
|
||||
@@ -646,7 +654,7 @@ def ensure_portal_identity(
|
||||
"""
|
||||
if not explicit:
|
||||
raise ValueError("ensure_portal_identity: only explicit creators may call this (explicit=True)")
|
||||
if not guest_enabled():
|
||||
if not (guest_allowed() if for_connectors else guest_enabled()):
|
||||
return None
|
||||
failure = _mint_failure_for_profile()
|
||||
if failure and not force and not current_nous_state() and time.monotonic() < failure.not_before:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Serve-start bootstrap for the Nous free tier: the ONE place a free-tier identity is created.
|
||||
"""Serve-start bootstrap for the Nous free tier: the one place a free-tier identity that carries
|
||||
inference is created.
|
||||
|
||||
Every Hermes process that may need the free tier runs this once at boot (``hermes serve`` on a
|
||||
daemon thread beside the other background boots; the CLI first-run guard synchronously). It
|
||||
@@ -6,7 +7,9 @@ inventories credentials cheap-first, creates the identity only when the launch g
|
||||
(:func:`hermes_cli.anon_auth.guest_enabled`), resolves which provider carries inference, records
|
||||
the answer in process memory, and tells every connected client with one ``setup.ready`` event.
|
||||
|
||||
Nothing else mints. ``free_tier.status`` and ``setup.status`` read the record; provider resolution
|
||||
Besides this boot path, only the first hosted connector action mints, and only for a user with no
|
||||
Nous identity (``tools.connectors.gateway.config.ensure_guest_identity``; that guest never opens the
|
||||
free model). ``free_tier.status`` and ``setup.status`` read the record; provider resolution
|
||||
never reaches the portal; a dead credential is replaced by the explicit re-mint in
|
||||
``auth_nous.resolve_nous_runtime_credentials``. Ruling: NS-845 Q1.2 (recorded on NS-847).
|
||||
"""
|
||||
|
||||
+1
-1
@@ -1092,7 +1092,7 @@ def _auth_store_logged_in(auth_file: Path, registry, strict_profile_scope: bool)
|
||||
def _has_any_provider_configured(*, strict_profile_scope: bool = False) -> bool:
|
||||
"""Check if at least one inference provider is usable. Never creates one: the Nous free tier
|
||||
counts only once its identity exists, and the boot bootstrap (``hermes_cli.free_tier_bootstrap``)
|
||||
is the only thing that creates it; ``cmd_chat`` runs the bootstrap before asking.
|
||||
is the only thing that creates one that carries inference; ``cmd_chat`` runs the bootstrap before asking.
|
||||
|
||||
``strict_profile_scope``: the caller has bound a NAMED profile's home and
|
||||
secret scope and wants an answer for that profile only — launch-process
|
||||
|
||||
@@ -295,7 +295,7 @@ async def _lifespan(app: FastAPI):
|
||||
|
||||
threading.Thread(target=_boot_local_runtime, daemon=True, name="local-runtime-boot").start()
|
||||
|
||||
# Nous free tier: the ONE place its identity is created. Inventories credentials, mints only
|
||||
# Nous free tier: the one boot-time creator of its identity. Inventories credentials, mints only
|
||||
# when HERMES_GUEST_ONBOARDING=1, records the answer for setup.status / free_tier.status and
|
||||
# broadcasts `setup.ready`. Off-thread so a slow portal never delays the socket; the desktop's
|
||||
# first setup.status waits on the record (bounded) instead.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""A guest identity for connectors is created on the first hosted connector action.
|
||||
|
||||
Contracts, for a user with no Nous identity on a surface without the free-tier launch gate (plain
|
||||
CLI, TUI, messaging gateway):
|
||||
- ``manage_connections`` is offered, and its first hosted action creates exactly one guest whose
|
||||
token is connectors-only; later actions create none, and the free model is never selected
|
||||
- tool_search sends nothing to the portal or gateway and points the model at ``manage_connections``
|
||||
- ``nous.guest: false`` keeps the old answer: no tool, no account
|
||||
Driven through the fake NAS (``anon_portal``) with Hermes' real client code.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import anon_auth
|
||||
from tests.hermes_cli.anon_portal import install_portal
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nas(monkeypatch, tmp_path):
|
||||
fake = install_portal(monkeypatch, tmp_path)
|
||||
monkeypatch.delenv("HERMES_GUEST_ONBOARDING")
|
||||
return fake
|
||||
|
||||
|
||||
def test_first_hosted_action_creates_one_connectors_only_guest(nas, monkeypatch):
|
||||
from tools.connectors.gateway import client
|
||||
from tools.connectors.gateway.config import connectors_available
|
||||
from tools.connectors.tool import manage_connections
|
||||
from tools.managed_tool_gateway import read_nous_access_token
|
||||
|
||||
bearers = []
|
||||
|
||||
class Gateway:
|
||||
"""Reads the bearer the way the real gateway client does on every request."""
|
||||
|
||||
def list_connectors(self, *, timeout=None):
|
||||
bearers.append(read_nous_access_token())
|
||||
return [{"connector": "gmail", "enabled": True, "connected": False}]
|
||||
|
||||
monkeypatch.setattr(client, "ConnectorClient", Gateway)
|
||||
assert connectors_available()
|
||||
assert nas.creates() == 0
|
||||
|
||||
for _ in range(2):
|
||||
result = json.loads(manage_connections({"action": "status"}, connectors_available=connectors_available))
|
||||
assert result["connectors"][0]["connector"] == "gmail"
|
||||
|
||||
assert nas.creates() == 1
|
||||
assert [r["body"].get("purpose") for r in nas.token_requests] == ["connectors"]
|
||||
assert bearers and all(anon_auth.is_connectors_only(b) for b in bearers)
|
||||
assert anon_auth.has_guest() and not anon_auth.has_free_tier_account()
|
||||
|
||||
|
||||
def test_tool_search_without_an_identity_sends_nothing(nas, monkeypatch):
|
||||
from tools.connectors.tool import MANAGE_CONNECTIONS_SCHEMA
|
||||
from tools.tool_search import dispatch_tool_search
|
||||
|
||||
def no_client():
|
||||
raise AssertionError("no gateway client may be built before an identity exists")
|
||||
|
||||
monkeypatch.setattr("tools.connectors.gateway.client.ConnectorClient", no_client)
|
||||
tool_defs = [{"type": "function", "function": MANAGE_CONNECTIONS_SCHEMA}]
|
||||
|
||||
result = json.loads(dispatch_tool_search({"queries": ["gmail send email"]}, current_tool_defs=tool_defs))
|
||||
|
||||
assert result["connectors"]["status"] == "not_set_up"
|
||||
assert "manage_connections" in result["connectors"]["hint"]
|
||||
assert nas.calls == []
|
||||
|
||||
|
||||
def test_guest_opt_out_keeps_connectors_closed(nas):
|
||||
from hermes_constants import get_hermes_home
|
||||
from tools.connectors.gateway.config import connectors_available, ensure_guest_identity
|
||||
|
||||
(get_hermes_home() / "config.yaml").write_text("nous:\n guest: false\n", encoding="utf-8")
|
||||
|
||||
assert not connectors_available()
|
||||
assert ensure_guest_identity() is None
|
||||
assert nas.calls == []
|
||||
@@ -20,6 +20,8 @@ _LAZY_EXPORTS: dict[str, str] = {
|
||||
"connectors_available": "tools.connectors.gateway.config",
|
||||
"dispatch_connector_batch": "tools.connectors.dispatch",
|
||||
"dispatch_connector_call": "tools.connectors.dispatch",
|
||||
"ensure_guest_identity": "tools.connectors.gateway.config",
|
||||
"guest_identity_pending": "tools.connectors.gateway.config",
|
||||
"is_connector_name": "tools.connectors.gateway.names",
|
||||
"manage_connections": "tools.connectors.tool",
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from dataclasses import dataclass, field, replace as dataclass_replace
|
||||
from typing import Any, Callable, Optional, Sequence
|
||||
|
||||
from tools.connectors.gateway.config import connectors_available
|
||||
from tools.connectors.gateway.config import connectors_available, guest_identity_pending
|
||||
from tools.connectors.gateway.errors import GatewayAuthError, GatewayUnavailable, ToolGatewayError
|
||||
from tools.connectors.gateway.merge import fill_remote_failure, splice_remote_results
|
||||
from tools.connectors.gateway.names import parse_connector_name, vendor_slug_candidates
|
||||
@@ -13,8 +13,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
SIGN_IN_EXPIRED = "sign_in_expired"
|
||||
UNREACHABLE = "unreachable"
|
||||
# No Nous identity yet: the first hosted manage_connections action creates one.
|
||||
NOT_SET_UP = "not_set_up"
|
||||
NOT_SET_UP_MESSAGE = ("Hosted connectors are not set up for this user yet. "
|
||||
"Call manage_connections to connect the app first; that sets them up.")
|
||||
|
||||
__all__ = [
|
||||
"NOT_SET_UP",
|
||||
"NOT_SET_UP_MESSAGE",
|
||||
"SIGN_IN_EXPIRED",
|
||||
"UNREACHABLE",
|
||||
"ConnectorLeg",
|
||||
@@ -35,6 +41,8 @@ _TOKEN_REJECTED_CODES = frozenset({"UNAUTHORIZED", "INVALID_TOKEN", "TOKEN_EXPIR
|
||||
|
||||
|
||||
def _leg_failure(exc: Exception) -> Optional[str]:
|
||||
if isinstance(exc, GatewayAuthError) and exc.code == NOT_SET_UP:
|
||||
return NOT_SET_UP
|
||||
if isinstance(exc, GatewayAuthError):
|
||||
if exc.status == 401 or str(exc.code).upper() in _TOKEN_REJECTED_CODES:
|
||||
return SIGN_IN_EXPIRED
|
||||
@@ -43,8 +51,11 @@ def _leg_failure(exc: Exception) -> Optional[str]:
|
||||
|
||||
|
||||
def _default_client_factory():
|
||||
"""The real client for the model's read and execute legs. These never create an identity."""
|
||||
from tools.connectors.gateway.client import ConnectorClient
|
||||
|
||||
if guest_identity_pending():
|
||||
raise GatewayAuthError(NOT_SET_UP_MESSAGE, code=NOT_SET_UP)
|
||||
return ConnectorClient()
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ __all__ = [
|
||||
"MAX_CALLS_PER_DISPATCH",
|
||||
"ConnectorConfig",
|
||||
"connectors_available",
|
||||
"ensure_guest_identity",
|
||||
"guest_identity_pending",
|
||||
"load_config",
|
||||
]
|
||||
|
||||
@@ -87,8 +89,8 @@ def connectors_available(
|
||||
|
||||
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."""
|
||||
not enabled. Free-tier identities are always in, and so is a user with no identity who may get
|
||||
one; 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:
|
||||
@@ -98,7 +100,7 @@ def connectors_available(
|
||||
from tools.managed_tool_gateway import _read_nous_provider_state
|
||||
|
||||
# Availability must not mint or refresh an identity.
|
||||
if is_guest_state(_read_nous_provider_state()):
|
||||
if is_guest_state(_read_nous_provider_state()) or guest_identity_pending():
|
||||
return True
|
||||
|
||||
entitlement_check = managed_tools_rolled_out
|
||||
@@ -108,6 +110,37 @@ def connectors_available(
|
||||
return False
|
||||
|
||||
|
||||
def guest_identity_pending() -> bool:
|
||||
"""No Nous identity or bearer yet, and a guest one may be created (``nous.guest`` is not false
|
||||
and no earlier mint was refused for good). No network: this feeds the ``check_fn``."""
|
||||
from hermes_cli.anon_auth import guest_allowed, last_mint_failure
|
||||
from tools.managed_tool_gateway import _read_nous_provider_state, peek_nous_access_token
|
||||
|
||||
if not guest_allowed() or _read_nous_provider_state() is not None or peek_nous_access_token():
|
||||
return False
|
||||
failure = last_mint_failure()
|
||||
return not failure or bool(failure["retryable"])
|
||||
|
||||
|
||||
def ensure_guest_identity() -> Optional[str]:
|
||||
"""Create the guest identity on the first hosted connector action, synchronously.
|
||||
|
||||
Returns None when an identity exists or was just created, else the failure copy for the model
|
||||
(rate limit or portal down, with its wait)."""
|
||||
if not guest_identity_pending():
|
||||
return None
|
||||
from hermes_cli.anon_auth import ensure_portal_identity, last_mint_failure
|
||||
from hermes_cli.auth_constants import AuthError
|
||||
|
||||
try:
|
||||
if ensure_portal_identity(explicit=True, for_connectors=True) is not None:
|
||||
return None
|
||||
except AuthError as exc:
|
||||
return str(exc)
|
||||
# Still inside the cooldown of an earlier failure: report that one.
|
||||
return str((last_mint_failure() or {}).get("error") or "Connectors could not be set up. Try again shortly.")
|
||||
|
||||
|
||||
def operation_session_key(session_id: Optional[str]) -> str:
|
||||
"""The key an operation is registered under: the gateway session key the RPCs look up by
|
||||
(``HERMES_SESSION_KEY``), falling back to the agent's session id where no gateway bound one.
|
||||
|
||||
@@ -6,7 +6,7 @@ import time
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from tools.connectors.contract import Actor, SettleReason, TargetState, allowed
|
||||
from tools.connectors.gateway.config import operation_session_key
|
||||
from tools.connectors.gateway.config import ensure_guest_identity, operation_session_key
|
||||
from tools.connectors.gateway.errors import RateLimited, ToolGatewayError
|
||||
from tools.connectors.operation import ConnectionOperation, DetachedOperation, IllegalTransition, Target
|
||||
from tools.connectors.run import Kind, run_operation
|
||||
@@ -43,9 +43,17 @@ NOTE = (
|
||||
)
|
||||
|
||||
|
||||
def managed_client():
|
||||
from tools.connectors.gateway.client import ConnectorClient
|
||||
GUEST_SETUP_FAILED = "GUEST_SETUP_FAILED"
|
||||
|
||||
|
||||
def managed_client():
|
||||
"""The real gateway client for a user action. The first one creates the guest identity when the
|
||||
user has none: every hosted action is a request for an app."""
|
||||
from tools.connectors.gateway.client import ConnectorClient
|
||||
from tools.connectors.gateway.errors import ToolGatewayError
|
||||
|
||||
if setup_error := ensure_guest_identity():
|
||||
raise ToolGatewayError(setup_error, code=GUEST_SETUP_FAILED, retryable=True)
|
||||
return ConnectorClient()
|
||||
|
||||
|
||||
@@ -378,6 +386,8 @@ def run_managed_action(
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("manage_connections %s failed: %s", action, exc)
|
||||
if getattr(exc, "code", None) == GUEST_SETUP_FAILED:
|
||||
return tool_error(str(exc))
|
||||
return tool_error(
|
||||
f"The connector gateway request failed: {exc}. "
|
||||
"If this persists, the user can manage connections in the Nous Portal."
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
from tools.connectors.gateway.bridge import SIGN_IN_EXPIRED, UNREACHABLE
|
||||
from tools.connectors.gateway.bridge import NOT_SET_UP, NOT_SET_UP_MESSAGE, SIGN_IN_EXPIRED, UNREACHABLE
|
||||
from tools.connectors.gateway.names import format_connector_name, is_connector_name, vendor_slug_candidates
|
||||
from tools.tool_search_catalog import CatalogEntry, _fn, _tokenize
|
||||
|
||||
@@ -16,11 +16,18 @@ def connections_in_scope(tool_defs: Iterable[dict[str, Any]]) -> bool:
|
||||
|
||||
def connectors_unavailable(failure: str, *, verb: str,
|
||||
names: Optional[list[str]] = None) -> dict[str, Any]:
|
||||
if failure == NOT_SET_UP:
|
||||
field: dict[str, Any] = {"status": NOT_SET_UP, "hint": (
|
||||
f"{NOT_SET_UP_MESSAGE} Hosted apps (Gmail, Calendar, Notion and more) are then searchable. "
|
||||
"If the request is better served by the tools above, use those instead.")}
|
||||
if names:
|
||||
field["names"] = names
|
||||
return field
|
||||
hint = (f"Hosted connector tools could not be {verb} right now. "
|
||||
"Do not conclude the app is missing.")
|
||||
if failure == SIGN_IN_EXPIRED:
|
||||
hint += " The user must sign in to Nous again."
|
||||
field: dict[str, Any] = {"status": "unavailable", "reason": failure, "hint": hint}
|
||||
field = {"status": "unavailable", "reason": failure, "hint": hint}
|
||||
if names:
|
||||
field["names"] = names
|
||||
return field
|
||||
|
||||
@@ -37,7 +37,7 @@ def auth_json_path():
|
||||
|
||||
|
||||
def _read_nous_provider_state() -> Optional[dict]:
|
||||
"""The profile's Nous state, or None. A free-tier identity counts only while the free tier is on:
|
||||
"""The profile's Nous state, or None. A free-tier identity counts only while guests are allowed:
|
||||
with ``nous.guest: false`` it is invisible here, so no cached or refreshed token of it is ever
|
||||
attached to a request.
|
||||
|
||||
@@ -51,9 +51,9 @@ def _read_nous_provider_state() -> Optional[dict]:
|
||||
nous_provider = get_provider_auth_state("nous")
|
||||
if not isinstance(nous_provider, dict):
|
||||
return None
|
||||
from hermes_cli.anon_auth import guest_enabled, is_guest_state
|
||||
from hermes_cli.anon_auth import guest_allowed, is_guest_state
|
||||
|
||||
if is_guest_state(nous_provider) and not guest_enabled():
|
||||
if is_guest_state(nous_provider) and not guest_allowed():
|
||||
return None
|
||||
return nous_provider
|
||||
except Exception:
|
||||
@@ -86,7 +86,7 @@ def read_nous_access_token() -> Optional[str]:
|
||||
"""Read a Nous Subscriber OAuth access token from auth store or env override.
|
||||
|
||||
A read: with no Nous identity there is no bearer and the answer is None. The free-tier identity
|
||||
is created by the boot bootstrap (``hermes_cli.free_tier_bootstrap``), never on a token-read
|
||||
is created by the boot bootstrap or the first hosted connector action, never on a token-read
|
||||
path (NS-845 Q1.2). A retired free-tier credential IS replaced here, once: that is the explicit
|
||||
dead-credential rule, shared with inference.
|
||||
"""
|
||||
@@ -125,7 +125,8 @@ def _replace_dead_guest_token(dead_state: dict, code: str = "anon_credential_dea
|
||||
if code == ANON_ACCOUNT_LOCKED:
|
||||
return None
|
||||
try:
|
||||
if ensure_portal_identity(explicit=True) is None:
|
||||
# The identity this replaces served connectors and managed tools: the connectors gate applies.
|
||||
if ensure_portal_identity(explicit=True, for_connectors=True) is None:
|
||||
return None
|
||||
return _clean(resolve_nous_access_token(refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS))
|
||||
except Exception as exc:
|
||||
|
||||
@@ -214,22 +214,10 @@ def _name_rows() -> Optional[list]:
|
||||
|
||||
|
||||
def _connectors_closed() -> Optional[str]:
|
||||
from tools.connectors.gateway.config import connectors_available, load_config
|
||||
from tools.connectors.gateway.config import connectors_available
|
||||
|
||||
if connectors_available():
|
||||
return None
|
||||
from hermes_cli.anon_auth import current_nous_state, ensure_portal_identity, guest_enabled
|
||||
|
||||
# Connectors ride a Nous identity. A boot-time guest mint can be refused (rate limited) and stop retrying, so
|
||||
# the card makes one attempt of its own; the mint memo's cooldown still holds it back from the portal.
|
||||
if load_config().enabled and not current_nous_state() and guest_enabled():
|
||||
try:
|
||||
ensure_portal_identity(explicit=True)
|
||||
except Exception: # health: allow BLE001 -- one best-effort mint; on any failure the card says why it is closed
|
||||
logger.info("setup_choose: no guest identity for connectors", exc_info=True)
|
||||
if connectors_available():
|
||||
return None
|
||||
return _NO_CONNECTORS
|
||||
# A user with no Nous identity is open too: the first connect creates the guest.
|
||||
return None if connectors_available() else _NO_CONNECTORS
|
||||
|
||||
|
||||
def _plugins_closed() -> Optional[str]:
|
||||
|
||||
@@ -78,12 +78,15 @@ def _session_owner(rid, owner):
|
||||
|
||||
|
||||
def _account_gate_closed(rid):
|
||||
from tools.connectors import connectors_available
|
||||
from tools.connectors import connectors_available, ensure_guest_identity
|
||||
from tui_gateway.contracts.connectors import ConnectorErrorReason
|
||||
|
||||
if connectors_available():
|
||||
return None
|
||||
return _connector_rpc_error(rid, 4031, ConnectorErrorReason.connectors_unavailable, "Connectors are not available.")
|
||||
if not connectors_available():
|
||||
return _connector_rpc_error(rid, 4031, ConnectorErrorReason.connectors_unavailable, "Connectors are not available.")
|
||||
# Opening the Connectors page is the user asking for apps: create the guest identity here.
|
||||
if setup_error := ensure_guest_identity():
|
||||
return _connector_rpc_error(rid, 5034, ConnectorErrorReason.connector_request_failed, setup_error)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_params(rid, params, model):
|
||||
|
||||
@@ -19,14 +19,10 @@ def _account_method(params_model=None, *, invalid="", invalid_reason=ConnectorEr
|
||||
def decorate(fn):
|
||||
def handler(rid, params):
|
||||
from pydantic import ValidationError
|
||||
from tools.connectors import connectors_available
|
||||
from tools.connectors.gateway.errors import GatewayAuthError
|
||||
from tui_gateway.contracts.connectors import ConnectorErrorReason
|
||||
|
||||
if not connectors_available():
|
||||
return _connector_rpc_error(
|
||||
rid, 4031, ConnectorErrorReason.connectors_unavailable, "Connectors are not available."
|
||||
)
|
||||
if gate_error := _account_gate_closed(rid):
|
||||
return gate_error
|
||||
try:
|
||||
request = params if params_model is None else params_model.model_validate(params)
|
||||
except ValidationError:
|
||||
|
||||
@@ -157,8 +157,8 @@ tools:
|
||||
|
||||
## Connectors (remote tools)
|
||||
|
||||
When you are signed in to the Nous Portal, the bridge additionally reaches
|
||||
**connectors** — remote tools served by the managed tool gateway. They are
|
||||
The bridge also reaches **connectors**: remote tools served by the managed tool
|
||||
gateway. They are
|
||||
never registered locally: `tool_search` sends each query to the gateway, adds
|
||||
the gateway's hits to the local catalog as documents (tagged
|
||||
`source: "connectors"`, named `connectors__<connector>__<tool>`), and ranks
|
||||
@@ -182,9 +182,22 @@ tools:
|
||||
# behaves exactly as if the feature didn't exist
|
||||
```
|
||||
|
||||
Signed out (or when the gateway does not serve connectors for your
|
||||
account), everything above is invisible: local search behaves exactly as
|
||||
described in the rest of this page, with no errors shown to the model.
|
||||
Connectors need a Nous identity. You do not have to sign in for one: if you
|
||||
have no Nous account, the first `manage_connections` action (or opening the
|
||||
desktop Connectors page) creates a free Nous identity for you, on every
|
||||
surface. It is used for connectors and managed tools only and never changes
|
||||
your model provider. Until it exists, `tool_search` sends nothing to the
|
||||
gateway and its result says that hosted connectors are not set up yet and
|
||||
that `manage_connections` sets them up. To opt out, set:
|
||||
|
||||
```yaml
|
||||
nous:
|
||||
guest: false
|
||||
```
|
||||
|
||||
With that setting, or when the gateway does not serve connectors for a
|
||||
signed-in account, everything above is invisible: local search behaves exactly
|
||||
as described in the rest of this page, with no errors shown to the model.
|
||||
|
||||
A connector call that needs an account you haven't linked returns a
|
||||
`CONNECTION_REQUIRED` error. The `manage_connections` tool lists connectors and
|
||||
@@ -192,9 +205,8 @@ their connection state and starts an authorization: in the desktop app the call
|
||||
shows a card, blocks until each app is connected or skipped, and reports the
|
||||
outcomes; elsewhere it returns a connect link per app for the user to open.
|
||||
Disconnecting an account is done by the user in the Portal. The same tool also installs, enables and authorizes
|
||||
local MCP servers from the catalog (targets with `mcp: true`), so it is
|
||||
present whether or not you are signed in; only the managed-connector actions
|
||||
need the sign-in.
|
||||
local MCP servers from the catalog (targets with `mcp: true`); those actions
|
||||
never create a Nous identity.
|
||||
|
||||
You can link more than one account of the same app, each with its own name
|
||||
(see [Multiple connector accounts](./tool-gateway.md#multiple-connector-accounts)).
|
||||
|
||||
Reference in new issue
Block a user