* feat(desktop): give Button a loading prop that swaps label for spinner without layout shift The label stays in the box, invisible, and the spinner is absolutely centred over it, so a Connect or Approve button keeps its width while it works instead of collapsing to a spinner. The approval bar had the same thrash and moves onto it. * refactor(desktop): one consent card for connectors and MCP setup McpSetupTool rendered its own copy of the connector card's markup. It now renders ConnectorCard for the pending question and ConnectorSummary once settled, and the card gains what MCP needed: keyboard accelerators, a source line, a question heading. The card also gets an avatar variant (40px mark in the left gutter, text and buttons on one column) and a collapseWhenSettled switch so a connector can stay a full card with a green Connected pill in the action slot while MCP keeps its one-line summary. Brand marks for Gmail, Calendar, Drive, Discord, Telegram and Spotify; Slack via Tabler because simple-icons dropped the mark. * feat(desktop): connector card drives the agent through manage_connections wait The offer used to end in a Continue in chat button, and the agent, seeing an unconnected status, would improvise around the app. Now the card does what the TUI does. Clicking Connect opens the browser and sends one hidden line telling the agent to park in manage_connections action=wait for that slug and to never call connect again (a second link cancels the one being signed into). Not now sends its own line. A hidden request that lands while the turn is busy steers it, or queues if the turn just ended. Which call owns the live card changes too: consecutive calls naming the same apps are one exchange (connect, the wait, the status that follows), and the first of the last exchange is the card, so the agent's wait no longer demotes the card mid-authorization and mints a fresh one below it. A targeted ask renders one or two bare cards; only a real catalog gets the header, search and refresh. * feat(desktop): onboarding connects apps in chat and keeps tasks finishable without them The welcome chat knew connectors only as preferences to pick and wire up later, so asked to connect Gmail it invented a Settings page that does not exist. Both scripts now carry one rule set: status once, one batched connect for every app named, the card is the ask so write a line and end the turn, never route around a declined app with another client or credential. The build handoff checks real connection status instead of asserting none are connected, and the first task must be finishable, not free of, the apps they picked. The connectors card explains what connecting means and reports the count on its Continue button. * fix(tools): resolve the Nous identity for share_auth profiles in the connector gate A profile created with share_auth has no auth.json of its own and signs in through the root store. Every other credential reader falls back to the global root; the connector gate read HERMES_HOME/auth.json directly, saw nothing, and stripped manage_connections from the profile's tool list, so the welcome chat's agent truthfully reported the tool missing. The gate now goes through get_provider_auth_state. * fix(agent): name a provider retry backoff on the live status line The retry status is buffered and replays only when every retry fails, so during a 60s backoff after a 5xx the user saw a bare spinner. Right after a connector sign-in landed this read as the agent going silent. The backoff now also rewrites the live wait notice, which the desktop already renders in the thread status row; it is transient and clears on recovery. * test(desktop): connector rehearsal launcher and flagged connector spec connector-rehearsal.mjs starts the real desktop and backend under a fresh HERMES_HOME with no copied credentials, a fixed Vite port and CDP on 9344, so the onboarding connector flow can be driven end to end by hand or from outside. The Playwright spec covers the flagged connector step. * fix(desktop): send the agent back into wait when the user keeps waiting after a timeout The card's Keep waiting re-entered the poll but the agent's own wait had timed out too and nothing told it to go back in, so it would start talking mid-authorization. keepWaiting now fires onWaiting like connect does. Tests also pin that an expired or revoked grant asks the gateway for reconnect, not connect. * style(desktop): blank lines in connector-flow test per lint * feat(desktop): HERMES_SKIP_INTRO=1 / --skip-intro skips the first-run film The intro is a one-time reveal, so anyone rehearsing the guided chat behind it sits through it on every fresh HERMES_HOME. The flag rides the existing launch-flags path (main → preload → renderer) next to guestOnboarding and only gates isIntroRevealEnabled; the backend never sees it. The rehearsal launcher sets it. * fix(desktop): onboarding card Continue stays Done after the transcript rebuilds The card kept its Done flag in component state. The hidden submit and the turn-end hydrate both rebuild the message list, so the card remounted with the flag false and Continue came back live, letting a step be answered twice. The committed steps now live with the other onboarding answers, keyed by step, and the first-build chip pick rides the same store. remember_onboarding projects by key, so the new field never reaches USER.md. * fix(desktop): no provider picker or free-tier chip over the guided first launch Two sign-in surfaces leaked into the guide. A credential probe on the setup profile (a free-tier token mid refresh, a session before its runtime settled) hit requestDesktopOnboarding and dropped the provider picker over the chat the user was in; and the statusbar free-tier chip sat there offering a second sign-in the whole time. Both now yield while the gate phase is cinematic, guided or handoff. The free tier is the provider for those phases, and the guide offers sign-in on its own ready screen. * fix(desktop): onboarding connector picks are real catalog slugs The picker offered Spotify, GitHub and Stripe, none of which the deployed connector catalog carries, and spelled Calendar and Drive with hyphens the gateway does not use. A pick the build chat could not honour ended as "Spotify isn't in the connector list" after the user had been told to expect it. The list is now twelve slugs from the live status catalog, spelled as the gateway spells them; GitHub is out (the terminal has git and gh), chat channels stay on Messaging. Marks for the new entries; the Google marks answer both spellings. The build runbook offers the picked connections in its first turn rather than after the work is underway. * fix(desktop): the free-tier ready screen never interrupts the guided chat A readiness round fires when the layout pick assembles the window, and it raised the free-tier ready screen over the conversation: the user was dropped into the main app, dismissed it, and came back to a card they had already answered. The guide is the introduction. The ready screen now yields while the gate is cinematic, guided or handoff, and the notice is acked the moment the guided chat takes the screen, not only when the film does, so a skipped film no longer leaves it pending. * feat(desktop): tour options that lead to building, and a fork that follows the tour "Just the basics" and "Show me around" read as a click-through with no exit; "I'll figure it out" read as declining help. Now Quick tour, Show me everything, and Skip, let's build something. The script also folds the fork into the same turn as the tour, so when the user closes the overlay the next ask is already waiting instead of a transcript that ends on the tour call. * feat(desktop): the onboarding connector picker reads the live catalog A hardcoded list, however carefully copied from today's catalog, is the next drift. The picker now asks connectors.list through the same session-owned RPC the connector cards use and offers exactly what the gateway carries: a curated lead order puts the everyday apps first, chat channels stay on Messaging, everything else is reachable by search. The picks are gateway slugs, handed straight to manage_connections. No catalog (toolset off, gateway unreachable) ends the step honestly with Skip instead of inventing apps. * test(desktop): the guided first launch never forces a sign-in The acceptance criterion the guided onboarding was built to, as a test: while the gate is cinematic, guided or handoff, the provider picker does not open and a credential warning is dropped rather than deferred to the next send. Outside the guide the picker opens as before. Red against the tree before the guards landed (6 of 9). * fix(desktop): a relaunch mid-guide resumes the guide, in the guide's shape Closing the app during the guided first launch and reopening it booted the normal shell around the persisted solo layout: the connecting splash, the stock composer and model picker, a small window whose sidebars would not open, while the gate still read guided. The gate now queues a kickoff for the guided phase too (the kickoff adopts the existing guide chat by title), takes the solo shape before the gateway opens rather than after, and the connecting overlay yields to the guide's own opening. A typed reply in the composer now closes an ask card and the first-build chips the same way a click does; the layout card's Continue comes back Done. * style(desktop): one answeredAfter helper for the ask card and first-build chips * fix(desktop): the guide takes its shape on the tick the film ends, not after the window shows Between the film and the greeting the full-size shell painted for a beat: finishIntroReveal showed the main window, then the kickoff shrank it once the setup profile answered. The listener on the intro's hidden edge now takes the guide's shape (solo layout + small centred window) synchronously, so the window is already the guide when it is shown. One takeGuideShape owns the pair; kickoff and the boot gate call it idempotently. * style(desktop): the 'nothing connects yet' line reads first on the connectors card
240 lines
9.4 KiB
Python
240 lines
9.4 KiB
Python
import os
|
|
import json
|
|
from datetime import datetime, timedelta, timezone
|
|
from importlib.util import module_from_spec, spec_from_file_location
|
|
from pathlib import Path
|
|
import sys
|
|
from unittest.mock import patch
|
|
|
|
from tools import managed_gateway_auth
|
|
|
|
|
|
MODULE_PATH = Path(__file__).resolve().parents[2] / "tools" / "managed_tool_gateway.py"
|
|
MODULE_SPEC = spec_from_file_location("managed_tool_gateway_test_module", MODULE_PATH)
|
|
assert MODULE_SPEC and MODULE_SPEC.loader
|
|
managed_tool_gateway = module_from_spec(MODULE_SPEC)
|
|
sys.modules[MODULE_SPEC.name] = managed_tool_gateway
|
|
MODULE_SPEC.loader.exec_module(managed_tool_gateway)
|
|
is_managed_tool_gateway_ready = managed_tool_gateway.is_managed_tool_gateway_ready
|
|
resolve_managed_tool_gateway = managed_tool_gateway.resolve_managed_tool_gateway
|
|
|
|
|
|
def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain():
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
|
|
},
|
|
clear=False,
|
|
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
result = resolve_managed_tool_gateway(
|
|
"firecrawl",
|
|
token_reader=lambda: "nous-token",
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.gateway_origin == "https://firecrawl-gateway.nousresearch.com"
|
|
assert result.nous_user_token == "nous-token"
|
|
assert result.managed_mode is True
|
|
|
|
|
|
def test_resolve_managed_tool_gateway_uses_vendor_specific_override():
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"BROWSER_USE_GATEWAY_URL": "http://browser-use-gateway.localhost:3009/",
|
|
},
|
|
clear=False,
|
|
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
result = resolve_managed_tool_gateway(
|
|
"browser-use",
|
|
token_reader=lambda: "nous-token",
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.gateway_origin == "http://browser-use-gateway.localhost:3009"
|
|
|
|
|
|
def test_resolve_managed_tool_gateway_is_inactive_without_nous_token():
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
|
|
},
|
|
clear=False,
|
|
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
result = resolve_managed_tool_gateway(
|
|
"firecrawl",
|
|
token_reader=lambda: None,
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_resolve_managed_tool_gateway_is_disabled_without_subscription():
|
|
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False), \
|
|
patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=False):
|
|
result = resolve_managed_tool_gateway(
|
|
"firecrawl",
|
|
token_reader=lambda: "nous-token",
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkeypatch):
|
|
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
expires_at = (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat()
|
|
(tmp_path / "auth.json").write_text(json.dumps({
|
|
"providers": {
|
|
"nous": {
|
|
"access_token": "stale-token",
|
|
"refresh_token": "refresh-token",
|
|
"expires_at": expires_at,
|
|
}
|
|
}
|
|
}))
|
|
monkeypatch.setattr(
|
|
"hermes_cli.auth.resolve_nous_access_token",
|
|
lambda refresh_skew_seconds=120: "fresh-token",
|
|
)
|
|
|
|
assert managed_tool_gateway.read_nous_access_token() == "fresh-token"
|
|
|
|
|
|
def test_is_managed_tool_gateway_ready_skips_refresh_for_expired_cached_token(tmp_path, monkeypatch):
|
|
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
expired_at = (datetime.now(timezone.utc) - timedelta(seconds=30)).isoformat()
|
|
(tmp_path / "auth.json").write_text(json.dumps({
|
|
"providers": {
|
|
"nous": {
|
|
"access_token": "expired-token",
|
|
"refresh_token": "refresh-token",
|
|
"expires_at": expired_at,
|
|
}
|
|
}
|
|
}))
|
|
refresh_calls = []
|
|
|
|
def _record_refresh(*, refresh_skew_seconds=120, **_kwargs):
|
|
refresh_calls.append(refresh_skew_seconds)
|
|
return "fresh-token"
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.auth.resolve_nous_access_token",
|
|
_record_refresh,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{"TOOL_GATEWAY_DOMAIN": "nousresearch.com"},
|
|
clear=False,
|
|
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
assert is_managed_tool_gateway_ready("modal") is True
|
|
|
|
assert refresh_calls == []
|
|
|
|
|
|
def test_connector_gateway_origin_pins_the_deployed_connectors_host():
|
|
# The connectors API is its own deployment on its own canonical host, so
|
|
# the default resolution must not land on the media/vendor origin.
|
|
with patch.dict(
|
|
os.environ,
|
|
{"TOOL_GATEWAY_DOMAIN": "nousresearch.com", "TOOL_GATEWAY_SCHEME": "https"},
|
|
clear=False,
|
|
):
|
|
os.environ.pop("CONNECTOR_GATEWAY_URL", None)
|
|
assert managed_gateway_auth.connector_gateway_origin() == (
|
|
"https://connector-gateway.nousresearch.com"
|
|
)
|
|
|
|
def test_managed_gateway_origin_honors_the_harness_override():
|
|
# TOOL_GATEWAY_URL pins the full media origin (the e2e harness sets it to a
|
|
# loopback gateway), and the bearer gate must accept exactly that origin.
|
|
with patch.dict(os.environ, {"TOOL_GATEWAY_URL": "http://127.0.0.1:3009/"}, clear=False):
|
|
os.environ.pop("CONNECTOR_GATEWAY_URL", None)
|
|
assert managed_gateway_auth.managed_gateway_origin() == "http://127.0.0.1:3009"
|
|
assert managed_gateway_auth.is_managed_nous_gateway_url(
|
|
"http://127.0.0.1:3009/api/vendorx/generations"
|
|
)
|
|
assert not managed_gateway_auth.is_managed_nous_gateway_url(
|
|
"https://tools.nousresearch.com/api/vendorx/generations"
|
|
)
|
|
|
|
def test_connector_gateway_origin_honors_its_own_override():
|
|
# CONNECTOR_GATEWAY_URL is the connectors host's own key: it moves the
|
|
# connectors origin without touching the media origin, and the bearer gate
|
|
# accepts the overridden origin.
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"CONNECTOR_GATEWAY_URL": "http://127.0.0.1:3009/",
|
|
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
|
|
},
|
|
clear=False,
|
|
):
|
|
os.environ.pop("TOOL_GATEWAY_URL", None)
|
|
assert managed_gateway_auth.connector_gateway_origin() == "http://127.0.0.1:3009"
|
|
assert managed_gateway_auth.managed_gateway_origin() == (
|
|
"https://tool-gateway.nousresearch.com"
|
|
)
|
|
assert managed_gateway_auth.is_managed_nous_gateway_url(
|
|
"http://127.0.0.1:3009/v1/connectors/search"
|
|
)
|
|
|
|
def test_default_bearer_gate_accepts_both_deployed_hosts_only():
|
|
# Exact (scheme, netloc) equality against each deployed origin. Both
|
|
# first-party hosts are in; the retired `tools.` host, subdomain cousins,
|
|
# and scheme downgrades are all out.
|
|
with patch.dict(
|
|
os.environ,
|
|
{"TOOL_GATEWAY_DOMAIN": "nousresearch.com", "TOOL_GATEWAY_SCHEME": "https"},
|
|
clear=False,
|
|
):
|
|
os.environ.pop("TOOL_GATEWAY_URL", None)
|
|
os.environ.pop("CONNECTOR_GATEWAY_URL", None)
|
|
for trusted in (
|
|
"https://connector-gateway.nousresearch.com/v1/connectors/execute",
|
|
"https://tool-gateway.nousresearch.com/api/vendorx/generations",
|
|
):
|
|
assert managed_gateway_auth.is_managed_nous_gateway_url(trusted)
|
|
for untrusted in (
|
|
"https://tools.nousresearch.com/v1/connectors/execute",
|
|
"https://evil-connector-gateway.nousresearch.com.attacker.dev/v1/connectors",
|
|
"https://connector-gateway.nousresearch.com.attacker.dev/v1/connectors",
|
|
"http://connector-gateway.nousresearch.com/v1/connectors",
|
|
"http://tool-gateway.nousresearch.com/api/vendorx/generations",
|
|
):
|
|
assert not managed_gateway_auth.is_managed_nous_gateway_url(untrusted)
|
|
|
|
|
|
def test_read_nous_provider_state_falls_back_to_global_root_for_share_auth_profiles(tmp_path, monkeypatch):
|
|
# A profile created with ``share_auth`` has no auth.json of its own; it signs in with the
|
|
# root identity. The connector gate must see that identity, or manage_connections vanishes
|
|
# from the profile's tool list while every other credential reader still works.
|
|
root = tmp_path / ".hermes"
|
|
profile = root / "profiles" / "hermes-setup"
|
|
profile.mkdir(parents=True)
|
|
(root / "auth.json").write_text(json.dumps({
|
|
"version": 1,
|
|
"providers": {"nous": {"auth_method": "anonymous", "access_token": "tok"}},
|
|
}))
|
|
monkeypatch.setenv("HERMES_HOME", str(profile))
|
|
monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1")
|
|
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
|
|
|
import hermes_constants
|
|
from hermes_cli import auth as auth_mod
|
|
|
|
monkeypatch.setattr(hermes_constants, "get_default_hermes_root", lambda: root)
|
|
monkeypatch.setattr(auth_mod, "get_hermes_home", lambda: profile)
|
|
monkeypatch.setattr(auth_mod, "_global_auth_store_cache", None)
|
|
monkeypatch.setattr(auth_mod, "_auth_file_path", lambda: profile / "auth.json")
|
|
|
|
state = managed_tool_gateway._read_nous_provider_state()
|
|
|
|
assert state is not None
|
|
assert state["auth_method"] == "anonymous"
|