diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a25ef164f5..a005c88f30 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -176,13 +176,25 @@ jobs: # decomposition opened with 1,703 such drops that reviewers had to find by hand. # Advisory: it never fails the job. The checkout above is depth-1, so deepen both sides until # a merge-base exists (the script refuses to report a clean diff without one, by design). + # Diff the PR head ref, not the checked-out refs/pull/N/merge commit: that synthetic commit + # is recomputed server-side whenever main moves, and once it is unreachable no amount of + # deepening ever reaches its parents (run 34285107265: 4 fetches, 4m15s, "no merge-base"). - name: Public-surface diff vs base (advisory) if: github.event_name == 'pull_request' continue-on-error: true + # Bound the advisory step so a long deepen/fetch (e.g. a distant or missing + # merge-base) can't consume the blocking job's 5-minute budget and cancel it. + # continue-on-error already keeps a step *failure* off the job; a step-level + # timeout keeps a step *overrun* off the job-level timeout the same way. + # Sizing: a --deepen=200 fetch took ~56s and a --deepen=1000 ~67s on the runner, and + # main gains ~170 commits/day, so a day-old branch legitimately needs both (~2 min). + timeout-minutes: 3 + env: + PR_HEAD: "+refs/pull/${{ github.event.pull_request.number }}/head:refs/remotes/origin/pr-head" run: | - git fetch --no-tags --deepen=200 origin "${{ github.base_ref }}" HEAD + git fetch --no-tags --deepen=200 origin "${{ github.base_ref }}" "$PR_HEAD" for i in 1 2 3; do - git merge-base "origin/${{ github.base_ref }}" HEAD >/dev/null 2>&1 && break - git fetch --no-tags --deepen=1000 origin "${{ github.base_ref }}" HEAD + git merge-base "origin/${{ github.base_ref }}" origin/pr-head >/dev/null 2>&1 && break + git fetch --no-tags --deepen=1000 origin "${{ github.base_ref }}" "$PR_HEAD" done - python scripts/ci/check_public_surface.py --base "origin/${{ github.base_ref }}" --head HEAD + python scripts/ci/check_public_surface.py --base "origin/${{ github.base_ref }}" --head origin/pr-head diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 04ded784ee..ced14d7721 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -456,6 +456,8 @@ prerequisites: # Optional legacy runtime requirements commands: [curl, jq] # Advisory only; does not hide the skill metadata: hermes: + editorial_name: My Skill # Optional human-readable UI title + editorial_description: What this skill helps a person accomplish. tags: [Category, Subcategory, Keywords] related_skills: [other-skill-name] fallback_for_toolsets: [web] # Optional — show only when toolset is unavailable @@ -488,6 +490,12 @@ Known failure modes and how to handle them. How the agent confirms it worked. ``` +`metadata.hermes.editorial_name` and `editorial_description` are optional, +human-facing presentation copy. They may use natural titles and fuller prose +than the routing-focused top-level fields. Hermes continues to identify and +route skills with `name` and `description`; UIs fall back to that canonical +pair when editorial copy is absent. + ### Platform-specific skills Skills can declare which OS platforms they support via the `platforms` frontmatter field. Skills with this field are automatically hidden from the system prompt, `skills_list()`, and slash commands on incompatible platforms. diff --git a/MagicMock/mock._session_db.db_path/126402682339024 b/MagicMock/mock._session_db.db_path/126402682339024 new file mode 100644 index 0000000000..2cc4d1be35 Binary files /dev/null and b/MagicMock/mock._session_db.db_path/126402682339024 differ diff --git a/MagicMock/mock._session_db.db_path/126402682339024.fts_rebuild.lock b/MagicMock/mock._session_db.db_path/126402682339024.fts_rebuild.lock new file mode 100644 index 0000000000..e69de29bb2 diff --git a/MagicMock/mock._session_db.db_path/126402682339024.quarantine.lock b/MagicMock/mock._session_db.db_path/126402682339024.quarantine.lock new file mode 100644 index 0000000000..e69de29bb2 diff --git a/MagicMock/mock._session_db.db_path/126402702293264 b/MagicMock/mock._session_db.db_path/126402702293264 new file mode 100644 index 0000000000..e93b5fe5a9 Binary files /dev/null and b/MagicMock/mock._session_db.db_path/126402702293264 differ diff --git a/MagicMock/mock._session_db.db_path/126402702293264.fts_rebuild.lock b/MagicMock/mock._session_db.db_path/126402702293264.fts_rebuild.lock new file mode 100644 index 0000000000..e69de29bb2 diff --git a/MagicMock/mock._session_db.db_path/126402702293264.quarantine.lock b/MagicMock/mock._session_db.db_path/126402702293264.quarantine.lock new file mode 100644 index 0000000000..e69de29bb2 diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 431f7368d8..4831a9acd0 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -312,8 +312,11 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent): try: from hermes_cli.models import detect_provider_for_model, parse_model_input + raw = new_model target_provider, new_model = parse_model_input(new_model, current_provider) - if target_provider == current_provider: + # An explicit ``provider:model`` prefix is a selection; detection is a fallback for bare + # names only and must not second-guess it (#59089). + if target_provider == current_provider and new_model == raw: detected = detect_provider_for_model(new_model, current_provider) if detected: target_provider, new_model = detected diff --git a/agent/agent_init.py b/agent/agent_init.py index 6157fd03b5..2aac090018 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -372,8 +372,11 @@ _EXPLICIT_API_MODES = { def _resolve_api_mode(agent, api_mode, provider_name, base_url): """Set ``agent.api_mode`` (and provider rewrites) — ordered ladder, first match wins.""" + from hermes_cli.providers import is_actual_route host, url = agent._base_url_hostname, agent._base_url_lower - if api_mode in _EXPLICIT_API_MODES: + if is_actual_route(agent.provider, base_url): + agent.api_mode = "chat_completions" + elif api_mode in _EXPLICIT_API_MODES: agent.api_mode = api_mode elif agent.provider in {"openai-codex", "xai", "xai-oauth"}: agent.api_mode = "codex_responses" @@ -416,6 +419,7 @@ def _resolve_api_mode(agent, api_mode, provider_name, base_url): def _finalize_routing(agent, api_mode, credential_pool): + from hermes_cli.providers import is_actual_route # Credential-pool validation runs AFTER provider auto-detection so a pool scoped to # "anthropic" isn't rejected for provider=None + anthropic.com URL. # Regression from #63048 which placed this check before the URL-based auto-detection block above (fixed @@ -453,6 +457,11 @@ def _finalize_routing(agent, api_mode, credential_pool): if agent.provider not in _AGGREGATOR_PROVIDERS: agent.model = normalize_model_for_provider(agent.model, agent.provider) + # Nous model policy follows the ROUTE (the welcome host serves one model); a credential-pool + # swap can change the route later, so ``_swap_credential`` applies the same helper again. + from hermes_cli.anon_auth import pin_model_for_route + agent.model = pin_model_for_route(agent.provider, agent.base_url, agent.model) + # Auto-upgrade to Responses for GPT-5.x-style models and direct OpenAI URLs, unless # api_mode was explicit, the runtime is ACP (`acp://` clients route themselves, no # Responses surface) or Azure OpenAI (gpt-5.x on /chat/completions only). Provider @@ -468,6 +477,7 @@ def _finalize_routing(agent, api_mode, credential_pool): # upgrade for Azure (openai.azure.com), even though it looks OpenAI-compatible. api_mode is None and agent.api_mode == "chat_completions" + and not is_actual_route(agent.provider, agent.base_url) and agent.provider != "copilot-acp" and not _base_lower.startswith(("acp://", "acp+tcp://")) and not agent._is_azure_openai_url() @@ -595,8 +605,8 @@ _SESSION_STATE: Dict[str, Any] = { # False on helper agents (compression / hygiene / review forks) that hand the session to # a continuation row that must stay open. "_end_session_on_close": True, - # True on the background review fork: never persist, so its harness turn can't hijack - # the live session. + # True on the background review fork: never persist or publish session lifecycle hooks, + # so its harness turn can't hijack or appear under the live session. "_persist_disabled": False, } @@ -679,12 +689,6 @@ def _setup_logging(agent): # would starve the root file handlers. Noise reduction belongs in hermes_logging. -def _bedrock_region_from_url(base_url) -> str: - """AWS region from a bedrock-runtime..amazonaws.com URL (default us-east-1).""" - m = re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url or "") - return m.group(1) if m else "us-east-1" - - def _print_key_banner(key, label: str, warn_missing: bool = False) -> None: """Masked credential line. ``key`` may be a callable Entra ID bearer provider (Azure Foundry) — never invoke or inspect it. Keys ≤ 12 chars (incl. "dummy-key") are not shown.""" @@ -706,14 +710,10 @@ def _init_anthropic_client(agent, api_key, base_url, _provider_timeout): agent._anthropic_base_url = base_url if agent.provider == "bedrock": # AnthropicBedrock SDK for full feature parity (prompt caching, thinking budgets). - from agent.anthropic_adapter import build_anthropic_bedrock_client - _br_region = agent._bedrock_region = _bedrock_region_from_url(base_url) - agent._anthropic_client = build_anthropic_bedrock_client(_br_region) - agent._anthropic_api_key = "aws-sdk" - agent._is_anthropic_oauth = False - agent.api_key = "aws-sdk" + from agent.bedrock_adapter import bind_bedrock_runtime + bind_bedrock_runtime(agent, base_url, "anthropic_messages") if not agent.quiet_mode: - print(f"🤖 AI Agent initialized with model: {agent.model} (AWS Bedrock + AnthropicBedrock SDK, {_br_region})") + print(f"🤖 AI Agent initialized with model: {agent.model} (AWS Bedrock + AnthropicBedrock SDK, {agent._bedrock_region})") return # ANTHROPIC_TOKEN fallback only for native Anthropic — other anthropic_messages providers # must use their own key or Anthropic credentials leak to third-party endpoints. @@ -775,22 +775,8 @@ def _init_moa_client(agent, api_key): def _init_bedrock_client(agent, base_url): """bedrock_converse: boto3 directly, no OpenAI client.""" - agent._bedrock_region = _bedrock_region_from_url(base_url) - # Guardrail config — read from config.yaml at init time. - agent._bedrock_guardrail_config = None - with suppress(Exception): - from hermes_cli.config import load_config_readonly as _load_br_cfg - _gr = _load_br_cfg().get("bedrock", {}).get("guardrail", {}) - if _gr.get("guardrail_identifier") and _gr.get("guardrail_version"): - agent._bedrock_guardrail_config = { - "guardrailIdentifier": _gr["guardrail_identifier"], - "guardrailVersion": _gr["guardrail_version"], - } - for _src, _dst in (("stream_processing_mode", "streamProcessingMode"), ("trace", "trace")): - if _gr.get(_src): - agent._bedrock_guardrail_config[_dst] = _gr[_src] - agent.client = None - agent._client_kwargs = {} + from agent.bedrock_adapter import bind_bedrock_runtime + bind_bedrock_runtime(agent, base_url, "bedrock_converse") if not agent.quiet_mode: _gr_label = " + Guardrails" if agent._bedrock_guardrail_config else "" print(f"🤖 AI Agent initialized with model: {agent.model} (AWS Bedrock, {agent._bedrock_region}{_gr_label})") @@ -840,6 +826,10 @@ def _routed_client_kwargs(agent, fallback_model, _provider_timeout) -> Dict[str, _routed_client, _ = resolve_provider_client( agent.provider or "auto", model=agent.model, raw_codex=True) if _routed_client is not None: + from hermes_cli.providers import is_actual_route, normalize_provider + effective_provider = getattr(_routed_client, "_hermes_aux_effective_provider", "") + if is_actual_route(effective_provider): + agent.provider = normalize_provider(effective_provider) return _client_kwargs_from_routed(_routed_client, _provider_timeout) # No credentials: try the fallback chain BEFORE failing (an exhausted single-entry pool # must not die with a misleading "No LLM provider configured"); only explicitly named @@ -924,6 +914,11 @@ def _init_openai_client(agent, api_key, base_url, fallback_model, _provider_time client_kwargs = _explicit_client_kwargs(agent, api_key, base_url, _provider_timeout) else: client_kwargs = _routed_client_kwargs(agent, fallback_model, _provider_timeout) + from hermes_cli.providers import is_actual_route + if is_actual_route(agent.provider, client_kwargs.get("base_url", "")): + agent.api_mode = "chat_completions" + if hasattr(agent, "_transport_cache"): + agent._transport_cache.clear() try: from agent.bedrock_adapter import configure_bedrock_openai_client_kwargs configure_bedrock_openai_client_kwargs(client_kwargs, timeout=_provider_timeout) @@ -2239,6 +2234,10 @@ def init_agent( agent.skip_background_review = bool(skip_background_review) agent.log_prefix = f"{log_prefix} " if log_prefix else "" # Effective base URL for feature detection (prompt caching, reasoning, etc.) + from hermes_cli.providers import is_actual_route + if is_actual_route(provider, base_url): + from hermes_cli.auth import normalize_actual_base_url + base_url = normalize_actual_base_url(base_url) agent.base_url = base_url or "" provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None agent.provider = provider_name or "" diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 43e862e52e..ff01faa4b2 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -749,7 +749,8 @@ def _recover_auth_failure(agent, pool, *, status_code, has_retried_429, error_co ) return False, has_retried_429 _ra().logger.info("Credential auth failure — refreshed pool entry %s", getattr(refreshed, 'id', '?')) - agent._swap_credential(refreshed) + if agent._swap_credential(refreshed) is False: + return False, has_retried_429 return True, has_retried_429 @@ -847,8 +848,7 @@ def recover_with_credential_pool( "Credential %s (%s) — rotated to pool entry %s", rotate_status, label, getattr(next_entry, "id", "?"), ) - agent._swap_credential(next_entry) - return True + return agent._swap_credential(next_entry) is not False if effective_reason == FailoverReason.upstream_rate_limit: # Upstream (e.g. DeepSeek behind OpenRouter) is throttling the aggregator; the credential is # healthy. Do not rotate/exhaust; let fallback switch models. @@ -889,7 +889,8 @@ def _apply_primary_runtime_fields(agent, rt: Dict[str, Any]) -> None: agent.provider = rt["provider"] agent.requested_provider = rt.get("requested_provider", agent.provider) agent.base_url = rt["base_url"] # setter updates _base_url_lower - agent.api_mode = rt["api_mode"] + from hermes_cli.providers import is_actual_route + agent.api_mode = "chat_completions" if is_actual_route(agent.provider, agent.base_url) else rt["api_mode"] if hasattr(agent, "_transport_cache"): agent._transport_cache.clear() agent.api_key = rt["api_key"] @@ -923,6 +924,9 @@ def _rebuild_primary_client(agent, rt: Dict[str, Any], *, reason: str) -> None: # factory so the restored facade keeps the reference_callback relay wired at init — a bare # MoAClient() would silently stop emitting moa.reference/moa.aggregating display events (#53802). agent._anthropic_client = None + elif agent.provider == "bedrock" and agent.api_mode in ("anthropic_messages", "bedrock_converse"): + from agent.bedrock_adapter import bind_bedrock_runtime + bind_bedrock_runtime(agent, agent.base_url, agent.api_mode) elif agent.api_mode == "anthropic_messages": _build_anthropic_client_from_runtime(agent, rt) else: @@ -957,16 +961,7 @@ def try_recover_primary_transport( agent._retire_shared_openai_client(agent.client, reason="primary_recovery") rt = agent._primary_runtime _apply_primary_runtime_fields(agent, rt) - if agent.api_mode == "anthropic_messages": - _build_anthropic_client_from_runtime(agent, rt) - elif (agent.provider or "").strip().lower() == "moa": - # MoA is a virtual provider with empty client_kwargs — rebuilding via _create_openai_client - # would raise "api_key client option must be set". Recreate the facade through the shared - # factory so the reference_callback relay survives recovery (#53802). - from agent.moa_loop import build_moa_facade - agent.client = build_moa_facade(agent, agent.model) - else: - agent.client = agent._create_openai_client(dict(rt["client_kwargs"]), reason="primary_recovery", shared=True) + _rebuild_primary_client(agent, rt, reason="primary_recovery") wait_time = min(3 + retry_count, 8) agent._vprint( f"{agent.log_prefix}🔁 Transient {error_type} on {agent.provider} — " @@ -1691,6 +1686,17 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo # that specific path; this copy locks the contract so future transport/keepalive work can't reintroduce # the same class of bug. client_kwargs = dict(client_kwargs) + try: + from providers import get_provider_profile + + profile = get_provider_profile(getattr(agent, "provider", "")) + if profile is not None: + for key, value in profile.build_client_kwargs_extras( + base_url=client_kwargs.get("base_url", "") + ).items(): + client_kwargs.setdefault(key, value) + except Exception: + _ra().logger.debug("Provider client-kwargs hook skipped", exc_info=True) # The MoA virtual provider has no OpenAI wire endpoint; the facade *is* the client. Rebuild the # facade, never a native client (TypeError; relay re-wire). # Rebuilding a native OpenAI client while agent.provider == "moa" (client replacement, stream-retry pool @@ -1703,7 +1709,10 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo return build_moa_facade(agent, getattr(agent, "model", None) or "default") ssl_ca_cert = client_kwargs.pop("ssl_ca_cert", None) ssl_verify_cfg = client_kwargs.pop("ssl_verify", None) - httpx_verify = resolve_httpx_verify(ca_bundle=ssl_ca_cert, ssl_verify=ssl_verify_cfg) + httpx_verify = resolve_httpx_verify( + ca_bundle=ssl_ca_cert, ssl_verify=ssl_verify_cfg, + base_url=str(client_kwargs.get("base_url", "")), + ) _validate_proxy_env_urls() _validate_base_url(client_kwargs.get("base_url")) # Provider-supplied client (registration seam): a provider whose wire protocol is not @@ -1739,6 +1748,15 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo # gets its OWN fresh ``httpx.Client`` whose lifetime is tied to the OpenAI client it is passed to. When # the OpenAI client is closed (rebuild, teardown, credential rotation), the paired ``httpx.Client`` # closes with it, and the next call constructs a fresh one — no stale closed transport can be reused. + # Bedrock Mantle: the ``aws-sdk`` placeholder is a sentinel for IAM-chain auth, not a bearer token. + # Every rebuild from bare ``{api_key, base_url}`` kwargs (switch_model, fallback restore, credential + # rotation, request-scoped clients) must reinstall the SigV4 http_client or Mantle answers 401. + if "bedrock-mantle." in str(client_kwargs.get("base_url") or ""): + from agent.bedrock_adapter import configure_bedrock_openai_client_kwargs + timeout = client_kwargs.get("timeout") + configure_bedrock_openai_client_kwargs( + client_kwargs, timeout=timeout if isinstance(timeout, (int, float)) else None, + ) if "http_client" not in client_kwargs: keepalive_http = agent._build_keepalive_http_client(client_kwargs.get("base_url", ""), verify=httpx_verify) if keepalive_http is not None: @@ -1827,7 +1845,7 @@ def _restore_switch_snapshot(agent, snapshot: Dict[str, Any]) -> None: def _resolve_switch_destination(agent, new_model, new_provider, base_url, api_mode, capabilities, old_norm, new_norm): """Resolve ``(api_mode, base_url, destination_capabilities)`` for the switch target.""" - from hermes_cli.providers import determine_api_mode + from hermes_cli.providers import determine_api_mode, is_actual_route from agent.native_compaction import resolve_native_compaction_capabilities from hermes_cli.models import opencode_provider_family # Pass model so dual-wire providers (Nous Portal anthropic/* -> Messages) resolve correctly. @@ -1841,6 +1859,11 @@ def _resolve_switch_destination(agent, new_model, new_provider, base_url, api_mo effective_base_url = base_url if not effective_base_url and old_norm == new_norm: effective_base_url = getattr(agent, "base_url", "") + if is_actual_route(new_provider, effective_base_url): + api_mode = "chat_completions" + if effective_base_url: + from hermes_cli.auth import normalize_actual_base_url + base_url = normalize_actual_base_url(effective_base_url) destination_capabilities = ( dict(capabilities) if isinstance(capabilities, dict) @@ -1874,6 +1897,12 @@ def _build_switched_client(agent, new_provider, api_key, base_url, api_mode, new agent._client_kwargs = {} agent.client = build_moa_facade(agent, agent.model) return + if new_provider == "bedrock" and api_mode in ("anthropic_messages", "bedrock_converse"): + # Non-Mantle Bedrock wires authenticate through boto3, never through the generic + # Anthropic/OpenAI builders (which would ship the ``aws-sdk`` sentinel as a credential). + from agent.bedrock_adapter import bind_bedrock_runtime + bind_bedrock_runtime(agent, base_url or agent.base_url, api_mode) + return if api_mode == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_client from agent.anthropic_credentials import resolve_anthropic_token, _is_oauth_token diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 206b58bc6d..b5bd7a5fbb 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -348,16 +348,22 @@ def _build_anthropic_client_with_bearer_hook( def _new_sdk_client(sdk, kwargs: Dict[str, Any], headers: Dict[str, str]): - """``sdk.Anthropic(**kwargs)`` with ``headers`` attached. Bearer-only construction leaves - ``api_key`` unset, so the SDK fills it from ANTHROPIC_API_KEY (loaded from ~/.hermes/.env) and - sends dual auth — X-Api-Key *and* Authorization: Bearer — on every Portal/MiniMax/OAuth/Entra - request; clear it whenever we intentionally authenticated via auth_token.""" - if headers: - kwargs["default_headers"] = headers - client = sdk.Anthropic(**kwargs) - if "auth_token" in kwargs and "api_key" not in kwargs: - client.api_key = None - return client + """``sdk.Anthropic(**kwargs)`` with ``headers`` attached, sending exactly ONE credential. + + The SDK fills whichever of ``api_key`` / ``auth_token`` we left unset from ANTHROPIC_API_KEY / + ANTHROPIC_AUTH_TOKEN in the environment (both loaded from ~/.hermes/.env) and then sends dual + auth — x-api-key *and* Authorization: Bearer — shipping a foreign credential to Portal / MiniMax + / OAuth / Entra / third-party endpoints (#26970, #105774). An ``Omit()`` default header is the + SDK-sanctioned way to drop the other header, and unlike an attribute clear it survives + ``with_options()``, which re-runs the constructor and re-reads the environment.""" + merged = dict(headers) + if "api_key" in kwargs and "auth_token" not in kwargs: + merged["Authorization"] = sdk.Omit() + elif "auth_token" in kwargs and "api_key" not in kwargs: + merged["X-Api-Key"] = sdk.Omit() + if merged: + kwargs["default_headers"] = merged + return sdk.Anthropic(**kwargs) def _auth_style(api_key, base_url, normalized_base_url) -> str: @@ -415,14 +421,17 @@ def build_anthropic_bedrock_client(region: str): """AnthropicBedrock client for Bedrock Claude models (boto3 default credential chain). The SDK's native Bedrock adapter gives full Claude feature parity (prompt caching, thinking budgets, adaptive thinking, fast mode) that Converse lacks. The common betas plus - ``context-1m-2025-08-07`` are attached: without the latter Bedrock caps Opus 4.6/4.7 at 200K.""" + ``context-1m-2025-08-07`` are attached: without the latter Bedrock caps Opus 4.6/4.7 at 200K. + A configured ``bedrock.guardrail`` rides as InvokeModel headers so every client built here + (primary, auxiliary, per-request rebuild) enforces it.""" + from agent.bedrock_adapter import bedrock_guardrail_headers sdk = _require_sdk("the Bedrock provider") if not hasattr(sdk, "AnthropicBedrock"): raise ImportError("anthropic.AnthropicBedrock not available. Upgrade with: pip install 'anthropic>=0.39.0'") return sdk.AnthropicBedrock( aws_region=region, timeout=_client_timeout(None), max_retries=0, # retry belongs to hermes's outer loop (honors Retry-After) - default_headers=_beta_header([*_COMMON_BETAS, _CONTEXT_1M_BETA]), + default_headers={**_beta_header([*_COMMON_BETAS, _CONTEXT_1M_BETA]), **bedrock_guardrail_headers()}, ) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 3174ee5135..3ad9b436be 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -113,8 +113,8 @@ from agent.model_metadata import ( ) from hermes_cli.config import get_hermes_home from agent.auxiliary_health import _custom_health_base_url, _unhealthy_cache_key -from hermes_constants import OPENROUTER_BASE_URL -from utils import base_url_host_matches, base_url_hostname, env_float, is_truthy_value, model_forces_max_completion_tokens, normalize_proxy_env_vars +from hermes_constants import OPENROUTER_BASE_URL, hermes_home_key +from utils import base_url_host_matches, base_url_hostname, base_url_origin, env_float, is_truthy_value, model_forces_max_completion_tokens, normalize_proxy_env_vars logger = logging.getLogger(__name__) @@ -936,6 +936,9 @@ def _to_openai_base_url(base_url: str) -> str: without it). Anthropic-only gateways keep their path. """ url = str(base_url or "").strip().rstrip("/") + if base_url_hostname(url) == "api.actual.inc": + from hermes_cli.auth import normalize_actual_base_url + return normalize_actual_base_url(url) if url.endswith("/anthropic"): if base_url_host_matches(url, "open.bigmodel.cn") or base_url_host_matches(url, "api.z.ai"): rewritten = url[: -len("/anthropic")] + "/coding/paas/v4" @@ -1446,6 +1449,15 @@ class _CodexCompletionsAdapter: return resp_kwargs, model, timeout def create(self, **kwargs) -> Any: + from hermes_cli.providers import is_actual_route + + if is_actual_route( + getattr(self._client, "_hermes_aux_effective_provider", ""), + str(getattr(self._client, "base_url", "") or ""), + ): + raise ValueError( + "Actual requests require Chat Completions; refusing to call /responses." + ) # Low-level ``responses.create(stream=True)`` and assemble the final response ourselves # from ``response.output_item.done``: the high-level ``responses.stream()`` rebuilds from # ``response.completed.response.output``, which Codex returns as ``null`` (SDK crash). @@ -2041,6 +2053,14 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: continue raw_base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url via = "" + # The session's own endpoint wins for its provider: the key was issued for that gateway, and + # sending it to the registry default 401s, then quarantines the provider the main model is on. + runtime = _normalize_main_runtime(None) + if runtime.get("provider") == provider_id and runtime.get("base_url"): + raw_base_url = runtime["base_url"].rstrip("/") + if isinstance(runtime.get("api_key"), str) and runtime["api_key"]: + api_key = runtime["api_key"] + via = " (session endpoint)" model = _get_aux_model_for_provider(provider_id) or None if model is None: continue # skip provider if we don't know a valid aux model @@ -2214,13 +2234,36 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: return None, None if runtime is None and nous: logger.debug("Auxiliary Nous: runtime JWT refresh failed; checking stored auth.json token.") + if runtime is not None: + api_key, base_url = runtime + else: + api_key = _nous_api_key(nous or {}) + if not api_key: + logger.warning( + "Auxiliary Nous client unavailable: no usable inference JWT found " + "(run: hermes auth add nous)." + ) + _mark_provider_unhealthy("nous", ttl=60) + return None, None + base_url = str( + (nous or {}).get("inference_base_url") or os.getenv("NOUS_INFERENCE_BASE_URL", _NOUS_DEFAULT_BASE_URL) + ).rstrip("/") + lane = "vision" if vision else "text" + # The free tier's host serves exactly one model, for every lane: asking it for the Portal's + # recommended aux model is a guaranteed 429 ``model_not_free``. Pin the route's model instead. + # Vision rides the same id (the backing model is multimodal; a backing that is not answers + # the request with the upstream's own error, which the ladder handles like any other). + from hermes_cli.anon_auth import GUEST_MODEL, route_is_welcome_host global auxiliary_is_nous + if route_is_welcome_host(base_url): + auxiliary_is_nous = True + logger.debug("Auxiliary/%s: Nous free tier; using %s", lane, GUEST_MODEL) + return _create_openai_client(api_key=api_key, base_url=base_url), GUEST_MODEL auxiliary_is_nous = True logger.debug("Auxiliary client: Nous Portal") # Portal recommended-models is authoritative (tier-aware); _NOUS_MODEL when unreachable/null. # Probes skip the lookup: exact model is irrelevant and it hits the network. model = _NOUS_MODEL - lane = "vision" if vision else "text" if not _aux_probe_active(): try: from hermes_cli.models import get_nous_recommended_aux_model @@ -2236,20 +2279,6 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: "falling back to %s", lane, exc, model, ) - if runtime is not None: - api_key, base_url = runtime - else: - api_key = _nous_api_key(nous or {}) - if not api_key: - logger.warning( - "Auxiliary Nous client unavailable: no usable inference JWT found " - "(run: hermes auth add nous)." - ) - _mark_provider_unhealthy("nous", ttl=60) - return None, None - base_url = str( - (nous or {}).get("inference_base_url") or os.getenv("NOUS_INFERENCE_BASE_URL", _NOUS_DEFAULT_BASE_URL) - ).rstrip("/") return _create_openai_client(api_key=api_key, base_url=base_url), model @@ -2443,7 +2472,9 @@ def _relay_sync_completion( from agent.auxiliary_wire import prepare_chat_messages kwargs = prepare_chat_messages(client, kwargs) - callback = create or (lambda request: client.chat.completions.create(**request)) + # The progress hook is installed per TASK, so every attempt (retries, recovery rungs, fallbacks) + # must stream through _create_with_progress or the compression watchdog sees silence (#98466). + callback = create or (lambda request: _create_with_progress(client, request)) route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode) # Isolate only the provider callback so the owning thread can unwind its lease/DB # transaction on hard cancel without touching the shared client. @@ -2465,7 +2496,8 @@ async def _relay_async_completion( from agent.auxiliary_wire import prepare_chat_messages kwargs = prepare_chat_messages(client, kwargs) - callback = create or (lambda request: client.chat.completions.create(**request)) + # Async twin of the seam default above (#98466). + callback = create or (lambda request: _acreate_with_progress(client, request)) route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode) if route is None: return await callback(kwargs) @@ -2975,7 +3007,7 @@ def _contains_any(text: str, needles: Tuple[str, ...]) -> bool: _PAYMENT_KEYWORDS = ( "credits", "insufficient funds", "can only afford", "billing", "payment required", "out of funds", "run out of funds", "balance_depleted", "no usable credits", - "model_not_supported_on_free_tier", "not available on the free tier", + "model_not_supported_on_free_tier", "not available on the free tier", "isn't available on the free tier", "requires a subscription", "upgrade for access", "upgrade for higher limits", "reached your session usage limit", "quota exceeded", "quota_exceeded", "too many tokens per day", "daily limit", "tokens per day", "daily quota", "resource exhausted", @@ -3008,7 +3040,7 @@ _RATE_LIMIT_KEYWORDS = ( _RATE_LIMIT_BILLING_KEYWORDS = ( "credits", "insufficient funds", "billing", "payment required", "can only afford", "out of funds", "run out of funds", "balance_depleted", "no usable credits", - "model_not_supported_on_free_tier", "not available on the free tier", + "model_not_supported_on_free_tier", "not available on the free tier", "isn't available on the free tier", ) @@ -3281,24 +3313,45 @@ def _provider_for_host(base_url: str, table: Tuple[Tuple[str, str], ...]) -> Opt def _recoverable_pool_provider( resolved_provider: str, client: Any, main_runtime: Optional[Dict[str, Any]] = None ) -> Optional[str]: - """Infer which provider pool can recover the current auxiliary client.""" + """Infer which provider pool can recover the current auxiliary client. + None when the client targets a different host than the session's configured endpoint for that + provider: a rejection there says nothing about the key, so rotating/quarantining it would kill a + working credential (Miho report — proxy users).""" normalized = _normalize_aux_provider(resolved_provider) + base = str(getattr(client, "base_url", "") or "") + runtime = _normalize_main_runtime(main_runtime) + rt_base = str(runtime.get("base_url") or "") + rt_key = runtime.get("api_key") + client_key = getattr(client, "api_key", None) + # Only the SESSION's own key is shielded, and only when it was sent somewhere other than the + # session's origin (scheme+host+port — a port or HTTPS→HTTP change is a different trust boundary). + # An independently owned auxiliary pool keeps rotating at its own origin. + if (base and rt_base and normalized == runtime.get("provider") + and isinstance(rt_key, str) and rt_key and client_key == rt_key + and base_url_origin(base) != base_url_origin(rt_base)): + logger.info("Auxiliary: %s rejected the session key at %s, but the session's endpoint is %s — " + "endpoint mismatch, not a dead key; skipping credential rotation", + normalized, base_url_hostname(base), base_url_hostname(rt_base)) + return None if normalized not in {"", "auto", "custom"}: return normalized - base = str(getattr(client, "base_url", "") or "") known = _provider_for_host(base, _POOL_PROVIDER_BY_HOST) if known is not None: return known # Providers outside the table (e.g. opencode-go): match base URL against registered # api_key providers so pool rotation works for them too. if main_runtime: - rt_provider = _normalize_main_runtime(main_runtime).get("provider", "") + runtime = _normalize_main_runtime(main_runtime) + rt_provider = runtime.get("provider", "") if rt_provider and rt_provider not in {"", "auto", "custom"}: with contextlib.suppress(Exception): from hermes_cli.auth import PROVIDER_REGISTRY pconfig = PROVIDER_REGISTRY.get(rt_provider) if pconfig and getattr(pconfig, "auth_type", None) == "api_key": - rt_base = str(getattr(pconfig, "inference_base_url", "") or "").rstrip("/") + # The pool's key was issued for the endpoint the main runtime actually uses; a + # rejection at any other host (registry default vs configured proxy) says nothing + # about that key, so it must not be marked exhausted. + rt_base = str(runtime.get("base_url") or getattr(pconfig, "inference_base_url", "") or "").rstrip("/") if rt_base and base_url_host_matches(base, base_url_hostname(rt_base)): return rt_provider return None @@ -3789,12 +3842,16 @@ async def _call_fallback_candidate_async( def _try_payment_fallback( failed_provider: str, task: str = None, reason: str = "payment error", *, - failed_base_url: str = "", failure_scope: Any = None, + failed_base_url: str = "", failure_scope: Any = None, main_runtime: Optional[Dict[str, Any]] = None, ) -> Tuple[Optional[Any], Optional[str], str]: """Try the auto-detection chain after a payment/credit or connection error, skipping the failed provider (and the main-provider path when it maps to the same backend). Returns (client, model, label) or (None, None, "").""" skip = failed_provider.lower().strip() - main_provider = _read_main_provider() + # The SESSION's provider decides whether discovery is allowed: a live `/model xai-oauth` session + # over a persisted ``provider: auto`` is a selection, so the disk value alone is not the answer. + main_provider = _normalize_main_runtime(main_runtime).get("provider") or _read_main_provider() + if not _discovery_chain_allowed(main_provider, task): + return None, None, "" skip_labels = {skip} if main_provider and main_provider.lower() in skip: skip_labels.add(main_provider.lower()) @@ -4168,6 +4225,21 @@ def _try_main_provider_route( return client, resolved or main_model, resolved_provider +def _discovery_chain_allowed(main_provider: str, task: Optional[str] = None) -> bool: + """The built-in discovery chain is a convenience for installs with NO selected main provider. + Once the user picked one, every auxiliary route must be a provider they configured (main, + ``auxiliary.``, ``fallback_providers``); guessing "whatever else is logged in" bills an + account they never pointed this session at (xAI OAuth session with a dead token → every + compression silently charged to a Nous Portal balance).""" + if (main_provider or "").strip().lower() in {"", "auto"}: + return True + logger.warning( + "Auxiliary %s: main provider %s is unavailable and no fallback_chain / fallback_providers is " + "configured — refusing to guess another logged-in provider. Re-authenticate (`hermes model`) " + "or declare a fallback.", task or "call", main_provider) + return False + + def _try_discovery_chain() -> Tuple[Optional[OpenAI], Optional[str], str]: """Step 3: hardcoded aggregator/fallback chain, skipping unhealthy providers.""" tried = [] @@ -4217,6 +4289,8 @@ def _resolve_auto_route( task, main_provider or "auto", reason="main provider unavailable") if fb_client is not None: return fb_client, fb_model, fb_label + if not _discovery_chain_allowed(main_provider, task): + return None, None, "" return _try_discovery_chain() @@ -4416,16 +4490,47 @@ def _log_once_debug(seen: set, key: Any, msg: str, *args: Any) -> None: logger.debug(msg, *args) +def _is_actual_auxiliary_route(req: _ResolveRequest, base_url: str) -> bool: + from hermes_cli.auth import normalize_actual_base_url + from hermes_cli.providers import is_actual_route + from hermes_cli.route_identity import normalize_route_base_url + + if is_actual_route(req.provider, base_url): + return True + runtime = _normalize_main_runtime(req.main_runtime) + return bool( + base_url + and is_actual_route(runtime.get("provider", ""), runtime.get("base_url", "")) + and normalize_route_base_url(normalize_actual_base_url(base_url)) + == normalize_route_base_url( + normalize_actual_base_url(runtime.get("base_url", "")) + ) + ) + + def _wrap_transport(req: _ResolveRequest, client_obj: Any, final_model_str: str, base_url_str: str = "", api_key_str: str = ""): """Wrap a plain OpenAI client in the right transport adapter; specialized wrappers pass through. - Codex (Responses API): explicit ``api_mode=codex_responses`` (or provider ``actual``), else — with no + Codex (Responses API): explicit ``api_mode=codex_responses``, else — with no explicit api_mode — api.openai.com + codex model. Anthropic (Messages): ``api_mode=anthropic_messages``, any ``/anthropic`` suffix, ``api.kimi.com/coding``, or ``api.anthropic.com``.""" - needs_codex = not (isinstance(client_obj, CodexAuxiliaryClient) or req.raw_codex) and ( - req.provider == "actual" or req.api_mode == "codex_responses" - or (not req.api_mode and base_url_hostname(base_url_str) == "api.openai.com" - and "codex" in (final_model_str or "").lower()) + if _is_actual_auxiliary_route(req, base_url_str): + client = ( + client_obj._real_client + if isinstance(client_obj, CodexAuxiliaryClient) + else client_obj + ) + client._hermes_aux_effective_provider = "actual" + return client + needs_codex = not ( + isinstance(client_obj, CodexAuxiliaryClient) or req.raw_codex + ) and ( + req.api_mode == "codex_responses" + or ( + not req.api_mode + and base_url_hostname(base_url_str) == "api.openai.com" + and "codex" in (final_model_str or "").lower() + ) ) if needs_codex: logger.debug("resolve_provider_client: wrapping client in CodexAuxiliaryClient " @@ -4563,6 +4668,9 @@ def _resolve_custom_branch(req: _ResolveRequest) -> _ResolveResult: if _main_base and _main_key: custom_base, custom_key = _main_base, _main_key if custom_base and custom_key: + if _is_actual_auxiliary_route(req, custom_base): + from hermes_cli.auth import normalize_actual_base_url + custom_base = normalize_actual_base_url(custom_base) final_model = _normalize_resolved_model( model or (main_runtime.get("model") if main_runtime else None) or "gpt-4o-mini", provider, ) @@ -4616,14 +4724,21 @@ def _resolve_named_custom_branch(req: _ResolveRequest) -> Optional[_ResolveResul custom_entry = _get_named_custom_provider(provider) if not custom_entry: return None - custom_base = (custom_entry.get("base_url") or "").strip() - custom_key = _named_custom_api_key(custom_entry, provider, custom_base) + # A per-task/explicit base_url or api_key composes OVER the named entry's defaults: the entry supplies + # whatever the caller left blank, never replaces what the caller set (compression prompts carry + # conversation history, so a silently swapped destination is a data-routing bug, not a nuisance). + custom_base = (req.explicit_base_url or custom_entry.get("base_url") or "").strip() + custom_key = (req.explicit_api_key or "").strip() or _named_custom_api_key(custom_entry, provider, custom_base) if custom_key == "no-key-required": logger.warning("resolve_provider_client: named custom provider %r has no resolvable " "api_key — request will be sent with placeholder no-key-required " "and will 401 on auth-required endpoints", custom_entry.get("name") or provider) - # Explicit per-task api_mode override wins over the provider entry's. + # Actual's wire protocol takes precedence over persisted task/provider modes. entry_api_mode = (req.api_mode or custom_entry.get("api_mode") or "").strip() + if _is_actual_auxiliary_route(req, custom_base): + from hermes_cli.auth import normalize_actual_base_url + custom_base = normalize_actual_base_url(custom_base) + entry_api_mode = "chat_completions" if not custom_base: logger.warning("resolve_provider_client: named custom provider %r has no base_url", provider) return None, None @@ -4710,7 +4825,7 @@ def _resolve_api_key_branch(req: _ResolveRequest, pconfig: Any, resolve_creds: C return None, None base_url = _to_openai_base_url(raw_base_url) # Explicit base_url override: a fallback_model/custom_providers entry pointing a built-in name elsewhere. - if req.explicit_base_url: + if req.explicit_base_url and provider != "actual": base_url = _to_openai_base_url(req.explicit_base_url.strip().rstrip("/")) final_model = _normalize_resolved_model(req.model or _get_aux_model_for_provider(provider), provider) if provider == "gemini": @@ -5199,7 +5314,9 @@ def _client_cache_key( # share an entry, and the second builder's _store_cached_client would close the first's client. model_key = model or runtime.get("model", "") api_key_key = _runtime_cache_discriminator("api_key", api_key or "") - return (provider, async_mode, base_url or "", api_key_key, api_mode or "", runtime_key, is_vision, task_key, pool_hint, model_key) + # Profile home leads the key: callers that omit api_key (pool / Nous auth.json paths) would + # otherwise share one client across multiplex profiles holding different credentials. + return (hermes_home_key(), provider, async_mode, base_url or "", api_key_key, api_mode or "", runtime_key, is_vision, task_key, pool_hint, model_key) def _current_event_loop() -> Any: @@ -5473,13 +5590,22 @@ def _unwrap_moa_provider(prov: str, mdl: Optional[str]) -> Tuple[str, Optional[s def _expand_direct_api_alias(prov: Optional[str], existing_base: Optional[str]) -> Tuple[Optional[str], Optional[str]]: - """``provider: openai`` → custom + api.openai.com/v1; a user base_url is kept but the provider still becomes custom.""" + """``provider: openai`` → custom + the user's OpenAI endpoint, api.openai.com/v1 only as the last resort. + + A ``providers.openai`` entry keeps the provider name so the named-custom branch applies its base_url and + key; otherwise ``OPENAI_BASE_URL`` (a proxy/gateway the OPENAI_API_KEY was issued for) wins over the + public endpoint — sending the proxy key to api.openai.com 401s and then quarantines a valid key. + """ if not prov: return prov, existing_base target_base = _AUX_DIRECT_API_BASE_URLS.get(prov.strip().lower()) if target_base is None: return prov, existing_base - return "custom", existing_base or target_base + with contextlib.suppress(Exception): + from hermes_cli.runtime_provider import _get_named_custom_provider + if _get_named_custom_provider(prov) is not None: + return prov, existing_base + return "custom", existing_base or os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/") or target_base def _preserve_provider_with_base_url(prov: Optional[str]) -> bool: @@ -6492,6 +6618,45 @@ async def _acreate_with_stream(client: Any, kwargs: Dict[str, Any], task: Option return await _aggregate_chat_stream_async(chunks, model=model, total_ceiling=total_ceiling) +def _async_client_streams_internally(client: Any) -> bool: + """Async twin of :func:`_client_streams_internally` (the async adapters are separate classes).""" + return isinstance(client, (AsyncCodexAuxiliaryClient, AsyncAnthropicAuxiliaryClient, AsyncBedrockAuxiliaryClient)) + + +async def _acreate_with_progress( + client: Any, kwargs: Dict[str, Any], task: Optional[str] = None, *, force_stream: bool = False +) -> Any: + """Async :func:`_create_with_progress`: stream + re-aggregate (ticking the hook per substantive + chunk) when a progress hook is active or the provider is stream-only; plain create otherwise.""" + _notify_aux_dispatch() + _notify_aux_progress() + if (not _aux_progress_active() and not force_stream) or _async_client_streams_internally(client): + response = await client.chat.completions.create(**kwargs) + if not _async_client_streams_internally(client): + _notify_aux_provider_response() + return response + stream_kwargs, model, total_ceiling = _stream_request_plan(kwargs) + try: + chunks = await client.chat.completions.create(**stream_kwargs) + except Exception as exc: + # Only a rejected stream NEGOTIATION falls back to a plain call (mirrors the sync wrapper); a + # failure mid-consumption below reaches the classified recovery ladder instead of silently + # re-sending the whole prompt non-streaming. + if (force_stream or _is_transient_transport_error(exc) or _is_auth_error(exc) + or _is_payment_error(exc) or _is_rate_limit_error(exc)): + raise + logger.debug("Auxiliary %s: streamed async request failed (%s); retrying non-streaming", + task or "call", exc) + _notify_aux_dispatch() + response = await client.chat.completions.create(**kwargs) + _notify_aux_provider_response() + return response + if hasattr(chunks, "choices"): # shims may hand back a complete response despite stream=True + _notify_aux_provider_response() + return chunks + return await _aggregate_chat_stream_async(chunks, model=model, total_ceiling=total_ceiling) + + # Shared request head + recovery ladder for call_llm / async_call_llm: the entry points differ # only in how a request is awaited, so route resolution and the ordered recovery ladder are # written once. The ladder is a generator yielding ``_LadderStep`` requests and receiving the @@ -6833,6 +6998,27 @@ def _ladder_credential_rungs( return None, first_err +def _next_fallback_after_quarantine( + task: Optional[str], resolved_provider: str, is_auto: bool, route: _LadderRoute, + failed_model: Optional[str], failure_scope: Any, +) -> Tuple[Optional[Any], Optional[str], str]: + """Next candidate after a fallback entry was quarantined mid-request: remaining configured + entries (task chain, then main chain on auto) before the discovery chain.""" + reason = "stale fallback credential" + fb = _try_configured_fallback_chain( + task, resolved_provider or "auto", reason=reason, failed_model=failed_model, + failed_base_url=route.base_info, failure_scope=failure_scope) + if fb[0] is None and is_auto: + fb = _try_main_fallback_chain( + task, resolved_provider or "auto", reason=reason, failed_model=failed_model, + failed_base_url=route.base_info, failure_scope=failure_scope) + if fb[0] is None: + fb = _try_payment_fallback( + resolved_provider, task, reason=reason, failed_base_url=route.base_info, + failure_scope=failure_scope, main_runtime=route.main_runtime) + return fb + + def _ladder_provider_fallback(first_err: Exception, route: _LadderRoute): """Last rung: other providers (per-task chain; then auto: main fallback chain + discovery chain, explicit: main-agent-model net). Returns the response or None. @@ -6880,23 +7066,23 @@ def _ladder_provider_fallback(first_err: Exception, route: _LadderRoute): if fb_client is None: fb_client, fb_model, fb_label = _try_payment_fallback( resolved_provider, task, reason=reason, failed_base_url=route.base_info, - failure_scope=_chain_failure_scope) + failure_scope=_chain_failure_scope, main_runtime=route.main_runtime) elif fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( resolved_provider, task, reason=reason, failed_model=_chain_failed_model, failed_base_url=route.base_info, failure_scope=_chain_failure_scope) if fb_client is not None: - # Second pass: the candidate credential was stale and quarantined — walk the discovery - # chain once more (unhealthy entries are skipped). + # Second pass: the candidate credential was stale and quarantined — re-walk the CONFIGURED + # chains first (the quarantined entry is now unhealthy and skipped, so later entries get + # their turn), then discovery where the selection policy allows it. for _pass in range(2): _record_route_info(route.route_info, _fallback_provider_from_label(fb_label), fb_model) fb_resp = yield _LadderStep("fallback", (fb_client, fb_model, fb_label)) if fb_resp is not None: return fb_resp if _pass == 0: - fb_client, fb_model, fb_label = _try_payment_fallback( - resolved_provider, task, reason="stale fallback credential", - failed_base_url=route.base_info, failure_scope=_chain_failure_scope) + fb_client, fb_model, fb_label = _next_fallback_after_quarantine( + task, resolved_provider, is_auto, route, _chain_failed_model, _chain_failure_scope) if fb_client is None: break # All fallback layers exhausted — one user-visible warning, then re-raise. @@ -7338,15 +7524,10 @@ async def _async_call_llm_impl( try: # Retry ONCE on the same provider for a transient blip before fallback (see call_llm()). # (PR #16587) - _force_stream_async = ( - _provider_requires_stream(request_provider, req.base_info or req.resolved_base_url) - and not isinstance(client, ( - AsyncCodexAuxiliaryClient, AsyncAnthropicAuxiliaryClient, AsyncBedrockAuxiliaryClient))) + _force_stream_async = _provider_requires_stream(request_provider, req.base_info or req.resolved_base_url) async def _acreate(_kwargs: Dict[str, Any]) -> Any: - if _force_stream_async: - return await _acreate_with_stream(client, _kwargs, task) - return await client.chat.completions.create(**_kwargs) + return await _acreate_with_progress(client, _kwargs, task, force_stream=_force_stream_async) async def _primary(**validate_kw: Any) -> Any: return _validate_llm_response( diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py index b962c0d32c..365bff1f97 100644 --- a/agent/bedrock_adapter.py +++ b/agent/bedrock_adapter.py @@ -298,6 +298,76 @@ def resolve_bedrock_runtime_region(config: Optional[Dict[str, Any]] = None) -> s return cfg_region or resolve_bedrock_region() +def bedrock_region_from_runtime_url(base_url: str) -> str: + """AWS region from a ``bedrock-runtime..amazonaws.com`` URL (default us-east-1).""" + m = re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url or "") + return m.group(1) if m else "us-east-1" + + +def bedrock_guardrail_config(config: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]: + """Converse ``guardrailConfig`` from ``bedrock.guardrail`` in config.yaml (None when unset).""" + if config is None: + config = {} + with suppress(Exception): + from hermes_cli.config import load_config_readonly + config = load_config_readonly() + gr = ((config or {}).get("bedrock") or {}).get("guardrail") or {} + if not (gr.get("guardrail_identifier") and gr.get("guardrail_version")): + return None + out = {"guardrailIdentifier": gr["guardrail_identifier"], "guardrailVersion": gr["guardrail_version"]} + for src, dst in (("stream_processing_mode", "streamProcessingMode"), ("trace", "trace")): + if gr.get(src): + out[dst] = gr[src] + return out + + +def bedrock_guardrail_headers(config: Optional[Dict[str, Any]] = None) -> Dict[str, str]: + """InvokeModel/Messages-wire form of the configured guardrail. The AnthropicBedrock SDK speaks + InvokeModel, which has no ``guardrailConfig`` body field; Bedrock reads the guardrail from these + headers instead (same enforcement, keeps prompt caching / thinking / 1M context).""" + gr = bedrock_guardrail_config(config) + if not gr: + return {} + headers = { + "X-Amzn-Bedrock-GuardrailIdentifier": str(gr["guardrailIdentifier"]), + "X-Amzn-Bedrock-GuardrailVersion": str(gr["guardrailVersion"]), + } + if str(gr.get("trace", "")).lower() in {"enabled", "enabled_full", "true"}: + headers["X-Amzn-Bedrock-Trace"] = "ENABLED" + return headers + + +GUARDRAIL_ACTION_FIELD = "amazon-bedrock-guardrailAction" + + +def anthropic_response_guardrail_intervened(response: Any) -> bool: + """True when Bedrock substituted the InvokeModel reply with guardrail messaging. Unlike Converse + (``stopReason=guardrail_intervened``), InvokeModel keeps ``stop_reason=end_turn`` and signals the + block only via an unmodelled body field the Anthropic SDK keeps in ``model_extra``.""" + extra = getattr(response, "model_extra", None) or {} + return str(extra.get(GUARDRAIL_ACTION_FIELD, "")).upper() == "INTERVENED" + + +def bind_bedrock_runtime(agent, base_url: str, api_mode: str) -> None: + """Point *agent* at a non-Mantle Bedrock wire: ``bedrock_converse`` (boto3 direct, no SDK client) or + ``anthropic_messages`` (AnthropicBedrock SDK, SigV4 via the boto3 chain). ``aws-sdk`` is a sentinel, + never a credential, so the generic Anthropic/OpenAI client builders must not see it. Startup and every + later rebuild (/model switch, fallback restore, fallback-to-Bedrock) share this so region and guardrail + state never lag the active endpoint.""" + agent._bedrock_region = bedrock_region_from_runtime_url(base_url) + agent._bedrock_guardrail_config = bedrock_guardrail_config() + agent.client = None + agent._client_kwargs = {} + agent.api_key = agent._anthropic_api_key = "aws-sdk" + agent._anthropic_base_url = base_url + agent._is_anthropic_oauth = False + if api_mode == "anthropic_messages": + from agent.anthropic_adapter import build_anthropic_bedrock_client + agent._anthropic_client = build_anthropic_bedrock_client(agent._bedrock_region) + else: + agent._anthropic_client = None + + def bedrock_model_ids_or_none() -> Optional[List[str]]: """Live-discover Bedrock model IDs; None on failure/empty so callers use the static list.""" with suppress(Exception): diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 3f3e83004a..a311c66ff1 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1872,7 +1872,10 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool logger.warning("Could not normalize fallback model %r for provider %r: %s", fb_model, fb_provider, _norm_err) fb_base_url = str(fb_client.base_url) - if not fb_api_mode_explicit and fb_api_mode == "chat_completions": + from hermes_cli.providers import is_actual_route + if is_actual_route(fb_provider, fb_base_url): + fb_api_mode = "chat_completions" + elif not fb_api_mode_explicit and fb_api_mode == "chat_completions": fb_api_mode = _fallback_api_mode_resolved(agent, fb_provider, fb_model, fb_base_url) old_model, old_provider, old_base_url = agent.model, agent.provider, agent.base_url @@ -2704,6 +2707,7 @@ class _StreamingCall(StreamingWaitMonitor): response = self._attempt_stream_response = getattr(raw_stream, "response", None) self.agent._capture_rate_limits(response) self.agent._capture_credits(response) + self.agent._capture_nous_model_switch(response) self.agent._stream_diag_capture_response(self.clients.diag, response) self.agent._check_openrouter_cache_status(response) self._writer_token = claim_stream_writer(self.agent) diff --git a/agent/client_lifecycle.py b/agent/client_lifecycle.py index 071b6c9855..2fc520fa06 100644 --- a/agent/client_lifecycle.py +++ b/agent/client_lifecycle.py @@ -67,6 +67,11 @@ def _valid_credential_pair(api_key: Any, base_url: Any) -> bool: def _swap_fallback_clients(agent, fb_client, fb_provider: str, fb_model: str, fb_base_url: str, fb_api_mode: str) -> None: """Install the fallback client(s) in place, honoring request_timeout_seconds (None = SDK default).""" timeout = get_provider_request_timeout(fb_provider, fb_model) + if fb_provider == "bedrock" and fb_api_mode in ("anthropic_messages", "bedrock_converse"): + # Non-Mantle Bedrock: boto3-chain auth, no OpenAI/Anthropic SDK client to carry over. + from agent.bedrock_adapter import bind_bedrock_runtime + bind_bedrock_runtime(agent, fb_base_url, fb_api_mode) + return # The SDK exposes an empty/stale api_key when a rotating source is installed. key_provider = vars(fb_client).get("_api_key_provider") credential = key_provider if callable(key_provider) else fb_client.api_key @@ -677,7 +682,12 @@ class ClientLifecycleMixin: env_url = get_env_prefer_dotenv(url_var).strip().rstrip("/") if url_var else "" default_base = (pconfig.inference_base_url or "").strip().rstrip("/") base_url = env_url or default_base - if self.provider in ("kimi-coding", "zai"): + if self.provider == "actual": + from hermes_cli.auth import normalize_actual_base_url + from hermes_cli.runtime_provider import _config_base_url_for_provider, _get_model_config + configured_base = _config_base_url_for_provider(_get_model_config(), "actual") + base_url = normalize_actual_base_url(configured_base or base_url) + elif self.provider in ("kimi-coding", "zai"): from hermes_cli import auth as _auth resolver = _auth._resolve_kimi_base_url if self.provider == "kimi-coding" else _auth._resolve_zai_base_url base_url = resolver(api_key, pconfig.inference_base_url, env_url).rstrip("/") @@ -912,13 +922,30 @@ class ClientLifecycleMixin: if merged: self._client_kwargs["default_headers"] = merged - def _swap_credential(self, entry) -> None: + def _swap_credential(self, entry) -> bool: + """Adopt *entry* as the live credential. Returns False, changing nothing, when the entry's + route cannot serve this conversation's model (a conversation's model is never rewritten by a + rotation; the caller treats a refused swap as "no entry").""" runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "") runtime_base = getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None) or self.base_url + from hermes_cli.providers import is_actual_route + actual_route = is_actual_route(getattr(self, "provider", ""), runtime_base) + if actual_route: + from hermes_cli.auth import normalize_actual_base_url + runtime_base = normalize_actual_base_url(runtime_base) + stripped_base = runtime_base.rstrip("/") if isinstance(runtime_base, str) else runtime_base + # Refuse BEFORE any state changes below: a refused swap must leave the agent exactly as it was. + from hermes_cli.anon_auth import route_can_serve_model + if not route_can_serve_model(getattr(self, "provider", None), stripped_base, getattr(self, "model", None)): + logger.info("Credential %s skipped: its route cannot serve model %s", getattr(entry, "id", "?"), self.model) + return False + if actual_route: + self.api_mode = "chat_completions" + if hasattr(self, "_transport_cache"): + self._transport_cache.clear() self._credential_pool_entry_id = getattr(entry, "id", None) from hermes_cli.route_identity import normalize_route_base_url route_changed = normalize_route_base_url(self.base_url) != normalize_route_base_url(runtime_base) - stripped_base = runtime_base.rstrip("/") if isinstance(runtime_base, str) else runtime_base if self.api_mode == "anthropic_messages": with suppress(Exception): self._anthropic_client.close() @@ -926,13 +953,14 @@ class ClientLifecycleMixin: self._anthropic_client = self._build_direct_anthropic_client(runtime_key, self._anthropic_base_url) self._is_anthropic_oauth = self._anthropic_oauth_flag(runtime_key) self.api_key, self.base_url = runtime_key, stripped_base - return + return True self.api_key, self.base_url = runtime_key, stripped_base # Inlined (not _sync_client_kwargs_credentials): tests call this unbound on a SimpleNamespace agent. self._client_kwargs["api_key"] = self.api_key self._client_kwargs["base_url"] = self.base_url self._reapply_route_client_config(route_changed=route_changed) self._replace_primary_openai_client(reason="credential_rotation") + return True def _reapply_route_client_config(self, *, route_changed: bool) -> None: """Recompute route-derived client kwargs (TLS material, default headers) for ``self.base_url``. diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index e58638cca8..48a6c1a673 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -918,6 +918,15 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta return bool(agent._interrupt_requested) def _open_codex_stream(next_api_kwargs: dict[str, Any]): + from hermes_cli.providers import is_actual_route + + if is_actual_route( + getattr(agent, "provider", ""), + str(getattr(active_client, "base_url", "") or ""), + ): + raise ValueError( + "Actual requests require Chat Completions; refusing to call /responses." + ) stream_kwargs = _sanitize_consumer_codex_request(agent, next_api_kwargs) stream_kwargs["stream"] = True return active_client.responses.create(**_bypass_sdk_request_transform(stream_kwargs)) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 15d3543e07..56f4447841 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1555,13 +1555,31 @@ def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_conten return f"[{tool_name}]{first_arg} ({content_len:,} chars result)" -def resolve_model_threshold(model: str, model_thresholds: dict[str, float] | None, default: float) -> float: - """Per-model threshold: longest matching ``model_thresholds`` substring key wins, else ``default``. - Module-level so plugin context engines can reuse it.""" +def _model_threshold_key_rank(key: str, model: str, provider: str) -> "tuple[int, int] | None": + """Match rank for one ``model_thresholds`` key, or None when it does not apply. + ``":"`` keys apply only on that provider; bare keys apply on every route. + The same slug means different windows on different routes (Codex caps Astra at 272K; OpenRouter + serves the full window), so a bare ``astra: 0.85`` written for Codex silently leaks everywhere. + Rank = (substring length, scoped): the most specific model match wins, scope breaks ties.""" + scope, sep, substr = key.partition(":") + if not sep: + return (len(key), 0) if key in model else None + return (len(substr), 1) if scope.strip().lower() == provider and substr in model else None + + +def resolve_model_threshold( + model: str, model_thresholds: dict[str, float] | None, default: float, provider: str = "", +) -> float: + """Per-model threshold: longest matching ``model_thresholds`` key wins, else ``default``. + Keys are substrings of the model name, optionally provider-scoped as ``":"`` + (a scoped key outranks a bare one of the same substring). Module-level so plugin context + engines can reuse it.""" if not model_thresholds or not model: return default - best_key = max((key for key in model_thresholds if key in model), key=len, default="") - return float(model_thresholds[best_key]) if best_key else default + provider = (provider or "").strip().lower() + ranked = ((_model_threshold_key_rank(key, model, provider), key) for key in model_thresholds) + best = max(((rank, key) for rank, key in ranked if rank is not None), default=None) + return float(model_thresholds[best[1]]) if best else default def _memory_provider_section(memory_context: str) -> str: @@ -2169,7 +2187,7 @@ class ContextCompressor(SummaryDispatchMixin, MicroCompactionMixin, ContextEngin self.context_length = context_length # Re-resolve from the raw config value so a switch away from an overridden model falls back correctly. _config_pct = getattr(self, "_config_threshold_percent", self.threshold_percent) - self._base_threshold_percent = resolve_model_threshold(model, self.model_thresholds, _config_pct) + self._base_threshold_percent = resolve_model_threshold(model, self.model_thresholds, _config_pct, provider) self.threshold_percent = self._effective_threshold_percent(context_length, self._base_threshold_percent) # max_tokens=None means "unspecified": keep the existing output reservation. # A switch that genuinely changes the output budget passes the new value explicitly. (#43547) @@ -2295,7 +2313,7 @@ class ContextCompressor(SummaryDispatchMixin, MicroCompactionMixin, ContextEngin self.model_thresholds = model_thresholds or {} # Raw config value, before override/floor; fallback when switching to a model with no override. self._config_threshold_percent = threshold_percent - self._base_threshold_percent = resolve_model_threshold(model, self.model_thresholds, threshold_percent) + self._base_threshold_percent = resolve_model_threshold(model, self.model_thresholds, threshold_percent, provider) self.threshold_percent = self._base_threshold_percent # Effective trigger = min(ratio threshold, cap); re-applied in update_model(). self.threshold_tokens_cap = self._coerce_threshold_tokens_cap(threshold_tokens_cap) diff --git a/agent/context_engine.py b/agent/context_engine.py index 54c10d5335..a052021b27 100644 --- a/agent/context_engine.py +++ b/agent/context_engine.py @@ -237,6 +237,6 @@ class ContextEngine(ABC): if not hasattr(self, "_config_threshold_percent"): self._config_threshold_percent = self.threshold_percent self._base_threshold_percent = resolve_model_threshold( - model, getattr(self, "model_thresholds", {}), self._config_threshold_percent) + model, getattr(self, "model_thresholds", {}), self._config_threshold_percent, provider) self.threshold_percent = self._base_threshold_percent self.threshold_tokens = int(context_length * self.threshold_percent) diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 87d36ba406..1f49aa9060 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -1768,6 +1768,15 @@ def _lower_threshold_to_aux_context( ) +def _aux_inherits_main_route(agent: Any, aux_model: str, aux_base_url: str) -> bool: + """True when the auxiliary compression client is the main model on the main endpoint.""" + from hermes_cli.route_identity import normalize_route_base_url + if str(aux_model or "").strip().lower() != str(getattr(agent, "model", "") or "").strip().lower(): + return False + main_base = normalize_route_base_url(str(getattr(agent, "base_url", "") or "")) + return not main_base or normalize_route_base_url(aux_base_url) == main_base + + def check_compression_model_feasibility(agent: Any) -> None: """Warn at session start if the aux compression context is below the threshold. Called from ``AIAgent.__init__`` (CLI sees it via ``_vprint``); the gateway wires ``status_callback`` @@ -1822,11 +1831,17 @@ def check_compression_model_feasibility(agent: Any) -> None: _aux_provider = ( _aux_cfg_provider if _aux_cfg_provider and _aux_cfg_provider != "auto" else getattr(agent, "provider", "") ) - aux_context = get_model_context_length( - aux_model, base_url=aux_base_url, api_key=aux_api_key, - config_context_length=getattr(agent, "_aux_compression_context_length_config", None), - provider=_aux_provider, custom_providers=agent._custom_providers, - ) + _aux_cfg_ctx = getattr(agent, "_aux_compression_context_length_config", None) + if _aux_cfg_ctx is None and _aux_inherits_main_route(agent, aux_model, aux_base_url): + # Same model on the same route: reuse the main model's already-resolved window (which honours + # model.context_length / provider pins). Re-resolving from scratch lost the pin and auto-lowered + # the session threshold to a catch-all catalog value (#89500, #45519). + aux_context = int(agent.context_compressor.context_length) + else: + aux_context = get_model_context_length( + aux_model, base_url=aux_base_url, api_key=aux_api_key, config_context_length=_aux_cfg_ctx, + provider=_aux_provider, custom_providers=agent._custom_providers, + ) # Aux model must meet MINIMUM_CONTEXT_LENGTH like the main model, else it cannot summarise a full window. if aux_context and aux_context < MINIMUM_CONTEXT_LENGTH: raise ValueError( diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 70e9a12762..6739c096e4 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -445,7 +445,9 @@ def _nous_entitlement_message(capability: str) -> str: get_nous_portal_account_info, ) account_info = get_nous_portal_account_info(force_fresh=True) - return format_nous_portal_entitlement_message(account_info, capability=capability) or "" + return format_nous_portal_entitlement_message( + account_info, capability=capability, in_chat=True + ) or "" except Exception: return "" @@ -756,15 +758,16 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) # request naming a surface the conversation has left (#104414). stage_surface_switch_note(agent, agent._cached_system_prompt, conversation_history) - # Plugin hook: on_session_start — fired once for a brand-new session, not on continuation. - try: - from hermes_cli.lifecycle import invoke_hook as _invoke_hook - _invoke_hook( - "on_session_start", session_id=agent.session_id, model=agent.model, - platform=getattr(agent, "platform", None) or "", - ) - except Exception as exc: - logger.warning("on_session_start hook failed: %s", exc) + # Persistence-disabled forks share their parent's session ID and are not real sessions. + if not getattr(agent, "_persist_disabled", False): + try: + from hermes_cli.lifecycle import invoke_hook as _invoke_hook + _invoke_hook( + "on_session_start", session_id=agent.session_id, model=agent.model, + platform=getattr(agent, "platform", None) or "", + ) + except Exception as exc: + logger.warning("on_session_start hook failed: %s", exc) # Cold-start credits seed (L3) fallback for the first-turn path; TUI/desktop seed at # session open, so this is idempotent (skips when _credits_state exists). Fail-open. @@ -1429,6 +1432,7 @@ def _run_conversation_turn( persist_user_display_kind: Optional[str] = None, persist_user_display_metadata: Optional[Dict[str, Any]] = None, persist_user_platform_id: Optional[str] = None, + turn_author: Optional[Dict[str, Any]] = None, moa_config: Optional[dict[str, Any]] = None, ) -> Dict[str, Any]: """Run a complete conversation with tool calling until completion; returns the result dict. @@ -1463,6 +1467,7 @@ def _run_conversation_turn( persist_user_display_kind=persist_user_display_kind, persist_user_display_metadata=persist_user_display_metadata, persist_user_platform_id=persist_user_platform_id, + turn_author=turn_author, restore_or_build_system_prompt=_restore_or_build_system_prompt, install_safe_stdio=_install_safe_stdio, sanitize_surrogates=_sanitize_surrogates, @@ -1578,6 +1583,7 @@ def run_conversation( persist_user_display_metadata: Optional[Dict[str, Any]] = None, persist_user_platform_id: Optional[str] = None, moa_config: Optional[dict[str, Any]] = None, + turn_author: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Run one turn (see ``_run_conversation_turn``) and export the current-turn boundary. @@ -1601,6 +1607,7 @@ def run_conversation( persist_user_display_metadata=persist_user_display_metadata, persist_user_platform_id=persist_user_platform_id, moa_config=moa_config, + turn_author=turn_author, ) return export_current_turn_boundary(agent, result, user_message) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index a311cae1e3..b76e53d866 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -167,6 +167,8 @@ _EXTRA_KEYS = frozenset({ "token_type", "scope", "client_id", "portal_base_url", "obtained_at", "expires_in", "agent_key_id", "agent_key_expires_in", "agent_key_reused", "agent_key_obtained_at", "tls", "secret_source", "secret_fingerprint", + # Nous guest identity (``auth_method: anonymous``): the anon_ credential is the refresh material. + "auth_method", "account_tier", "anon_token", "user_id", "org_id", # Classified failure semantics for the last exhaustion (agent/error_classifier.py). # Providers return 403 for both an edge throttle and a spending limit, so the # raw status cannot size a cooldown; persisted so a restart doesn't downgrade @@ -178,6 +180,7 @@ _EXTRA_KEYS = frozenset({ _NOUS_EXTRA_STATE_KEYS = ( "obtained_at", "expires_in", "agent_key_id", "agent_key_expires_in", "agent_key_reused", "agent_key_obtained_at", + "auth_method", "account_tier", "anon_token", "user_id", "org_id", ) # ``replace(entry, **_CLEAR_STATUS)`` returns an entry with no error state. diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index 764631f0e4..0c21708b0d 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -118,6 +118,17 @@ def _sticky_notice(text: str, level: str, key: str) -> AgentNotice: return AgentNotice(text=text, level=level, kind=CREDITS_NOTICE_KIND, key=key, id=key) +def _is_nous_welcome_route(base_url: str) -> bool: + """True when *base_url* is the Nous welcome host, which serves only the free tier. Local data only; + False wherever the free tier is not built in. The host is the evidence, not the model name: the paid + inference host can serve ``nous/welcome`` to a named account, and that account's depletion is real.""" + try: + from hermes_cli.anon_auth import route_is_welcome_host + except ImportError: + return False + return route_is_welcome_host(base_url) + + def is_free_tier_model(model: str, base_url: str = "") -> bool: """True when *model* is a Nous free-tier model, using ONLY local data: (1) ``:free`` suffix — canonical Nous free SKU marker; (2) ``stealth/`` prefix — stealth-preview SKUs are free without the suffix @@ -130,6 +141,11 @@ def is_free_tier_model(model: str, base_url: str = "") -> bool: return True if not base_url: return False + # (4) the Nous free tier: the welcome host serves only the free tier. A free-tier identity carries $0 + # by design, so the portal seed reports paid_access=False for it; that is not a depleted account, and + # "run /topup" means nothing to it. Local data only, same as the rules above. + if _is_nous_welcome_route(base_url): + return True try: from hermes_cli.models import _is_model_free from hermes_cli.models_pricing import peek_cached_pricing diff --git a/agent/error_classifier.py b/agent/error_classifier.py index e4d1709498..09488986dd 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -10,6 +10,7 @@ from __future__ import annotations import enum import json import logging +import time from dataclasses import dataclass, field from typing import Any, Callable, Dict, Iterator, Optional, Sequence @@ -514,9 +515,44 @@ def _plugin_verdict(c: _Ctx) -> Optional[Verdict]: return verdict +def _nous_welcome_tier(c: _Ctx) -> Optional[Verdict]: + """The Nous inference gateway's welcome-tier (free tier) refusals, read from the structured body. + + A 429 carrying a fairshare ``reason`` is either a tier gate (``model_not_free`` / + ``feature_not_free``: the model or feature is never served on the free tier, so retrying is + pointless — abort this route and fall back) or capacity (``at_capacity`` / ``admission_closed`` + / ``rate_limited``: honour ``retry_after``, never rotate the free tier's only credential). A + 400/403 whose message names the wrong host or a dark tier is deterministic for the request. + The parsed refusal rides ``error_context`` so the terminal copy can say what happened. + """ + from hermes_cli.anon_auth import ( + WELCOME_TIER_GATE_REASONS, parse_welcome_refusal, welcome_route_refusal) + status = c.status_code + if status == 429: + refusal = parse_welcome_refusal(c.body) + if refusal is None: + return None + ctx = {"welcome_refusal": refusal} + if refusal["reason"] in WELCOME_TIER_GATE_REASONS: + return _v(_R.model_not_found, retryable=False, should_fallback=True, error_context=ctx) + if refusal["retry_after"] > 0: + ctx["reset_at"] = time.time() + refusal["retry_after"] + return _v(_R.rate_limit, should_fallback=True, error_context=ctx) + kind = welcome_route_refusal(status, c.msg) + if kind is None: + return None + ctx = {"welcome_route": kind} + if status == 403: + return _v(_R.auth_permanent, retryable=False, should_fallback=True, error_context=ctx) + return _v(_R.format_error, retryable=False, should_fallback=True, error_context=ctx) + + def _provider_special_cases(c: _Ctx) -> Optional[Verdict]: """Highest-priority provider-specific shapes that a status code would misroute.""" msg, status = c.msg, c.status_code + welcome = _nous_welcome_tier(c) + if welcome is not None: + return welcome # Safety refusal before status classification so a 400 block isn't downgraded # to format_error and a status-less block isn't left retryable (#18028). if any(p in msg for p in _CONTENT_POLICY_BLOCKED_PATTERNS): @@ -723,7 +759,10 @@ def _classify_400(c: _Ctx) -> Verdict: # overflow because "encrypted content … could not be verified" trips it. if code == "invalid_encrypted_content" or "invalid_encrypted_content" in msg or ( "encrypted content for item" in msg and "could not be verified" in msg - ) or "could not decrypt the provided encrypted_content" in msg: + ) or "could not decrypt the provided encrypted_content" in msg or ( + # Azure Foundry (gpt-6-astra) rejects replayed reasoning from several prior responses this way (#105369). + "conflicting authenticated continuation identities" in msg + ): return _V_INVALID_ENCRYPTED # Reasoning-mandatory route rejecting a disable (GLM-5.3 on Nous Portal / OpenRouter). Deterministic # for the request shape, but the only bad field is ``reasoning: {enabled: false}`` — the loop drops diff --git a/agent/error_surface.py b/agent/error_surface.py index 77e7cd6393..95fbdadd03 100644 --- a/agent/error_surface.py +++ b/agent/error_surface.py @@ -77,7 +77,35 @@ def _surface(layer: str, code: str, retryable: bool, provider: str = "", model: # Identity captured at classification time, so clients report the session # that actually failed — not whatever the composer points at later. identity = {k: v for k, v in (("provider", provider), ("model", model)) if v} - return {"layer": layer, "code": code, "retryable": bool(retryable), **identity} + surface = {"layer": layer, "code": code, "retryable": bool(retryable), **identity} + if layer == LAYER_AUTH and provider: + # OAuth providers are fixed by signing in again; API-key providers by + # replacing the key. The client's one-click recovery needs to know which + # and how to name the account it re-opens. + surface["auth_kind"] = _auth_kind(provider) + surface["provider_label"] = _provider_label(provider) + return surface + + +def _provider_label(provider: str) -> str: + try: + from hermes_cli.models import provider_label + + return provider_label(provider) + except Exception: # pragma: no cover — advisory only + return provider + + +def _auth_kind(provider: Optional[str]) -> str: + """``"oauth"`` for providers whose credential is an OAuth/subscription grant + (desktop Accounts tab), ``"api_key"`` for everything else.""" + try: + from hermes_cli.provider_catalog import provider_catalog_by_slug + + descriptor = provider_catalog_by_slug().get((provider or "").strip().lower()) + return "oauth" if descriptor is not None and descriptor.tab == "accounts" else "api_key" + except Exception: # pragma: no cover — advisory only + return "api_key" def _disk_full(candidate: Any) -> bool: diff --git a/agent/file_safety.py b/agent/file_safety.py index 937a73cc09..1ee92367ad 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -211,6 +211,10 @@ _READ_DENIED_DIRS = ( ("browser-profile", "is the Hermes real-profile browser snapshot directory (copied cookies/logins) and cannot be read directly.", "is inside the Hermes real-profile browser snapshot (copied cookies/logins) and cannot be read directly."), + # vault.key + vault.json.enc sit side by side; key + ciphertext = plaintext, so the whole dir is one credential. + ("vault", + "is the Hermes credential vault directory and cannot be read directly (secrets are filled server-side by browser_vault_fill).", + "is inside the Hermes credential vault (encrypted secrets + local key) and cannot be read directly (browser_vault_fill resolves them server-side)."), ) diff --git a/agent/insights.py b/agent/insights.py index aa3739711b..249ce47dd0 100644 --- a/agent/insights.py +++ b/agent/insights.py @@ -10,6 +10,7 @@ from decimal import Decimal from typing import Any, Dict, List, Optional from agent.usage_pricing import CanonicalUsage, estimate_usage_cost, format_cost_label, format_duration_compact, has_known_pricing +from hermes_cli.timefmt import coerce_epoch _TOKEN_KEYS = ("input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens") _SKILL_TOOLS = {"skill_view", "skill_manage"} @@ -71,7 +72,7 @@ def _hour12(hr: int) -> str: def _day(ts: Any) -> str: - return datetime.fromtimestamp(ts).strftime("%b %d") if ts else "?" + return datetime.fromtimestamp(ts).strftime("%b %d") if ts and (ts := coerce_epoch(ts)) else "?" def _scoped(before: str, after: str = "", *, src: str = " AND s.source = ?") -> tuple[str, str]: @@ -206,7 +207,13 @@ class InsightsEngine: # ------------------------------------------------------------------ SQL def _get_sessions(self, cutoff: float, source: str = None) -> List[Dict]: - return [dict(row) for row in self._query("_GET_SESSIONS", cutoff, source)] + # Coerce the two epoch columns once at load: one corrupt/TEXT cell must degrade to "unknown" + # for that session, never abort the whole report (#99959). + rows = [dict(row) for row in self._query("_GET_SESSIONS", cutoff, source)] + for row in rows: + for col in ("started_at", "ended_at"): + row[col] = coerce_epoch(row.get(col), session_id=row.get("id"), field=col) + return rows def _get_tool_usage(self, cutoff: float, source: str = None) -> List[Dict]: """Tool call counts from two sources: ``tool_name`` on 'tool' rows (set @@ -479,9 +486,9 @@ class InsightsEngine: " ╚══════════════════════════════════════════════════════════╝", "", ] - if o.get("date_range_start") and o.get("date_range_end"): - start_str = datetime.fromtimestamp(o["date_range_start"]).strftime("%b %d, %Y") - end_str = datetime.fromtimestamp(o["date_range_end"]).strftime("%b %d, %Y") + if (start := coerce_epoch(o.get("date_range_start"))) is not None and (end := coerce_epoch(o.get("date_range_end"))) is not None: + start_str = datetime.fromtimestamp(start).strftime("%b %d, %Y") + end_str = datetime.fromtimestamp(end).strftime("%b %d, %Y") lines += [f" Period: {start_str} — {end_str}", ""] lines += self._section("📋 Overview") + [ f" Sessions: {o['total_sessions']:<12} Messages: {o['total_messages']:,}", diff --git a/agent/learn_prompt.py b/agent/learn_prompt.py index db50f95aa3..676007af7f 100644 --- a/agent/learn_prompt.py +++ b/agent/learn_prompt.py @@ -38,6 +38,12 @@ Frontmatter: cross-platform first (tempfile.gettempdir(), pathlib.Path, psutil); gate only when the dependency is genuinely platform-bound. Omit the field for portable skills. +- metadata.hermes.editorial_name: a concise, human-readable title for app + surfaces. Use normal title casing and spaces; this is presentation copy, not + the agent-facing skill identifier. +- metadata.hermes.editorial_description: one or two plain-language sentences + explaining the skill to a person browsing it. This is presentation copy and + does not replace the routing-focused top-level description. - metadata.hermes.tags: a few Capitalized, Relevant, Tags. Body section order (omit a section only if it genuinely has no content): diff --git a/agent/memory_manager.py b/agent/memory_manager.py index bc23f6617b..a80cd05d5c 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -472,27 +472,31 @@ class MemoryManager: ), kind="prefetch") @staticmethod - def _provider_sync_accepts_messages(provider: MemoryProvider) -> bool: - """Whether ``sync_turn`` accepts a ``messages`` keyword (uninspectable → assume yes).""" + def _provider_sync_accepts(provider: MemoryProvider, keyword: str) -> bool: + """Whether ``sync_turn`` accepts ``keyword`` (uninspectable → assume yes).""" params = _signature_params(provider.sync_turn) - return params is None or _has_var_kwargs(params) or "messages" in params + return params is None or _has_var_kwargs(params) or keyword in params def sync_all(self, user_content: str, assistant_content: str, *, session_id: str = "", - messages: Optional[List[Dict[str, Any]]] = None) -> None: + messages: Optional[List[Dict[str, Any]]] = None, + turn_author: Optional[Dict[str, Any]] = None) -> None: """Sync a completed turn to all providers on the background worker. Never inline: a provider's ``sync_turn`` may block for minutes, which kept ``run_conversation`` open after the user saw the response. The single worker also serializes writes (turn N before N+1). + ``turn_author`` reaches only providers whose ``sync_turn`` accepts it. """ providers = list(self._providers) clean_user_content = self._strip_skill_scaffolding(user_content) if providers else None if not clean_user_content: return + optional_kwargs = {"messages": messages, "turn_author": turn_author} def _sync(provider: MemoryProvider) -> None: kwargs: Dict[str, Any] = {"session_id": session_id} - if messages is not None and self._provider_sync_accepts_messages(provider): - kwargs["messages"] = messages + for keyword, value in optional_kwargs.items(): + if value is not None and self._provider_sync_accepts(provider, keyword): + kwargs[keyword] = value provider.sync_turn(clean_user_content, assistant_content, **kwargs) self._submit_background( @@ -595,7 +599,13 @@ class MemoryManager: return tool_error(f"Memory tool '{tool_name}' failed: {e}") def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None: - self._each_provider("on_turn_start failed", lambda p: p.on_turn_start(turn_number, message, **kwargs)) + def _tick(p: MemoryProvider) -> None: + # A provider written before the author kwargs declares (turn_number, message) only; it still gets its tick. + params = _signature_params(p.on_turn_start) + accepted = kwargs if params is None or _has_var_kwargs(params) else {k: v for k, v in kwargs.items() if k in params} + p.on_turn_start(turn_number, message, **accepted) + + self._each_provider("on_turn_start failed", _tick) def on_session_end(self, messages: List[Dict[str, Any]]) -> None: self._each_provider("on_session_end failed", lambda p: p.on_session_end(messages), level=logging.WARNING, diff --git a/agent/memory_provider.py b/agent/memory_provider.py index fa2114c3d1..9d698ada7a 100644 --- a/agent/memory_provider.py +++ b/agent/memory_provider.py @@ -107,8 +107,10 @@ class MemoryProvider(ABC): def sync_turn( self, user_content: str, assistant_content: str, *, session_id: str = "", messages: Optional[List[Dict[str, Any]]] = None, + turn_author: Optional[Dict[str, Any]] = None, ) -> None: - """Persist a completed turn (non-blocking). ``messages`` is the OpenAI-style list so far.""" + """Persist a completed turn (non-blocking). ``messages`` is the OpenAI-style list so far. + ``turn_author`` (``{"id", "name", "is_bot"}``) is who wrote the user side; the manager sends it only to signatures that accept it.""" @abstractmethod def get_tool_schemas(self) -> List[Dict[str, Any]]: @@ -124,7 +126,15 @@ class MemoryProvider(ABC): # -- Optional hooks (override to opt in) --------------------------------- def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None: - """Per-turn tick. kwargs may include remaining_tokens, model, platform, tool_count.""" + """Per-turn tick. kwargs may include remaining_tokens, model, platform, tool_count, author_id, author_name, + author_is_bot. The author trio names who wrote THIS turn (None, None, False without one): a shared session + carries several participants, so a provider keying durable state on identity must read it per turn.""" + + def identity_signature(self) -> Dict[str, Any]: + """Identity-mapping values that must bust a cached gateway agent when they change (writer identity, alias + tables, session-name prefixing). Provider-namespaced keys, JSON-serializable values. The gateway calls this + on an uninitialized instance on every inbound message, so keep it cheap and read-only.""" + return {} def on_session_end(self, messages: List[Dict[str, Any]]) -> None: """End-of-session extraction; fires only at real session boundaries, never per-turn.""" diff --git a/agent/moa_loop.py b/agent/moa_loop.py index ce2e2bd5c0..d9dc7afb2d 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -142,7 +142,7 @@ def _resolve_preset_cached(preset_name: str) -> tuple[dict[str, Any], Any]: _runtime_cache_lock = threading.Lock() -_runtime_cache: dict[tuple[str, str], tuple[float, dict[str, Any]]] = {} +_runtime_cache: dict[tuple[str, str, str], tuple[float, dict[str, Any]]] = {} # Short TTL so rotated keys / base_url edits are picked up within 5 minutes. _RUNTIME_CACHE_TTL_SECONDS = 300.0 @@ -245,12 +245,15 @@ def _aggregator_reasoning_config(aggregator: dict[str, Any]) -> dict[str, Any] | def _slot_runtime(slot: dict[str, Any]) -> dict[str, Any]: """Slot → ``call_llm`` kwargs with the provider's real api_mode/base_url/api_key. - Cached per (provider, model) with a short TTL. Falls back to bare provider/model + Cached per (profile home, provider, model) with a short TTL. Falls back to bare provider/model on error — never cached, or a transient error would pin bare kwargs for a TTL. """ provider = str(slot.get("provider") or "").strip() model = str(slot.get("model") or "").strip() - cache_key = (provider, model) + # hermes_home_key() in the key: the resolved api_key/base_url are per-profile, and under a + # multiplex gateway two profiles can share (provider, model) with different accounts. + from hermes_constants import hermes_home_key + cache_key = (hermes_home_key(), provider, model) now = time.monotonic() with _runtime_cache_lock: entry = _runtime_cache.get(cache_key) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index aa6b60eb3c..1e20bb5c13 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -295,10 +295,12 @@ DEFAULT_CONTEXT_LENGTHS = { # Google / Gemma ("gemma4" is Ollama-style naming, e.g. gemma4:31b-cloud) "gemini": 1048576, "gemma-4": 256000, "gemma4": 256000, "gemma-4-31b": 256000, "gemma-3": 131072, "gemma": 8192, - # DeepSeek — V4 family is 1M; deepseek-chat/-reasoner alias v4-flash modes. + # DeepSeek — V4 family is 1M; deepseek-chat/-reasoner alias v4-flash modes. ``deepseek-flash`` + # (version-less canonical id, 2026-09 Flash refresh) needs a discrete entry or the + # longest-key-first scan falls through to the 128K ``deepseek`` catch-all below. # https://api-docs.deepseek.com/zh-cn/quick_start/pricing - "deepseek-v4-pro": 1_000_000, "deepseek-v4-flash": 1_000_000, "deepseek-chat": 1_000_000, - "deepseek-reasoner": 1_000_000, "deepseek": 128000, + "deepseek-v4-pro": 1_000_000, "deepseek-v4.1-flash": 1_000_000, "deepseek-v4-flash": 1_000_000, "deepseek-chat": 1_000_000, + "deepseek-reasoner": 1_000_000, "deepseek-flash": 1_000_000, "deepseek": 128000, # Meta; Muse Spark family (1.1/1.2/1.3, -contributor(-free), meta/ prefixed) is 1M per OpenRouter, # models.dev and api.commandcode.ai /models — keep the "muse-spark" prefix (bare "muse" would match # muse-image/muse-voice). Thinking Machines inkling (covers inkling-small and :free/:batch variants) @@ -309,9 +311,14 @@ DEFAULT_CONTEXT_LENGTHS = { "qwen3-coder-plus": 1000000, "qwen3-coder": 262144, "qwen3-max": 262144, "qwen": 131072, # MiniMax — M3 is 1M; M2.x is 204,800. https://platform.minimax.io/docs/api-reference/text-chat-openai "minimax-m3": 1000000, "minimax": 204800, - # GLM — 5.2/5.3 are 1M (5.2 verified empirically at 789K on api.z.ai); older GLM ~202K. + # GLM — Nous + OpenRouter /v1/models (2026-09-09): 5.3 / 5.3-flash 1,310,720 (:batch/:US 1,048,576); + # 5.2 1,048,576; 5 / 5.1 / 4.7 / 4.6 204,800; *-turbo / 4.7-flash 202,752 (the catch-all). # The OpenRouter :free variant is capped; the longer key wins. - "glm-5.2": 1_048_576, "glm-5.2:free": 256_000, "glm-5.3": 1_048_576, "glm": 202752, + "glm-5.3": 1_310_720, "glm-5.3-flash": 1_310_720, "glm-5.3:batch": 1_048_576, "glm-5.3:us": 1_048_576, + "glm-5.3-flash:batch": 1_048_576, "glm-5.3-flash:us": 1_048_576, + "glm-5.2": 1_048_576, "glm-5.2:free": 256_000, + "glm-5.1": 204_800, "glm-5-turbo": 202752, "glm-5v-turbo": 202752, "glm-5": 204_800, + "glm-4.7-flash": 202752, "glm-4.7": 204_800, "glm-4.6v": 131072, "glm-4.6": 204_800, "glm": 202752, # xAI — /v1/models returns no context_length, so these prevent probe-down on api.x.ai # custom providers (docs.x.ai). grok-composer(-2.5-fast, Grok Build CLI) is OAuth-only: # 200k usable (the /v1/responses ~262144 input+output budget is a separate limit). @@ -444,11 +451,17 @@ def _server_root(base_url: str) -> str: return server_url[:-3] if server_url.endswith("/v1") else server_url +def _catalog_key_matches(key: str, model_lower: str) -> bool: + """Substring match with version separators normalised on both sides, so a relay slug like + ``z-ai-glm-5-3`` still hits the ``glm-5.3`` entry instead of the ``glm`` catch-all (#97398).""" + return key in model_lower or _normalize_model_version(key) in _normalize_model_version(model_lower) + + def _longest_key_match(table: Dict[str, int], model_lower: str) -> Optional[Tuple[str, int]]: """First ``(key, value)`` whose key is a substring of ``model_lower``, longest key first so specific entries (``gpt-5.4-mini``) beat their family catch-all (``gpt-5``); ties keep table order.""" for key, value in sorted(table.items(), key=lambda x: len(x[0]), reverse=True): - if key in model_lower: + if _catalog_key_matches(key, model_lower): return key, value return None @@ -1249,7 +1262,7 @@ def _stale_pre_catalog_cache_entry(model: str, cached: int) -> bool: """True when a persisted window is a pre-catalog leftover: the model resolves (longest-key-first) to a _PRE_CATALOG_STALE_KEYS key and the cached value is <= the largest shorter matching catch-all (or 256K).""" model_lower = model.lower() - matches = [(key, value) for key, value in DEFAULT_CONTEXT_LENGTHS.items() if key in model_lower] + matches = [(key, value) for key, value in DEFAULT_CONTEXT_LENGTHS.items() if _catalog_key_matches(key, model_lower)] if not matches: return False specific_key, specific_value = max(matches, key=lambda kv: len(kv[0])) diff --git a/agent/rate_limit_credits.py b/agent/rate_limit_credits.py index 9dc0f2d45e..44c6f7a200 100644 --- a/agent/rate_limit_credits.py +++ b/agent/rate_limit_credits.py @@ -49,6 +49,19 @@ class RateLimitCreditsMixin: """Return the last captured RateLimitState, or None.""" return self._rate_limit_state + def _capture_nous_model_switch(self, http_response: Any) -> None: + """Record the Nous gateway's ``x-nous-model-switch`` header (a named account asked for the + free tier's model; the gateway served its backing model and named it). Applied between + calls by ``hermes_cli.anon_auth.apply_model_switch``. Fail-open.""" + headers = _response_headers(http_response) + if not headers: + return + try: + from hermes_cli.anon_auth import note_model_switch + note_model_switch(self, headers) + except Exception: + pass # Never let header parsing break the agent loop + def _capture_anthropic_response_headers(self, http_response: Any) -> None: """Capture rate-limit + credits state from Anthropic Messages response headers (the SDK's aggregated ``Message`` drops them). Fail-open.""" diff --git a/agent/reasoning_timeouts.py b/agent/reasoning_timeouts.py index 76ae3511e1..ae0a405ca4 100644 --- a/agent/reasoning_timeouts.py +++ b/agent/reasoning_timeouts.py @@ -20,7 +20,9 @@ _REASONING_STALE_TIMEOUT_FLOORS: dict[int, tuple[str, ...]] = { # NVIDIA Nemotron behind hosted NIM: documented 60-180s upstream idle kill. "nemotron-3-ultra", "nemotron-3-super", # DeepSeek R1 / V4 (reasoning_content streamed before final content). - "deepseek-r1", "deepseek-reasoner", "deepseek-v4-flash", "deepseek-v4-pro", + # ``deepseek-flash`` is the version-less canonical Flash id (2026-09 Flash refresh); + # ``deepseek-v4-flash`` still aliases onto it server-side. + "deepseek-r1", "deepseek-reasoner", "deepseek-flash", "deepseek-v4-flash", "deepseek-v4.1-flash", "deepseek-v4-pro", # OpenAI o-series: each variant enumerated so bare ``o1`` cannot over-match ``olmo-1``. "o1", "o1-mini", "o1-pro", "o1-preview", "o3", "o3-pro", # Mythos-class named models (claude-fable-5): 1M ctx + 128K output, a heavier thinking diff --git a/agent/redact.py b/agent/redact.py index 34a8df803f..136c1fb487 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -17,6 +17,64 @@ from agent.file_safety import _BLOCKED_PROJECT_ENV_BASENAMES as _ENV_FILE_BASENA logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Vault-value redaction registry (profile-scoped, bounded) +# --------------------------------------------------------------------------- +# Exact secret values that transited a server-side vault fill (browser_vault_fill). Generic +# credential-shaped regexes cannot catch an arbitrary user password, so the fill path registers +# the exact bytes and every browser_* tool result (including browser_cdp Runtime.evaluate +# passthrough) is scrubbed against them before it can reach the model. Memory only: never +# persisted or logged. Keyed by profile home so a multiplex gateway never scrubs profile B's +# output with profile A's passwords (which would also confirm to B that the bytes exist), and +# bounded per profile: a fill-heavy session evicts its oldest entries rather than growing forever. +_VAULT_REDACTION_MAX_PER_PROFILE = 64 +_VAULT_REDACTION_VALUES: dict = {} # profile home → ordered {value: None} +_VAULT_REDACTION_LOCK = threading.Lock() + + +def _vault_scope() -> str: + from hermes_constants import get_hermes_home + return str(get_hermes_home()) + + +def register_vault_redaction_value(value) -> None: + """Register an exact vault secret value for model-facing redaction. + + Called by the vault fill path BEFORE the injection happens, so no later browser tool result + can echo the value back into model context. Also registers the form a text input normalizes + it to (CR/LF stripped), since that is what the page holds. + """ + if not isinstance(value, str) or not value: + return + normalized = value.replace("\r", "").replace("\n", "") + with _VAULT_REDACTION_LOCK: + bucket = _VAULT_REDACTION_VALUES.setdefault(_vault_scope(), {}) + for v in (value, normalized): + if v: + bucket.pop(v, None) # re-registering refreshes recency + bucket[v] = None + while len(bucket) > _VAULT_REDACTION_MAX_PER_PROFILE: + del bucket[next(iter(bucket))] + + +def clear_vault_redaction_values() -> None: + """Drop the current profile's registered values (profile teardown / explicit lock).""" + with _VAULT_REDACTION_LOCK: + _VAULT_REDACTION_VALUES.pop(_vault_scope(), None) + + +def redact_registered_vault_values(text: str) -> str: + """Exact-substring scrub of every vault secret value registered for the current profile.""" + if not isinstance(text, str) or not text: + return text + with _VAULT_REDACTION_LOCK: + bucket = _VAULT_REDACTION_VALUES.get(_vault_scope()) + values = sorted(bucket, key=len, reverse=True) if bucket else () # longest first: a substring never shadows its superstring + for value in values: + if value in text: + text = text.replace(value, "«redacted-vault-secret»") + return text + # Sensitive query-string param names (case-insensitive): opaque tokens / OAuth # codes / pre-signed signatures with no vendor prefix. # Ported from nearai/ironclaw#2529 — catches tokens whose values don't match any known vendor prefix regex @@ -583,7 +641,11 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F if text is None: return None text = text if isinstance(text, str) else str(text) - if not text or not (force or _REDACT_ENABLED): + if not text: + return text + # Vault secrets are a hard model-egress boundary: scrubbed regardless of the redact_secrets preference. + text = redact_registered_vault_values(text) + if not (force or _REDACT_ENABLED): return text code_file = code_file or file_read diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 9d3b653396..42fabfefa6 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -33,6 +33,12 @@ ORG_ACTIVE_MARKER = ".active_org" ORG_PROVENANCE_FILE = ".org-provenance.json" ORG_BASELINE_FILE = ".org-baseline.json" # upstream fingerprint; detects local edits +# Collective Wisdom managed installs are intentionally separate from the M2 +# whole-org mirror. The only writer of this marker is the Wisdom setup/client +# path after the Gateway has accepted the profile's installation identity. +WISDOM_MANAGED_DIR_NAME = "_wisdom" +WISDOM_ACTIVE_MARKER = ".active_org" + def read_active_org_id(skills_dir: Path) -> Optional[str]: """The org id whose mirror may resolve, or None (no org skills load).""" @@ -43,6 +49,27 @@ def read_active_org_id(skills_dir: Path) -> Optional[str]: return None +def read_active_wisdom_org_id(skills_dir: Path) -> Optional[str]: + """The last Gateway-verified org whose managed Wisdom skills may load.""" + try: + marker = skills_dir / WISDOM_MANAGED_DIR_NAME / WISDOM_ACTIVE_MARKER + if not marker.exists(): + return None + value = marker.read_text(encoding="utf-8").strip() + return value or None + except OSError: + return None + + +def is_wisdom_managed_path(path, skills_dir: Path) -> bool: + """True when *path* is below ``_wisdom//``.""" + try: + rel = Path(path).resolve().relative_to(Path(skills_dir).resolve()) + except (OSError, ValueError): + return False + return bool(rel.parts) and rel.parts[0] == WISDOM_MANAGED_DIR_NAME + + def _org_rel_parts(path, skills_dir: Path) -> Tuple[str, ...]: """Path parts of *path* relative to *skills_dir* if it is under ``_org/``, else ``()``.""" try: @@ -733,22 +760,113 @@ def is_skill_description_truncated_for_prompt(frontmatter: Dict[str, Any]) -> bo return len(_normalize_skill_description(frontmatter)) > SKILL_PROMPT_DESC_LIMIT +def extract_skill_editorial_metadata( + frontmatter: Dict[str, Any], + *, + fallback_name: str, + fallback_description: str, +) -> Dict[str, str]: + """Resolve optional human-facing skill copy without changing agent metadata. + + ``name`` and ``description`` remain the canonical agent-facing routing + fields. Hermes UIs may use the optional values under ``metadata.hermes``; + older and third-party skills fall back to the canonical pair. + """ + metadata = frontmatter.get("metadata") + hermes = metadata.get("hermes") if isinstance(metadata, dict) else None + if not isinstance(hermes, dict): + hermes = {} + + editorial_name = hermes.get("editorial_name") + editorial_description = hermes.get("editorial_description") + return { + "editorial_name": ( + editorial_name.strip() + if isinstance(editorial_name, str) and editorial_name.strip() + else fallback_name + ), + "editorial_description": ( + editorial_description.strip() + if isinstance(editorial_description, str) + and editorial_description.strip() + else fallback_description + ), + } + + +def load_skill_editorial_metadata( + skill_path: Path, + *, + fallback_name: str | None = None, + fallback_description: str = "", +) -> Dict[str, str]: + """Load human-facing copy from a skill directory with safe fallbacks.""" + canonical_name = fallback_name or skill_path.name + canonical_description = fallback_description + try: + frontmatter, _body = parse_frontmatter( + (skill_path / "SKILL.md").read_text(encoding="utf-8") + ) + name = frontmatter.get("name") + description = frontmatter.get("description") + if isinstance(name, str) and name.strip(): + canonical_name = name.strip() + if isinstance(description, str) and description.strip(): + canonical_description = description.strip() + return extract_skill_editorial_metadata( + frontmatter, + fallback_name=canonical_name, + fallback_description=canonical_description, + ) + except (OSError, UnicodeError, ValueError): + return { + "editorial_name": canonical_name, + "editorial_description": canonical_description, + } + + +# ── File iteration ──────────────────────────────────────────────────────── + + def iter_skill_index_files(skills_dir: Path, filename: str): - """Walk skills_dir yielding sorted paths matching *filename*; prunes - EXCLUDED_SKILL_DIRS and support dirs of skill roots. Org mirrors are - TOKEN-GATED: only the active org's subdir is walked, so leaving an org - stops its skills resolving without manual cleanup.""" + """Walk skills_dir yielding sorted paths matching *filename*. + + Excludes Hermes metadata, VCS, virtualenv/dependency, cache, and skill + support directories. Support directories (references/templates/assets/ + scripts) can contain arbitrary markdown and even archived package + ``SKILL.md`` files, but they are progressive-disclosure data loaded through + ``skill_view(..., file_path=...)`` rather than active skill roots. + + M2 org mirrors (``_org/``) and Collective Wisdom installs + (``_wisdom/``): TOKEN-GATED resolution. Only the active org's + subdir (per the sync-client-written ``.active_org`` marker) is walked; + every other ``_org//`` (stale mirror from a previous org, or no + marker at all) is pruned — leave an org and its skills stop resolving, + without any manual cleanup. + """ skills_dir_str = str(skills_dir) active_org = read_active_org_id(skills_dir) + active_wisdom_org = read_active_wisdom_org_id(skills_dir) org_root = os.path.join(skills_dir_str, ORG_MIRROR_DIR_NAME) + wisdom_root = os.path.join(skills_dir_str, WISDOM_MANAGED_DIR_NAME) matches: list[str] = [] for root, dirs, files in os.walk(skills_dir_str, followlinks=True): has_skill_md = "SKILL.md" in files if root == skills_dir_str and ORG_MIRROR_DIR_NAME in dirs and active_org is None: dirs.remove(ORG_MIRROR_DIR_NAME) + if root == skills_dir_str and WISDOM_MANAGED_DIR_NAME in dirs and active_wisdom_org is None: + dirs.remove(WISDOM_MANAGED_DIR_NAME) elif root == org_root: dirs[:] = [d for d in dirs if d == active_org] - dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS and not (has_skill_md and d in SKILL_SUPPORT_DIRS)] + elif root == wisdom_root: + # Inside _wisdom/: descend ONLY into the last Gateway-verified org. + dirs[:] = [d for d in dirs if d == active_wisdom_org] + dirs[:] = [ + d + for d in dirs + if d not in EXCLUDED_SKILL_DIRS + and not (has_skill_md and d in SKILL_SUPPORT_DIRS) + ] if filename in files: matches.append(os.path.join(root, filename)) yield from map(Path, sorted(matches)) diff --git a/agent/tool_dispatch_helpers.py b/agent/tool_dispatch_helpers.py index f994ecfab2..2ec46728d2 100644 --- a/agent/tool_dispatch_helpers.py +++ b/agent/tool_dispatch_helpers.py @@ -25,10 +25,11 @@ from tools.threat_patterns import scan_for_threats logger = logging.getLogger(__name__) # Interactive / user-facing tools never run concurrently: any of these in a batch is a barrier. -_NEVER_PARALLEL_TOOLS = frozenset({"clarify"}) +_NEVER_PARALLEL_TOOLS = frozenset({"clarify", "manage_connections"}) # Read-only tools with no shared mutable session state. _PARALLEL_SAFE_TOOLS = frozenset({ + "connectors__execute", # pure remote batches have per-dispatch idempotency keys "ha_get_state", "ha_list_entities", "ha_list_services", @@ -86,18 +87,48 @@ _PARALLEL_SAFE_BRIDGE_LOOKUPS = frozenset({"tool_search", "tool_describe"}) def _peel_bridge_call(tool_name: str, function_args: dict) -> tuple[str, dict]: - """Resolve a ``tool_call`` bridge invocation to ``(underlying_name, underlying_args)`` so - admission is decided on the real tool (as the executors' unwrap does). An unparseable - bridge call is returned unchanged: it stays a sequential barrier and fails at dispatch.""" + """Resolve a ``tool_call`` bridge invocation to its underlying tool. + + The batch planner admits calls to a parallel run by tool NAME, but when + tool search is active the model emits the literal name ``tool_call`` for + every deferred tool — so a server opted in via + ``supports_parallel_tool_calls: true`` silently lost concurrency the + moment the bridge activated. Peel the wrapper here so admission is + decided on the underlying tool, exactly like the executors' unwrap. + + Returns ``(underlying_name, underlying_args)`` when the wrapper parses + cleanly, else ``(tool_name, function_args)`` unchanged — an unparseable + bridge call stays a sequential barrier and fails at dispatch as before. + """ try: - from tools.tool_search import TOOL_CALL_NAME, resolve_underlying_call - if tool_name == TOOL_CALL_NAME: - underlying, underlying_args, err = resolve_underlying_call(function_args) - if err is None and underlying: + from tools.tool_search import ( + CONNECTOR_BATCH_SENTINEL, + TOOL_CALL_NAME, + is_connector_name, + resolve_underlying_call, + ) + if tool_name != TOOL_CALL_NAME: + return tool_name, function_args + underlying, underlying_args, err = resolve_underlying_call(function_args) + if err is not None or not underlying: + return tool_name, function_args + if underlying == CONNECTOR_BATCH_SENTINEL: + # Only a PURE connector batch is parallel-safe (network-bound, + # no local state, own idempotency key). A batch containing any + # local entry keeps the sequential barrier: its entries never + # went through per-tool admission here, so treating the batch + # as parallel-safe would bypass path-overlap serialization for + # local writers and the per-server MCP parallel opt-in. + entries = underlying_args.get("calls") or [] + if entries and all( + isinstance(e, dict) and is_connector_name(e.get("name")) + for e in entries + ): return underlying, underlying_args + return tool_name, function_args + return underlying, underlying_args except Exception: - pass - return tool_name, function_args + return tool_name, function_args def _batch_admission(tool_call, execution_cwd: Optional[Path]) -> tuple[str, List[Path], bool] | None: diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 6b15d2f637..518b714f1b 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -389,6 +389,10 @@ def _unwrap_tool_search_call( underlying, underlying_args, err = _ts.resolve_underlying_call(function_args) if err or not underlying: return function_name, function_args, None + if underlying == _ts.CONNECTOR_BATCH_SENTINEL: + # Both executors retain the wrapper: scope/probe/hooks run per entry + # in the batch dispatcher, not against a synthetic registry name. + return function_name, function_args, None if underlying not in _tool_search_scoped_names(agent): return function_name, function_args, ( f"'{underlying}' is not available in this session. Use tool_search to find tools you can call." diff --git a/agent/transports/anthropic.py b/agent/transports/anthropic.py index 978a0d4af2..ee39e1dfa3 100644 --- a/agent/transports/anthropic.py +++ b/agent/transports/anthropic.py @@ -97,11 +97,20 @@ class AnthropicTransport(ProviderTransport): provider_data["anthropic_content_blocks"] = ordered_blocks return NormalizedResponse( content="\n".join(text_parts) if text_parts else None, tool_calls=tool_calls or None, - finish_reason=self.map_finish_reason(response.stop_reason), + finish_reason=self.response_finish_reason(response), reasoning="\n\n".join(reasoning_parts) if reasoning_parts else None, usage=None, provider_data=provider_data or None, ) + def response_finish_reason(self, response: Any) -> str: + """``stop_reason`` mapped to the OpenAI vocabulary. Bedrock InvokeModel guardrail blocks keep + ``stop_reason=end_turn`` and hand back the guardrail's canned text as an ordinary reply; they + must surface as ``content_filter`` so the loop treats them as a refusal, not model output.""" + from agent.bedrock_adapter import anthropic_response_guardrail_intervened + if anthropic_response_guardrail_intervened(response): + return "content_filter" + return self.map_finish_reason(response.stop_reason) + def validate_response(self, response: Any) -> bool: """Structural check; empty content is legitimate for ``end_turn``/``refusal`` (retrying either would loop forever).""" diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 1c4a87dbfa..a7c1bd0ab4 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -11,7 +11,7 @@ import re from typing import Any, Callable, Optional from agent.reasoning_effort import ( - ACTUAL_RELAY_EFFORTS, CODEX_ASTRA_EFFORTS, CODEX_LEGACY_EFFORTS, + CODEX_ASTRA_EFFORTS, CODEX_LEGACY_EFFORTS, XAI_GROK46_EFFORTS, XAI_LEGACY_EFFORTS, clamp_effort, is_astra_model, # Same declared vocabulary + shared clamp as the main Codex transport (agent.reasoning_effort): # per-model — "max" is gpt-5.6-only, "minimal"/"ultra" always rejected (live-verified, #68365). @@ -221,8 +221,6 @@ def _resolve_reasoning(model: str, params: dict[str, Any]) -> tuple[Any, bool]: # Grok 4.6 accepts xhigh; older Grok tops out at high. supported = XAI_GROK46_EFFORTS if is_grok_46_family(model) else XAI_LEGACY_EFFORTS - elif (params.get("provider") or "").strip().lower() == "actual": - supported = ACTUAL_RELAY_EFFORTS else: declared = _profile_declared_efforts(params.get("provider"), model, params.get("base_url")) if declared is not None and not declared: @@ -405,6 +403,39 @@ def _is_post_tool_replay(messages: Optional[list[dict[str, Any]]]) -> bool: return False +def _is_azure_responses(params: dict[str, Any]) -> bool: + """True for any Azure-hosted Responses endpoint: the ``azure-foundry`` provider, a resource-level + ``*.openai.azure.com`` host, or the project-scoped ``*.services.ai.azure.com`` gateway.""" + from utils import base_url_host_matches + + if str(params.get("provider") or "").strip().lower() == "azure-foundry": + return True + base_url = str(params.get("base_url") or "") + return base_url_host_matches(base_url, "openai.azure.com") or base_url_host_matches(base_url, "services.ai.azure.com") + + +def _newest_reasoning_only(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Copy of ``messages`` keeping ``codex_reasoning_items`` only on the newest assistant row that has any. + Foundry rejects a request that replays encrypted reasoning from more than one prior response (HTTP 400 + "Conflicting authenticated continuation identities", #105369). ``compaction`` checkpoints stay everywhere.""" + out: list[dict[str, Any]] = [] + newest_kept = False + for msg in reversed(messages): + items = msg.get("codex_reasoning_items") if isinstance(msg, dict) and msg.get("role") == "assistant" else None + if isinstance(items, list) and any(isinstance(i, dict) and i.get("type") != "compaction" for i in items): + if newest_kept: + checkpoints = [i for i in items if isinstance(i, dict) and i.get("type") == "compaction"] + msg = dict(msg) + if checkpoints: + msg["codex_reasoning_items"] = checkpoints + else: + msg.pop("codex_reasoning_items") + newest_kept = True + out.append(msg) + out.reverse() + return out + + def _native_compaction_active(context_management: Any) -> bool: """True only when the caller's eligibility gate produced a non-empty payload. @@ -536,6 +567,10 @@ class ResponsesApiTransport(ProviderTransport): replay_encrypted_reasoning = bool(params.get("replay_encrypted_reasoning", True)) and not ( _is_azure_foundry_responses(params) and _is_post_tool_replay(payload_messages) ) + # Own predicate: #101243 may narrow _is_azure_foundry_responses to the project gateway, and the + # multi-item rejection happens on resource-level hosts too. + if replay_encrypted_reasoning and _is_azure_responses(params): + payload_messages = _newest_reasoning_only(payload_messages) # One predicate decides whether context_management goes out AND whether the converter may replay a checkpoint. context_management = params.get("context_management") native_compaction_active = _native_compaction_active(context_management) diff --git a/agent/turn_api_call.py b/agent/turn_api_call.py index 0aa3519289..379a5e4509 100644 --- a/agent/turn_api_call.py +++ b/agent/turn_api_call.py @@ -219,15 +219,25 @@ def nous_rate_limit_guard( ) if agent.provider == "nous": + # A gateway ``x-nous-model-switch`` recorded on the previous response moves this session + # (and the config default, when it still names the free tier's model) before the next call. + try: + from hermes_cli.anon_auth import apply_model_switch + apply_model_switch(agent) + except Exception: + pass try: from agent.nous_rate_guard import ( nous_rate_limit_remaining, format_remaining as _fmt_nous_remaining ) _nous_remaining = nous_rate_limit_remaining() if _nous_remaining is not None and _nous_remaining > 0: - _nous_msg = ( - f"Nous Portal rate limit active — resets in {_fmt_nous_remaining(_nous_remaining)}." - ) + from hermes_cli import anon_auth + reset = _fmt_nous_remaining(_nous_remaining) + if anon_auth.route_is_welcome_host(getattr(agent, "base_url", "")): + _nous_msg = anon_auth.FREE_TIER_RATE_LIMIT_CHAT.format(reset=reset) + else: + _nous_msg = f"Nous Portal rate limit active — resets in {reset}." agent._buffer_vprint(f"⏳ {_nous_msg} Trying fallback...") agent._buffer_status(f"⏳ {_nous_msg}") if agent._try_activate_fallback(): diff --git a/agent/turn_author.py b/agent/turn_author.py new file mode 100644 index 0000000000..c837f9b266 --- /dev/null +++ b/agent/turn_author.py @@ -0,0 +1,103 @@ +"""Who wrote the user side of a turn, carried from the dispatcher into the recipient's turn. + +The dispatcher sets ``HERMES_TURN_AUTHOR`` on the recipient's one-shot subprocess only. A cached +gateway agent sees several authors over its lifetime, so the author is read per turn, never per agent. +""" + +from __future__ import annotations + +import json +import os +import socket +import unicodedata +from typing import Any, Dict, Mapping, MutableMapping, Optional + +TURN_AUTHOR_ENV = "HERMES_TURN_AUTHOR" + +_MAX_FIELD_LEN = 200 + + +_DROPPED_CATEGORIES = frozenset({"Cc", "Cs", "Cn", "Co"}) +_TRUTHY = frozenset({"true", "1", "yes"}) + + +def _clean_text(value: Any) -> Optional[str]: + """Strip whitespace, control and unassigned characters, then cap the length. None when nothing is left. + Format characters and non-breaking spaces stay so emoji sequences and display names survive.""" + if not isinstance(value, str): + return None + text = "".join(ch for ch in value if unicodedata.category(ch) not in _DROPPED_CATEGORIES).strip() + if not text: + return None + return text[:_MAX_FIELD_LEN] + + +def _bot_flag(value: Any) -> bool: + if isinstance(value, str): + return value.strip().lower() in _TRUTHY + return isinstance(value, (bool, int)) and bool(value) + + +def bot_author_id(profile: str, origin: Optional[str] = None) -> str: + """``bot:`` for a bot on the recipient's own install, ``bot:/`` for one on another + install. The origin is the Desktop's connection id on a relayed dm and the sender's hostname on a peer dm.""" + origin = (origin or "").strip() + return f"bot:{origin}/{profile}" if origin else f"bot:{profile}" + + +def local_origin() -> str: + """This machine's hostname as an author-id origin, cleaned like any author field. Empty when unknown.""" + try: + host = socket.gethostname() + except Exception: + return "" + # A slash would split the id into a different origin and profile at parse time. + return (_clean_text(host) or "").replace("/", "") + + +def parse_turn_author(raw: Any) -> Optional[Dict[str, Any]]: + """Normalize a dict or JSON string into ``{"id", "name", "is_bot"}``; None for anything else or without id and name. + The id is whatever the transport knows the sender by: ``bot:`` on a bot-mode delivery inside one + install, ``bot:/`` when the Desktop relayed it from another machine, + ``bot:/`` on a peer dm, the platform user id elsewhere. + An ``origin`` field qualifies a bare ``bot:`` id the same way.""" + try: + if isinstance(raw, (str, bytes)): + raw = json.loads(raw) + if not isinstance(raw, Mapping): + return None + author = { + "id": _clean_text(raw.get("id")), + "name": _clean_text(raw.get("name")), + "is_bot": _bot_flag(raw.get("is_bot")), + } + if author["id"] is None and author["name"] is None: + return None + origin = _clean_text(raw.get("origin")) + if origin and author["id"] and author["id"].startswith("bot:") and "/" not in author["id"]: + author["id"] = _clean_text(bot_author_id(author["id"][len("bot:"):], origin)) + return author + except Exception: + return None + + +def turn_author_from_env(environ: Mapping[str, str] = os.environ) -> Optional[Dict[str, Any]]: + """The author the dispatcher placed in ``HERMES_TURN_AUTHOR``, or None.""" + return parse_turn_author(environ.get(TURN_AUTHOR_ENV)) + + +def take_turn_author_from_env(environ: MutableMapping[str, str] = os.environ) -> Optional[Dict[str, Any]]: + """Read and remove ``HERMES_TURN_AUTHOR`` so subprocesses started during the turn do not inherit it.""" + return parse_turn_author(environ.pop(TURN_AUTHOR_ENV, None)) + + +def turn_author_env(author: Dict[str, Any]) -> Dict[str, str]: + """The environment entry a dispatcher merges into a child's env.""" + return {TURN_AUTHOR_ENV: json.dumps(author, separators=(",", ":"))} + + +def a2a_key(author: Optional[Dict[str, Any]]) -> Optional[str]: + """``a2a:``, the shared name for a bot author's turns. None for a human or an id-less bot.""" + if not isinstance(author, dict) or not author.get("is_bot") or not author.get("id"): + return None + return f"a2a:{author['id']}" diff --git a/agent/turn_context.py b/agent/turn_context.py index 584d75d211..8ff9048519 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -25,6 +25,7 @@ from agent.message_metadata import append_message, stamp_message_timestamp from agent.model_metadata import estimate_messages_tokens_rough, estimate_request_tokens_rough from agent.image_token_cost import bind_image_token_cost from agent.usage_anchor import anchored_context_tokens, restore_usage_anchor +from agent.turn_author import parse_turn_author logger = logging.getLogger(__name__) @@ -661,6 +662,8 @@ def _collect_pre_llm_call_context( """Run ``pre_llm_call`` plugins; their context is injected into the user message (never the system prompt). Oversized per-hook context is spilled to disk so a runaway plugin can't inflate every subsequent turn's prompt.""" + if getattr(agent, "_persist_disabled", False): + return "" try: from hermes_cli.lifecycle import invoke_hook as _invoke_hook _pre_results = _invoke_hook( @@ -751,15 +754,23 @@ def _bind_interrupt_scope(agent: Any, ra) -> None: agent._interrupt_thread_signal_pending = False -def _memory_turn_start_and_prefetch(agent: Any, original_user_message: Any) -> str: +def _memory_turn_start_and_prefetch( + agent: Any, original_user_message: Any, turn_author: Optional[Dict[str, Any]] = None, +) -> str: """Notify memory providers of the new turn, then prefetch external memory once before the tool loop (skipped on trivial prompts with no semantic signal). Returns the prefetch text (``""`` when nothing was injected).""" if not agent._memory_manager: return "" _query = original_user_message if isinstance(original_user_message, str) else "" + # The author rides along so a provider can attribute THIS turn, not whoever opened the session. + _author = turn_author if isinstance(turn_author, dict) else {} with suppress(Exception): - agent._memory_manager.on_turn_start(agent._user_turn_count, _query) + agent._memory_manager.on_turn_start( + agent._user_turn_count, _query, + author_id=_author.get("id") or None, author_name=_author.get("name") or None, + author_is_bot=bool(_author.get("is_bot")), + ) ext_prefetch_cache = "" with suppress(Exception): if not is_trivial_prompt(_query): @@ -843,7 +854,8 @@ def build_turn_context( conversation_history: Optional[List[Dict[str, Any]]], task_id: Optional[str], stream_callback, persist_user_message: Optional[Any], persist_user_timestamp: Optional[float]=None, persist_user_platform_id: Optional[str]=None, *, persist_user_display_kind: Optional[str]=None, - persist_user_display_metadata: Optional[Dict[str, Any]]=None, restore_or_build_system_prompt, + persist_user_display_metadata: Optional[Dict[str, Any]]=None, turn_author: Optional[Dict[str, Any]]=None, + restore_or_build_system_prompt, install_safe_stdio, sanitize_surrogates, summarize_user_message_for_log, set_session_context, set_current_write_origin, ra, moa_active: bool=False, ) -> TurnContext: @@ -858,6 +870,10 @@ def build_turn_context( # Guard stdio against OSError from broken pipes (systemd/headless/daemon). install_safe_stdio() + # Reset first: a cached gateway agent must never carry the previous turn's bot author into a human turn. + turn_author = parse_turn_author(turn_author) + agent._turn_author = turn_author + # Recover a rotated session before binding log/turn ids or copying client history so # everything in this turn belongs to the canonical child. recovered_history = recover_rotated_compression_session(agent) @@ -966,7 +982,7 @@ def build_turn_context( ) _bind_interrupt_scope(agent, ra) - ext_prefetch_cache = _memory_turn_start_and_prefetch(agent, original_user_message) + ext_prefetch_cache = _memory_turn_start_and_prefetch(agent, original_user_message, turn_author) # Sidecar skipped for codex_app_server/MoA. if ( diff --git a/agent/turn_explainers.py b/agent/turn_explainers.py index f564572443..0ee38115e3 100644 --- a/agent/turn_explainers.py +++ b/agent/turn_explainers.py @@ -119,6 +119,21 @@ _PERSISTENCE_CAUSE_EXPLANATIONS: Dict[str, str] = { "sessions/.jsonl and, on the gateway, " "pending_messages/pending-*.json." ), + "deleted_wal": ( + "the turn was stopped because a live Hermes process held a retired " + "state.db-wal generation after its pathname was deleted or " + "replaced. Stop the gateway, dashboard, and cron writers; " + "do not overwrite the current state.db or delete its sidecars. " + "Check the logs for whether Hermes captured the retired generation, " + "then read the adjacent state.db.retired-wal-*/manifest.json. If " + "manifest.main.mode is `copied`, inspect that artifact with `hermes " + "sessions recover --source " + "--inspect-only` before deciding whether its committed frames belong " + "on the current database. A `header_only` artifact is forensic and " + "does not contain a copied state.db to inspect. Unwritten messages " + "were diverted to sessions/.jsonl and, on the gateway, " + "pending_messages/pending-*.json." + ), "corrupt": ( "the turn was stopped because the state database " "reported structural corruption (the transcript would " @@ -281,7 +296,7 @@ class TurnExplainersMixin: @staticmethod def _format_turn_completion_explanation( - turn_exit_reason: str, persistence_cause: Optional[str] = None + turn_exit_reason: str, persistence_cause: Optional[str] = None, db_path=None ) -> str: """User-facing explanation for an abnormal turn ending, or "" for normal / unknown reasons. @@ -304,11 +319,14 @@ class TurnExplainersMixin: persistence_cause or "unknown", _PERSISTENCE_DEFAULT_EXPLANATION ) if persistence_cause == "corrupt": - # Copy-pasteable, so name the real store (profiles / HERMES_HOME do not live under ~/.hermes). + # Copy-pasteable, so name the store that actually failed: the agent's own + # SessionDB. A multi-profile backend (Desktop serve) hosts sessions whose + # state.db is NOT the process default, so the default would send the operator + # to inspect/repair the wrong profile's database (#105887). from hermes_constants import get_default_hermes_root from hermes_state import _default_db_path - body = body.replace("{db_path}", str(_default_db_path())) + body = body.replace("{db_path}", str(db_path or _default_db_path())) body = body.replace( "{backups_dir}", str(get_default_hermes_root() / "backups") ) diff --git a/agent/turn_facade.py b/agent/turn_facade.py index 128ecd1eca..acd3bc97ed 100644 --- a/agent/turn_facade.py +++ b/agent/turn_facade.py @@ -26,6 +26,7 @@ class TurnFacadeMixin: persist_user_timestamp: Optional[float]=None, persist_user_display_kind: Optional[str]=None, persist_user_display_metadata: Optional[Dict[str, Any]]=None, persist_user_platform_id: Optional[str]=None, moa_config: Optional[dict[str, Any]]=None, + turn_author: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Forwarder — see ``agent.conversation_loop.run_conversation``.""" # A review shares this session_id for cache parity: fence review startup or interrupt @@ -128,6 +129,7 @@ class TurnFacadeMixin: persist_user_display_kind=persist_user_display_kind, persist_user_display_metadata=persist_user_display_metadata, persist_user_platform_id=persist_user_platform_id, moa_config=moa_config, + turn_author=turn_author, ) finally: # Post-loop relay/task finalization must not receive a late refresh interrupt; diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 144a57fa78..0a9dec481f 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -375,7 +375,8 @@ def _explain_abnormal_exit(agent, final_response, _turn_exit_reason, preserved_v ) if _is_empty_terminal or _is_partial_fragment or str(_turn_exit_reason) == "partial_stream_recovery": _explanation = agent._format_turn_completion_explanation( - _turn_exit_reason, getattr(agent, "_last_persistence_error_cause", None) + _turn_exit_reason, getattr(agent, "_last_persistence_error_cause", None), + db_path=getattr(getattr(agent, "_session_db", None), "db_path", None), ) if _explanation: # Replace the bare sentinel; keep a partial fragment and append why. @@ -415,18 +416,19 @@ def _apply_output_hooks( if isinstance(_hook_result, str) and _hook_result: pre_transform, final_response, transformed = final_response, _hook_result, True break - # post_llm_call (e.g. sync conversation data to an external memory system). - _invoke_hook_safely( - "post_llm_call", logger, - session_id=agent.session_id, - task_id=effective_task_id, - turn_id=turn_id, - user_message=original_user_message, - assistant_response=final_response, - conversation_history=list(messages), - model=agent.model, - platform=platform, - ) + # Detached forks are internal work and must not publish turns under the parent's session ID. + if not getattr(agent, "_persist_disabled", False): + _invoke_hook_safely( + "post_llm_call", logger, + session_id=agent.session_id, + task_id=effective_task_id, + turn_id=turn_id, + user_message=original_user_message, + assistant_response=final_response, + conversation_history=list(messages), + model=agent.model, + platform=platform, + ) return final_response, transformed, pre_transform @@ -622,18 +624,19 @@ def finalize_turn( # Memory provider on_session_end()/shutdown_all() are NOT called here: # run_conversation() runs once per message; CLI/gateway own session-end cleanup. - _invoke_hook_safely( - "on_session_end", logger, - session_id=agent.session_id, - task_id=effective_task_id, - turn_id=turn_id, - completed=completed, - failed=failed, - interrupted=interrupted, - turn_exit_reason=_turn_exit_reason, - model=agent.model, - platform=_platform, - ) + if not getattr(agent, "_persist_disabled", False): + _invoke_hook_safely( + "on_session_end", logger, + session_id=agent.session_id, + task_id=effective_task_id, + turn_id=turn_id, + completed=completed, + failed=failed, + interrupted=interrupted, + turn_exit_reason=_turn_exit_reason, + model=agent.model, + platform=_platform, + ) agent._turn_preflight_display_snapshot = None agent._turn_received_provider_response = False diff --git a/agent/turn_recovery.py b/agent/turn_recovery.py index df56ca9a04..41b6c053dc 100644 --- a/agent/turn_recovery.py +++ b/agent/turn_recovery.py @@ -648,6 +648,19 @@ def _print_nonretryable_auth_guidance( _vlines(agent, " • Check credits: https://openrouter.ai/settings/credits") +def _welcome_tier_guidance(classified: Any, *, model: Any, in_chat: bool) -> str: + """Copy for a Nous free-tier refusal the classifier parsed (``welcome_refusal`` / + ``welcome_route`` in ``error_context``); empty for every other error.""" + ctx = getattr(classified, "error_context", None) or {} + refusal, route = ctx.get("welcome_refusal"), ctx.get("welcome_route") + if not refusal and not route: + return "" + from hermes_cli.anon_auth import welcome_refusal_copy, welcome_route_refusal_copy + if refusal: + return welcome_refusal_copy(refusal, model=str(model or ""), in_chat=in_chat) + return welcome_route_refusal_copy(str(route), in_chat=in_chat) + + # Terminal status label per non-retryable reason (default names the HTTP status). _NONRETRYABLE_LABELS = { FailoverReason.content_policy_blocked: "Provider safety filter blocked this request", @@ -683,7 +696,12 @@ def nonretryable_client_error_result( f" 🔌 Provider: {provider} Model: {model}", f" 🌐 Endpoint: {base_url}", ) - if classified.is_auth or classified.reason == FailoverReason.billing: + _welcome_hint = _welcome_tier_guidance(classified, model=model, in_chat=False) + if _welcome_hint: + # A free-tier gate or a wrong-host refusal: the way forward is a sign-in or another + # provider, never the key/credits advice below. + _vlines(agent, f" 💡 {_welcome_hint}") + elif classified.is_auth or classified.reason == FailoverReason.billing: _print_nonretryable_auth_guidance( agent, classified, status_code=status_code, provider=provider, base_url=base_url, model=model ) @@ -738,7 +756,18 @@ def nonretryable_client_error_result( classified=classified, summary=_nonretryable_summary, messages=messages, api_call_count=api_call_count, provider=provider, base_url=base_url, model=model, ) - return _failed_turn_result(_nonretryable_summary, messages, api_call_count, _nonretryable_summary) + _final_response = _nonretryable_summary + if _welcome_hint: + _final_response += f"\n\n{_welcome_tier_guidance(classified, model=model, in_chat=True)}" + result = _failed_turn_result(_final_response, messages, api_call_count, _nonretryable_summary) + # Same verdict fields as the max-retries path: without them the UI descriptor + # (agent/error_surface.py) reads a rejected OAuth token as a retryable + # "Provider error" and offers Retry instead of a re-login. + result.update({ + "failure_reason": classified.reason.value, + "failure_retryable": bool(classified.retryable), + }) + return result _STREAM_DROP_MARKERS = ( @@ -787,6 +816,9 @@ def max_retries_exhausted_result( else: agent._emit_status(f"❌ API failed after {max_retries} retries — {_final_summary}") _vlines(agent, f" 💀 Final error: {_final_summary}") + _welcome_hint = _welcome_tier_guidance(classified, model=model, in_chat=False) + if _welcome_hint: + _vlines(agent, f" 💡 {_welcome_hint}") # SSE stream-drop (e.g. "Network connection lost"): usually a proxy/CDN cutting a very # large tool call mid-response. @@ -841,6 +873,8 @@ def max_retries_exhausted_result( ) else: _final_response = f"API call failed after {max_retries} retries: {_final_summary}" + if _welcome_hint: + _final_response += f"\n\n{_welcome_tier_guidance(classified, model=model, in_chat=True)}" if _is_thinking_timeout: # Thinking-timeout guidance overrides stream-drop guidance, which would wrongly # suggest splitting large file writes. diff --git a/agent/turn_response_check.py b/agent/turn_response_check.py index 07dab60ceb..a0847ae1cb 100644 --- a/agent/turn_response_check.py +++ b/agent/turn_response_check.py @@ -67,7 +67,7 @@ def _derive_finish_reason(agent: Any, response: Any, messages: Any) -> str: return _codex_finish_reason(response) transport = agent._get_transport() if agent.api_mode == "anthropic_messages": - return transport.map_finish_reason(response.stop_reason) + return transport.response_finish_reason(response) normalized = transport.normalize_response(response) # Bedrock already normalized at dispatch finish_reason = normalized.finish_reason if agent.api_mode != "bedrock_converse" and agent._should_treat_stop_as_truncated( diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index 214c63223a..172374bf6b 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -186,11 +186,11 @@ _SNAPSHOTS: tuple[tuple[str, Optional[str], str, dict], ...] = ( "gpt-4.1-nano": ("0.10", "0.40", "0.025"), "o3": ("10.00", "40.00", "2.50"), "o3-mini": ("1.10", "4.40", "0.55"), }), - # deepseek-chat / deepseek-reasoner are deprecated aliases of - # deepseek-v4-flash's non-thinking / thinking modes — same rates. - ("deepseek", "https://api-docs.deepseek.com/quick_start/pricing", "deepseek-pricing-2026-07", { - ("deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash"): ("0.14", "0.28", "0.0028"), - "deepseek-v4-pro": ("0.435", "0.87", "0.003625"), + # Off-peak USD rates (peak = 2x, Mon-Fri 01-04 + 06-10 UTC). ``deepseek-v4-flash`` and the + # retired deepseek-chat / deepseek-reasoner aliases are served by V4.1-Flash at the Flash price. + ("deepseek", "https://api-docs.deepseek.com/quick_start/pricing", "deepseek-pricing-2026-09-10", { + ("deepseek-flash", "deepseek-v4-flash", "deepseek-chat", "deepseek-reasoner"): ("0.15", "0.60", "0.003"), + "deepseek-v4-pro": ("0.66", "1.98", "0.022"), }), ("google", "https://ai.google.dev/gemini-api/docs/pricing", "google-pricing-2026-09-02", { ("gemini-3.8-flash", "gemini-3.7-flash"): ("0.75", "3.75", "0.075"), diff --git a/agent/vault_backends/__init__.py b/agent/vault_backends/__init__.py new file mode 100644 index 0000000000..f2980cee4b --- /dev/null +++ b/agent/vault_backends/__init__.py @@ -0,0 +1,18 @@ +"""Login backends for the browser credential vault. + +The local Fernet store (``agent/vault_store.py``) is one backend; 1Password +(``op``) and Bitwarden Password Manager (``bw``) are the others. Every backend +hands the agent the same shape — opaque handle + login metadata — and resolves +the password server-side at fill time only. Handles are namespaced by backend +(``vault_…`` local, ``op:…``, ``bw:…``) so the browser tools need no schema +change to route to the right one. + +External managers are locked until the user unlocks them for the current +session (``agent/vault_backends/unlock.py``); the master password is typed +into a masked prompt owned by the surface (CLI panel, Desktop dialog) and is +never a tool argument, never argv, never persisted. +""" + +from agent.vault_backends.base import LoginBackend, UnlockRequired, backend_for_handle, enabled_backends + +__all__ = ["LoginBackend", "UnlockRequired", "backend_for_handle", "enabled_backends"] diff --git a/agent/vault_backends/base.py b/agent/vault_backends/base.py new file mode 100644 index 0000000000..05e43ce3a0 --- /dev/null +++ b/agent/vault_backends/base.py @@ -0,0 +1,139 @@ +"""Login-backend contract + registry for the browser credential vault. + +A ``LoginBackend`` lists login metadata (never secrets) and resolves ONE +password at fill time. External managers (1Password, Bitwarden) additionally +need a per-session unlock; ``resolve_password`` raises ``UnlockRequired`` +while locked so the tool can ask the surface to prompt. Handles are +namespaced by ``prefix`` so ``backend_for_handle`` needs no lookup table. +""" + +from __future__ import annotations + +import subprocess +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Dict, List, Optional, Sequence + +from agent.vault_store import VaultItemMeta + + +class UnlockRequired(Exception): + """The backend is locked for this session; the surface must prompt for the master password.""" + + def __init__(self, backend: "LoginBackend"): + super().__init__(f"{backend.display_name} is locked") + self.backend = backend + + +class LoginBackend(ABC): + name: str # config key: local | onepassword | bitwarden + display_name: str # user-facing + prefix: str # handle prefix ("vault_", "op:", "bw:") + needs_unlock: bool = False + + def owns(self, handle: str) -> bool: + return handle.startswith(self.prefix) + + def is_unlocked(self) -> bool: + return True + + @abstractmethod + def list_items(self) -> List[VaultItemMeta]: + """Metadata only. Locked external backends return [] (the agent sees a lock hint instead).""" + + @abstractmethod + def get_meta(self, handle: str) -> Optional[VaultItemMeta]: ... + + @abstractmethod + def resolve_password(self, handle: str) -> str: + """Server-side only; raises ``UnlockRequired`` when locked.""" + + def resolve_otp(self, handle: str) -> Optional[str]: + """Current one-time code for a login that stores a TOTP seed, else None (the user is asked). + Server-side only, like resolve_password.""" + return None + + def resolve_secret(self, handle: str) -> Dict[str, str]: + """Full payload of a payment/address item (server-side only). External managers list only + logins, so the base returns the password-only shape.""" + return {"password": self.resolve_password(handle)} + + +def run_with_stdin_secret(argv: Sequence[str], *, env: Dict[str, str], secret: str, timeout: float, + label: str) -> subprocess.CompletedProcess: + """Run a manager CLI feeding *secret* on stdin (never argv, never env). Spawn/timeout → RuntimeError.""" + try: + return subprocess.run( # noqa: S603 — argv list, no shell + list(argv), env=env, input=secret + "\n", capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=timeout) + except subprocess.TimeoutExpired as exc: + raise RuntimeError(f"{label} unlock timed out after {timeout:.0f}s") from exc + except OSError as exc: + raise RuntimeError(f"failed to invoke {label}: {exc}") from exc + + +def run_with_secret_env(argv: Sequence[str], *, env: Dict[str, str], secret_env: str, secret: str, timeout: float, + label: str) -> subprocess.CompletedProcess: + """Run a manager CLI whose non-interactive contract reads the secret from a named env var. + The variable is set on the child's environment only (never argv, never our process).""" + child_env = dict(env) + child_env[secret_env] = secret + try: + return subprocess.run( # noqa: S603 — argv list, no shell + list(argv), env=child_env, stdin=subprocess.DEVNULL, capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=timeout) + except subprocess.TimeoutExpired as exc: + raise RuntimeError(f"{label} unlock timed out after {timeout:.0f}s") from exc + except OSError as exc: + raise RuntimeError(f"failed to invoke {label}: {exc}") from exc + + +def _cfg() -> Dict: + from hermes_cli.config import load_config_readonly + cfg = load_config_readonly().get("vault") or {} + return cfg if isinstance(cfg, dict) else {} + + +def external_backend_classes(): + from agent.vault_backends.bitwarden import BitwardenLoginBackend + from agent.vault_backends.onepassword import OnePasswordLoginBackend + return (OnePasswordLoginBackend, BitwardenLoginBackend) + + +def is_installed(name: str) -> bool: + """Is the manager CLI reachable — honouring a configured ``binary_path`` over PATH.""" + import shutil + section = _cfg().get(name) or {} + explicit = str(section.get("binary_path") or "") if isinstance(section, dict) else "" + if explicit: + return Path(explicit).is_file() + if name == "onepassword": + from agent.secret_sources.onepassword import find_op + return find_op() is not None + return shutil.which("bw") is not None + + +def is_enabled(name: str) -> bool: + """An installed manager is a login source unless the user opted out (``vault..enabled: false``). + Zero-config on purpose: a user with ``bw``/``op`` on PATH should never have to discover a toggle.""" + section = _cfg().get(name) or {} + if isinstance(section, dict) and section.get("enabled") is False: + return False + return is_installed(name) + + +def enabled_backends() -> List[LoginBackend]: + """Local first (always on), then every detected external manager the user has not turned off.""" + from agent.vault_backends.local import LocalLoginBackend + + cfg = _cfg() + out: List[LoginBackend] = [LocalLoginBackend()] + for cls in external_backend_classes(): + if is_enabled(cls.name): + section = cfg.get(cls.name) or {} + out.append(cls(section if isinstance(section, dict) else {})) + return out + + +def backend_for_handle(handle: str) -> Optional[LoginBackend]: + return next((b for b in enabled_backends() if b.owns(handle)), None) diff --git a/agent/vault_backends/bitwarden.py b/agent/vault_backends/bitwarden.py new file mode 100644 index 0000000000..e1ebfdae47 --- /dev/null +++ b/agent/vault_backends/bitwarden.py @@ -0,0 +1,125 @@ +"""Bitwarden Password Manager logins as a vault backend (``bw`` CLI). + +This is the personal/org *password* vault (``bw``), distinct from the +Bitwarden Secrets Manager (``bws``) source that hydrates API keys at startup. +Unlock: ``bw unlock --raw --passwordenv VAR`` (the CLI rejects a piped password) mints a +``BW_SESSION`` token. List: ``bw list items`` filtered to type=1 (login) with +a URI. Resolve: ``bw get password ``. +""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +import subprocess +from pathlib import Path +from typing import Dict, List, Optional + +from agent.secret_sources.base import run_cli, scrub_ansi +from agent.vault_backends import unlock as _unlock +from agent.vault_backends.base import LoginBackend, UnlockRequired, run_with_secret_env +from agent.vault_store import VaultItemMeta, normalize_origin + +logger = logging.getLogger(__name__) + +_TIMEOUT = 30.0 +_ENV_KEEP = ("PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "SystemRoot", + "TMPDIR", "TMP", "TEMP", "XDG_CONFIG_HOME", "BITWARDENCLI_APPDATA_DIR") + + +class BitwardenLoginBackend(LoginBackend): + name = "bitwarden" + display_name = "Bitwarden" + prefix = "bw:" + needs_unlock = True + + def __init__(self, cfg: Optional[Dict] = None): + self.cfg = cfg or {} + + def _bw(self) -> Path: + explicit = str(self.cfg.get("binary_path") or "") + found = explicit or shutil.which("bw") + if not found: + raise RuntimeError("Bitwarden CLI (bw) not found — install it or set vault.bitwarden.binary_path") + return Path(found) + + def _env(self, session_token: Optional[str]) -> Dict[str, str]: + env = {k: os.environ[k] for k in _ENV_KEEP if k in os.environ} + env["NO_COLOR"] = "1" + if session_token: + env["BW_SESSION"] = session_token + return env + + def is_unlocked(self) -> bool: + return _unlock.is_unlocked(self.name) + + def unlock(self, master_password: str) -> None: + # bw refuses a piped password ("Master password is required"); its non-interactive contract is + # --passwordenv: the variable exists only in the child's environment, never in argv or ours. + generation = _unlock.begin_unlock(self.name) + proc = run_with_secret_env([str(self._bw()), "unlock", "--raw", "--nointeraction", "--passwordenv", "HERMES_BW_MASTER"], + env=self._env(None), secret_env="HERMES_BW_MASTER", secret=master_password, + timeout=_TIMEOUT, label="bw") + token = (proc.stdout or "").strip() + if proc.returncode != 0 or not token: + err = scrub_ansi(proc.stderr or "").strip()[:200] + if "not logged in" in err.lower(): + err = "not logged in — run `bw login` once in a terminal first" + raise RuntimeError(f"Bitwarden unlock failed: {err or 'no session key'}") + if not _unlock.store_session_token(self.name, token, generation): + raise RuntimeError("Bitwarden was locked while unlocking; try again") + + def _run(self, *args: str) -> str: + token = _unlock.get_session_token(self.name) + if not token: + raise UnlockRequired(self) + proc = run_cli([str(self._bw()), *args, "--nointeraction"], env=self._env(token), timeout=_TIMEOUT, + label="bw", timeout_message="bw timed out", stdin=subprocess.DEVNULL) + if proc.returncode != 0: + err = scrub_ansi(proc.stderr or "") + if "locked" in err.lower() or "session" in err.lower(): + _unlock.lock(self.name) + raise UnlockRequired(self) + raise RuntimeError(f"bw failed: {err[:200]}") + return proc.stdout or "" + + def list_items(self) -> List[VaultItemMeta]: + if not self.is_unlocked(): + return [] + raw = json.loads(self._run("list", "items") or "[]") + out: List[VaultItemMeta] = [] + for item in raw if isinstance(raw, list) else []: + if item.get("type") != 1 or not isinstance(item.get("login"), dict): + continue + login = item["login"] + origin = None + for uri in login.get("uris") or []: + try: + origin = normalize_origin(str(uri.get("uri") or "")) + break + except Exception: + continue + if not origin: + continue + username = str(login.get("username") or "").strip() or None + out.append(VaultItemMeta( + id=f"{self.prefix}{item.get('id')}", kind="login", label=str(item.get("name") or origin), + origin=origin, created_at=str(item.get("creationDate") or ""), + identifier_type="username" if username else None, identifier=username)) + return out + + def get_meta(self, handle: str) -> Optional[VaultItemMeta]: + return next((m for m in self.list_items() if m.id == handle), None) + + def resolve_password(self, handle: str) -> str: + return self._run("get", "password", handle[len(self.prefix):]).rstrip("\r\n") + + def resolve_otp(self, handle: str) -> Optional[str]: + # `bw get totp ` mints the current code from the item's TOTP seed; "No TOTP available" otherwise. + try: + code = self._run("get", "totp", handle[len(self.prefix):]).strip() + except Exception: + return None + return code if code.isdigit() else None diff --git a/agent/vault_backends/local.py b/agent/vault_backends/local.py new file mode 100644 index 0000000000..c49e7ff365 --- /dev/null +++ b/agent/vault_backends/local.py @@ -0,0 +1,38 @@ +"""Local Fernet vault as a login backend (the always-on default).""" + +from __future__ import annotations + +from typing import Dict, List, Optional + +from agent.vault_backends.base import LoginBackend +from agent.vault_store import VaultItemMeta + + +def _store(): + # Late import: tests and callers patch ``agent.vault_store.get_vault_store``; binding it here + # at call time keeps that facade name the single seam. + from agent.vault_store import get_vault_store + return get_vault_store() + + +class LocalLoginBackend(LoginBackend): + name = "local" + display_name = "Hermes vault" + prefix = "vault_" + + def list_items(self) -> List[VaultItemMeta]: + return _store().list_items() + + def get_meta(self, handle: str) -> Optional[VaultItemMeta]: + return _store().get_meta(handle) + + def resolve_password(self, handle: str) -> str: + return str(_store().resolve_secret(handle).get("password") or "") + + def resolve_otp(self, handle: str) -> Optional[str]: + from agent.vault_store import totp_now + seed = str(_store().resolve_secret(handle).get("otp_secret") or "") + return totp_now(seed) if seed else None + + def resolve_secret(self, handle: str) -> Dict[str, str]: + return {k: str(v) for k, v in _store().resolve_secret(handle).items()} diff --git a/agent/vault_backends/onepassword.py b/agent/vault_backends/onepassword.py new file mode 100644 index 0000000000..dd933fb70f --- /dev/null +++ b/agent/vault_backends/onepassword.py @@ -0,0 +1,140 @@ +"""1Password Login items as a vault backend (``op`` CLI). + +Unlock: ``op signin --raw`` with the master password on stdin (desktop-app +integration or account-level auth) mints an ``OP_SESSION_`` token. +A configured service-account token skips the prompt entirely (headless). +List: ``op item list --categories Login --format json`` → title, urls, +username. Resolve: ``op item get --fields label=password --reveal``. +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +from pathlib import Path +from typing import Dict, List, Optional + +from agent.secret_sources.base import run_cli +from agent.secret_sources.onepassword import _OP_ENV_ALLOWLIST, _scrub, find_op +from agent.vault_backends.base import LoginBackend, UnlockRequired, run_with_stdin_secret +from agent.vault_backends import unlock as _unlock +from agent.vault_store import VaultItemMeta, normalize_origin + +logger = logging.getLogger(__name__) + +_TIMEOUT = 30.0 + + +class OnePasswordLoginBackend(LoginBackend): + name = "onepassword" + display_name = "1Password" + prefix = "op:" + needs_unlock = True + + def __init__(self, cfg: Optional[Dict] = None): + self.cfg = cfg or {} + from agent.secret_scope import get_secret + env_name = str(self.cfg.get("service_account_token_env") or "OP_SERVICE_ACCOUNT_TOKEN") + self._service_token = get_secret(env_name, "") or "" + + # ── auth ──────────────────────────────────────────────────────────────── + + def _op(self) -> Path: + op = find_op(str(self.cfg.get("binary_path") or "")) + if op is None: + raise RuntimeError("1Password CLI (op) not found — install it or set vault.onepassword.binary_path") + return op + + def _env(self, session_token: Optional[str]) -> Dict[str, str]: + from agent.secret_scope import get_secret + env = {k: os.environ[k] for k in _OP_ENV_ALLOWLIST if k in os.environ and not k.startswith("OP_CONNECT_")} + # Connect credentials outrank OP_SERVICE_ACCOUNT_TOKEN inside op, so they must come from the + # profile's own secret scope like the service token does — never from the launch environment. + for k in ("OP_CONNECT_HOST", "OP_CONNECT_TOKEN"): + if v := get_secret(k, ""): + env[k] = v + env["NO_COLOR"] = "1" + account = str(self.cfg.get("account") or "") + if account: + env["OP_ACCOUNT"] = account + if self._service_token: + env["OP_SERVICE_ACCOUNT_TOKEN"] = self._service_token + elif session_token: + # op signin --raw prints the bare token; the env var name carries the account shorthand, + # which op also accepts as plain OP_SESSION for the default account. + env[f"OP_SESSION_{account}" if account else "OP_SESSION"] = session_token + return env + + def is_unlocked(self) -> bool: + return bool(self._service_token) or _unlock.is_unlocked(self.name) + + def unlock(self, master_password: str) -> None: + """Mint a session token from the master password (consumed on stdin, never argv).""" + generation = _unlock.begin_unlock(self.name) + cmd = [str(self._op()), "signin", "--raw"] + if account := str(self.cfg.get("account") or ""): + cmd += ["--account", account] + proc = run_with_stdin_secret(cmd, env=self._env(None), secret=master_password, timeout=_TIMEOUT, label="op") + token = (proc.stdout or "").strip() + if proc.returncode != 0 or not token: + raise RuntimeError(f"1Password unlock failed: {_scrub(proc.stderr or '')[:200] or 'no session token'}") + if not _unlock.store_session_token(self.name, token, generation): + raise RuntimeError("1Password was locked while unlocking; try again") + + def _run(self, *args: str) -> str: + token = None if self._service_token else _unlock.get_session_token(self.name) + if not self._service_token and not token: + raise UnlockRequired(self) + proc = run_cli([str(self._op()), *args], env=self._env(token), timeout=_TIMEOUT, label="op", + timeout_message="op timed out", stdin=subprocess.DEVNULL) + if proc.returncode != 0: + err = _scrub(proc.stderr or "") + if "session" in err.lower() or "sign in" in err.lower() or "not signed in" in err.lower(): + _unlock.lock(self.name) + raise UnlockRequired(self) + raise RuntimeError(f"op failed: {err[:200]}") + return proc.stdout or "" + + # ── backend contract ─────────────────────────────────────────────────── + def list_items(self) -> List[VaultItemMeta]: + if not self.is_unlocked(): + return [] + raw = json.loads(self._run("item", "list", "--categories", "Login", "--format", "json") or "[]") + out: List[VaultItemMeta] = [] + for item in raw if isinstance(raw, list) else []: + urls = [str(u["href"]) for u in item.get("urls") or [] if isinstance(u, dict) and u.get("href")] + origin = _first_origin(urls) + if not origin: + continue + username = str(item.get("additional_information") or "").strip() or None + out.append(VaultItemMeta( + id=f"{self.prefix}{item.get('id')}", kind="login", label=str(item.get("title") or origin), + origin=origin, created_at=str(item.get("created_at") or ""), + identifier_type="username" if username else None, identifier=username)) + return out + + def get_meta(self, handle: str) -> Optional[VaultItemMeta]: + return next((m for m in self.list_items() if m.id == handle), None) + + def resolve_password(self, handle: str) -> str: + item_id = handle[len(self.prefix):] + return self._run("item", "get", item_id, "--fields", "label=password", "--reveal").rstrip("\r\n") + + def resolve_otp(self, handle: str) -> Optional[str]: + # `--otp` mints the current TOTP from the item's one-time-password field; items without one error out. + try: + code = self._run("item", "get", handle[len(self.prefix):], "--otp").strip() + except Exception: + return None + return code if code.isdigit() else None + + +def _first_origin(urls: List[str]) -> Optional[str]: + for u in urls: + try: + return normalize_origin(u) + except Exception: + continue + return None diff --git a/agent/vault_backends/unlock.py b/agent/vault_backends/unlock.py new file mode 100644 index 0000000000..ffa621e0e7 --- /dev/null +++ b/agent/vault_backends/unlock.py @@ -0,0 +1,171 @@ +"""Per-process unlock state for external password managers. + +An unlock is a session token minted by the manager's CLI from the master +password (``op signin --raw`` / ``bw unlock --raw``). The token lives in +process memory only, keyed by backend, and expires after an idle TTL or an +explicit lock. The master password itself is consumed by the CLI call and +dropped; nothing is written to disk or env. + +The surface owns the prompt: ``set_unlock_prompt_callback`` is installed by +the CLI panel / TUI gateway bridge for the current thread, exactly like the +sudo-password callback. Headless contexts (cron, webhook, api_server, +single-query) install none and the vault stays locked — the same posture +approvals take where nobody can answer. +""" + +from __future__ import annotations + +import threading +import time +from typing import Callable, Dict, Optional + +_IDLE_TTL_S = 30 * 60 + +_lock = threading.Lock() +_sessions: Dict[tuple[str, str], tuple[str, float]] = {} # (profile home, backend) → (token, last_used) +_callback_tls = threading.local() + +UnlockPrompt = Callable[[str, str], str] # (backend_name, display_name) -> master password ("" = cancelled) +# (origin, site label) -> {"identifier": str, "password": str} or None when the user declines. The +# surface owns the masked fields; the tool stores the answer in the local vault and fills at once. +SaveLoginPrompt = Callable[[str, str], Optional[Dict[str, str]]] + + +def set_unlock_prompt_callback(cb: Optional[UnlockPrompt]) -> None: + """Register the current surface's masked master-password prompt (per-thread slot).""" + _callback_tls.prompt = cb + + +def get_unlock_prompt_callback() -> Optional[UnlockPrompt]: + return getattr(_callback_tls, "prompt", None) + + +# (site, hint) -> the one-time code the user reads off their phone/email/app, "" when declined. +CodePrompt = Callable[[str, str], str] + + +def set_code_prompt_callback(cb: Optional[CodePrompt]) -> None: + """Register the surface's "enter the code {site} sent you" prompt, per thread.""" + _callback_tls.code = cb + + +def get_code_prompt_callback() -> Optional[CodePrompt]: + return getattr(_callback_tls, "code", None) + + +def set_save_login_prompt_callback(cb: Optional[SaveLoginPrompt]) -> None: + """Register the surface's "save this login" prompt (identifier + masked password), per thread.""" + _callback_tls.save_login = cb + + +def get_save_login_prompt_callback() -> Optional[SaveLoginPrompt]: + return getattr(_callback_tls, "save_login", None) + + +def _key(backend: str) -> tuple[str, str]: + # Tokens are profile-scoped: a Desktop gateway hosts several profiles in one process and + # profile B must never reuse (or lock) profile A's manager session. + from hermes_constants import get_hermes_home + return (str(get_hermes_home()), backend) + + +# Lock generation per key: ``lock()`` bumps it, and an unlock that started before the bump must +# not commit its token afterwards (a slow `bw unlock` child would otherwise silently undo an +# acknowledged Lock). +_generation: Dict[tuple[str, str], int] = {} +# Which gateway session performed the unlock; the token is released when THAT session ends, +# not when any sibling session in the profile is torn down. +_owner_session: Dict[tuple[str, str], Optional[str]] = {} +_current_session_tls = threading.local() + + +def set_current_session_id(session_id: Optional[str]) -> None: + """Gateway surfaces bind the session running on this thread so an unlock records its owner.""" + _current_session_tls.sid = session_id + + +def _live(backend: str, *, touch: bool) -> Optional[str]: + key = _key(backend) + with _lock: + entry = _sessions.get(key) + if entry is None: + return None + token, last = entry + if time.monotonic() - last > _IDLE_TTL_S: + del _sessions[key] + return None + if touch: + _sessions[key] = (token, time.monotonic()) + return token + + +def get_session_token(backend: str) -> Optional[str]: + """Token for a real manager call; refreshes the idle timer.""" + return _live(backend, touch=True) + + +def begin_unlock(backend: str) -> int: + """Snapshot the lock generation before spawning the manager CLI; pass it to ``store_session_token``.""" + with _lock: + return _generation.get(_key(backend), 0) + + +def store_session_token(backend: str, token: str, generation: Optional[int] = None) -> bool: + """Commit an unlock. Returns False (and drops the token) when a Lock happened since ``begin_unlock``.""" + key = _key(backend) + with _lock: + if generation is not None and generation != _generation.get(key, 0): + return False + _sessions[key] = (token, time.monotonic()) + _owner_session[key] = getattr(_current_session_tls, "sid", None) + return True + + +def lock(backend: Optional[str] = None) -> None: + """Forget the current profile's session for one backend (or all of them when None).""" + home = _key("")[0] + with _lock: + # Bump the generation for every key the lock names (not only the ones holding a token): + # an unlock that is still running for this backend must see the lock when it returns. + keys = {k for k in list(_sessions) + list(_generation) if k[0] == home and (backend is None or k[1] == backend)} + if backend is not None: + keys.add((home, backend)) + for key in keys: + _forget(key) + + +def release_session(session_id: str) -> None: + """A gateway session ended: drop only the tokens that session unlocked.""" + with _lock: + for key in [k for k, sid in _owner_session.items() if sid == session_id]: + _forget(key) + + +def _forget(key: tuple[str, str]) -> None: + _sessions.pop(key, None) + _owner_session.pop(key, None) + _generation[key] = _generation.get(key, 0) + 1 + + +def lock_all_profiles() -> None: + """Process shutdown: drop every token.""" + with _lock: + for key in list(_sessions): + _forget(key) + + +def is_unlocked(backend: str) -> bool: + """Status probe: does NOT extend the idle TTL (only real manager calls do).""" + return _live(backend, touch=False) is not None + + +def can_prompt_here() -> bool: + """False in contexts where no human can answer (cron, webhook, api_server, -q).""" + from tools.approval_context import ( + _is_cron_approval_context, + _is_single_query_approval_context, + _is_unattended_platform_approval_context, + ) + if _is_cron_approval_context() or _is_unattended_platform_approval_context() or _is_single_query_approval_context(): + return False + return get_unlock_prompt_callback() is not None diff --git a/agent/vault_login_classifier.py b/agent/vault_login_classifier.py new file mode 100644 index 0000000000..684f113b45 --- /dev/null +++ b/agent/vault_login_classifier.py @@ -0,0 +1,333 @@ +"""Login / checkout form control classifier for vault autofill. + +Python port (~170 LOC) of Merit-Systems/OpenInstinct's +``lib/manager/server/kernel-login-autofill.ts`` (MIT). Classifies visible +input controls on a page into login-autofill tokens. The vault fill path +uses the classification to select the single best current-password control +(the identifier is agent-visible metadata and is typed by the agent +itself via normal input tools). + +Scoring: +- exact autocomplete-token match ................ 100 +- type=password (not new/confirm/create/repeat) .. 90 +- type=email / type=tel .......................... 85 +- label/name regex heuristics .................. 70-75 +Hard exclusions: autocomplete ``new-password`` / ``one-time-code``, and +label/name text matching ``(new|confirm|create|repeat)\\s*password``. +""" + +from __future__ import annotations + +import json +import re +import unicodedata +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +LOGIN_AUTOFILL_TOKENS = ("username", "email", "tel", "current-password") + +# Payment / address autocomplete tokens (WHATWG) the checkout fill targets. ``cc-exp`` (combined +# MM/YY) is derived at fill time from exp_month + exp_year. Field-name/label heuristics below back +# up sites that omit autocomplete attributes. +PAYMENT_AUTOFILL_TOKENS = ("cc-number", "cc-name", "cc-exp", "cc-exp-month", "cc-exp-year", "cc-csc") +ADDRESS_AUTOFILL_TOKENS = ("address-line1", "address-line2", "address-level2", "address-level1", + "postal-code", "country-name", "country") +_CHECKOUT_HEURISTICS = ( + (re.compile(r"\b(?:card\s*number|cardnumber|ccnumber|cc\s*num|pan)\b"), "cc-number"), + (re.compile(r"\b(?:name\s*on\s*card|cardholder|cc\s*name|ccname)\b"), "cc-name"), + (re.compile(r"\b(?:cvc|cvv|csc|security\s*code|card\s*code)\b"), "cc-csc"), + (re.compile(r"\b(?:exp(?:iry|iration)?\s*month|exp\s*mm|ccmonth)\b"), "cc-exp-month"), + (re.compile(r"\b(?:exp(?:iry|iration)?\s*year|exp\s*yy(?:yy)?|ccyear)\b"), "cc-exp-year"), + (re.compile(r"\b(?:exp(?:iry|iration)?(?:\s*date)?|mm\s*yy|valid\s*thru)\b"), "cc-exp"), + (re.compile(r"\b(?:address\s*(?:line\s*)?2|apt|suite|unit)\b"), "address-line2"), + (re.compile(r"\b(?:address(?:\s*line\s*1)?|street)\b"), "address-line1"), + (re.compile(r"\b(?:city|town|locality)\b"), "address-level2"), + (re.compile(r"\b(?:state|province|region|county)\b"), "address-level1"), + (re.compile(r"\b(?:zip|postal|postcode)\b"), "postal-code"), + (re.compile(r"\b(?:country)\b"), "country-name"), +) + +_EXCLUDED_AUTOCOMPLETE = {"new-password", "one-time-code"} + +_RE_EXCLUDED_PASSWORD = re.compile(r"\b(?:new|confirm|create|repeat)\s*password\b") +_RE_EMAIL = re.compile(r"\b(?:e[\s-]?mail|email address)\b") +_RE_TEL = re.compile(r"\b(?:phone|telephone|mobile)\b") +_RE_USERNAME = re.compile( + r"\b(?:user\s*name|username|login|account|member|membership|mileageplus)\b" +) + + +def _normalize_text(value: str) -> str: + value = unicodedata.normalize("NFKD", value).lower() + return re.sub(r"[^a-z0-9]+", " ", value).strip() + + +@dataclass(frozen=True) +class LoginControl: + """Descriptor of a visible input control, as inspected in the page.""" + + autocomplete: str + form_index: Optional[int] + index: int + label: str + name: str + type: str + max_length: Optional[int] = None + + @classmethod + def from_dict(cls, raw: Dict[str, Any]) -> "LoginControl": + form_index = raw.get("formIndex", raw.get("form_index")) + max_length = raw.get("maxLength", raw.get("max_length")) + return cls( + autocomplete=str(raw.get("autocomplete") or ""), + form_index=int(form_index) if form_index is not None else None, + index=int(raw.get("index") or 0), + label=str(raw.get("label") or ""), + name=str(raw.get("name") or ""), + type=str(raw.get("type") or ""), + max_length=int(max_length) if max_length is not None else None, + ) + + +@dataclass(frozen=True) +class ClassifiedLoginControl: + control: LoginControl + score: int + token: str + + +def classify_login_control(control: LoginControl) -> Optional[ClassifiedLoginControl]: + """Classify one control, or return None if it is not a login fill target.""" + autocomplete_tokens = [ + t for t in control.autocomplete.lower().split() if t + ] + if any(t in _EXCLUDED_AUTOCOMPLETE for t in autocomplete_tokens): + return None + + for token in LOGIN_AUTOFILL_TOKENS: + if token in autocomplete_tokens: + return ClassifiedLoginControl(control, 100, token) + + searchable = _normalize_text( + " ".join(part for part in (control.name, control.label) if part) + ) + if _RE_EXCLUDED_PASSWORD.search(searchable): + return None + if control.type == "password": + return ClassifiedLoginControl(control, 90, "current-password") + if control.type == "email": + return ClassifiedLoginControl(control, 85, "email") + if control.type == "tel": + return ClassifiedLoginControl(control, 85, "tel") + if _RE_EMAIL.search(searchable): + return ClassifiedLoginControl(control, 75, "email") + if _RE_TEL.search(searchable): + return ClassifiedLoginControl(control, 75, "tel") + if _RE_USERNAME.search(searchable): + return ClassifiedLoginControl(control, 70, "username") + return None + + +_RE_OTP = re.compile( + r"\b(?:one[\s-]?time|verification|security|auth(?:entication|enticator)?|2fa|two[\s-]?factor|mfa|totp|otp|" + r"passcode|sms)\b.*\b(?:code|pin|token)\b|\b(?:otp|totp|2fa|mfa|verification\s*code|passcode)\b" +) + + +def classify_otp_controls(controls: List[LoginControl]) -> List[ClassifiedLoginControl]: + """The controls that take a second-factor code. ``autocomplete=one-time-code`` is authoritative; + otherwise a text/tel/number input whose name/label says code/OTP/2FA/verification. Some sites split + the code into one input per digit (``maxlength=1`` boxes): they are returned in DOM order and the + fill spreads the code across them.""" + out: List[ClassifiedLoginControl] = [] + for c in controls: + tokens = c.autocomplete.lower().split() + if "one-time-code" in tokens: + out.append(ClassifiedLoginControl(c, 100, "one-time-code")) + continue + if c.type not in ("text", "tel", "number", "password", ""): + continue + if _RE_OTP.search(_normalize_text(" ".join(p for p in (c.name, c.label) if p))): + out.append(ClassifiedLoginControl(c, 70, "one-time-code")) + return out + + +def select_password_fill( + classified: List[ClassifiedLoginControl], + password: str, +) -> List[Dict[str, Any]]: + """Select the single best current-password control to fill. + + The vault fill path is password-only: the identifier is agent-visible + metadata and is typed by the agent via normal input tools. This picks + the highest-scoring ``current-password`` control (ties broken by DOM + order) and returns ``[{"index": int, "token": "current-password", + "value": password}]`` or ``[]`` when no password field exists. + """ + passwords = [c for c in classified if c.token == "current-password"] + if not passwords or not password: + return [] + best_password = sorted( + passwords, key=lambda c: (-c.score, c.control.index) + )[0] + return [ + { + "index": best_password.control.index, + "token": "current-password", + "value": password, + } + ] + + +def classify_checkout_control(control: LoginControl) -> Optional[ClassifiedLoginControl]: + """Classify one control as a payment/address fill target (autocomplete token exact match 100, + label/name heuristic 70), or None. Password/email inputs are never checkout targets.""" + tokens = [t for t in control.autocomplete.lower().split() if t] + for token in PAYMENT_AUTOFILL_TOKENS + ADDRESS_AUTOFILL_TOKENS: + if token in tokens: + return ClassifiedLoginControl(control, 100, "country-name" if token == "country" else token) + if control.type in ("password", "email"): + return None + searchable = _normalize_text(" ".join(part for part in (control.name, control.label) if part)) + for pattern, token in _CHECKOUT_HEURISTICS: + if pattern.search(searchable): + return ClassifiedLoginControl(control, 70, token) + return None + + +def select_checkout_fills(classified: List[ClassifiedLoginControl], secret: Dict[str, str], + field_tokens: Dict[str, str]) -> List[Dict[str, Any]]: + """Map a payment/address secret payload onto the best control per autocomplete token. + + ``field_tokens`` is ``PAYMENT_FIELDS`` / ``ADDRESS_FIELDS`` (agent/vault_store.py). A combined + ``cc-exp`` control gets ``MM/YY`` from exp_month + exp_year and then suppresses the separate + month/year fills. Returns ``[{"index", "token", "value"}]``: one control per token, highest + score then DOM order. + """ + values: Dict[str, str] = {tok: secret[f] for f, tok in field_tokens.items() if secret.get(f)} + if "cc-exp-month" in values and "cc-exp-year" in values: + values["cc-exp"] = f"{values['cc-exp-month'].zfill(2)}/{values['cc-exp-year'][-2:]}" + fills: List[Dict[str, Any]] = [] + for token, value in values.items(): + candidates = sorted((c for c in classified if c.token == token), key=lambda c: (-c.score, c.control.index)) + if candidates: + fills.append({"index": candidates[0].control.index, "token": token, "value": value}) + if any(f["token"] == "cc-exp" for f in fills): + fills = [f for f in fills if f["token"] not in ("cc-exp-month", "cc-exp-year")] + return fills + + +# JS expression evaluated in the page to inspect candidate input controls. +# Ported from OpenInstinct's nativeLoginControlInspectionExpression. +# Inspection stamps every input with ``:`` under a per-inspection attribute; the fill +# script resolves targets by the stamp of ITS OWN inspection instead of re-querying by position, so +# neither a DOM reflow nor a second inspection in between can redirect the password into another field. +INSPECTION_STAMP_ATTR = "data-hermes-vault-slot" + + +def build_otp_fills(otp_controls: List[ClassifiedLoginControl], code: str) -> List[Dict[str, Any]]: + """One fill per box. Default: the single best-scoring code field takes the whole code. + + Per-digit entry only when the page unmistakably uses it: exactly len(code) OTP controls that are all + ``maxlength=1``, all in the same form, and adjacent in DOM order (the classic N-box widget). Anything + looser (several code-like inputs scattered over a page) gets ONE field, never a digit sprayed across + unrelated inputs.""" + best = max(otp_controls, key=lambda c: c.score) + boxes = sorted((c for c in otp_controls if c.control.max_length == 1), key=lambda c: c.control.index) + if (len(boxes) == len(code) + and len({b.control.form_index for b in boxes}) == 1 + and all(b.control.index - a.control.index == 1 for a, b in zip(boxes, boxes[1:]))): + return [{"index": b.control.index, "token": "one-time-code", "value": ch} for b, ch in zip(boxes, code)] + return [{"index": best.control.index, "token": "one-time-code", "value": code}] + + +def build_inspection_js(nonce: str) -> str: + return _LOGIN_CONTROL_INSPECTION_JS_TEMPLATE.replace("__NONCE__", json.dumps(nonce)) + + +_LOGIN_CONTROL_INSPECTION_JS_TEMPLATE = """(() => { + const nonce = __NONCE__; + const elements = Array.from(document.querySelectorAll("input, select")); + const forms = Array.from(document.forms); + elements.forEach((element, index) => element.setAttribute("data-hermes-vault-slot", nonce + ":" + index)); + const out = elements.flatMap((element, index) => { + if (element.disabled || element.readOnly) return []; + if (["hidden", "submit", "button", "reset", "file", "image", "checkbox", "radio"].includes(element.type)) return []; + const style = getComputedStyle(element); + if (style.display === "none" || style.visibility === "hidden" || element.getClientRects().length === 0) return []; + const labels = element.labels ? Array.from(element.labels, (l) => l.textContent || "") : []; + const ariaText = (element.getAttribute("aria-labelledby") || "") + .split(/\\s+/).filter(Boolean) + .map((id) => { const n = document.getElementById(id); return n ? (n.textContent || "") : ""; }) + .join(" "); + const resolvedFormIndex = element.form ? forms.indexOf(element.form) : -1; + return [{ + autocomplete: element.autocomplete || "", + formIndex: resolvedFormIndex >= 0 ? resolvedFormIndex : null, + index, + maxLength: element.maxLength > 0 ? element.maxLength : null, + label: [ + ...labels, + element.getAttribute("aria-label") || "", + ariaText, + element.getAttribute("placeholder") || "", + element.getAttribute("title") || "", + ].join(" "), + name: [element.name, element.id].join(" "), + type: element.tagName === "SELECT" ? "select" : (element.type || ""), + }]; + }); + return JSON.stringify(out); +})()""" + + +def build_fill_js(fills: List[Dict[str, Any]], expected_origin: str, nonce: str = "") -> str: + """Build a JS expression that fills the selected controls and reports only a count. The + returned expression never echoes the values back. + + ``expected_origin`` is asserted against ``window.location.origin`` synchronously inside the SAME + evaluated script, immediately before any write. If the page navigated between inspection and fill + (TOCTOU), the script writes nothing and returns ``{"refused": "origin_changed", "found": }``: + proof scope equals mutation scope (#88706). Targets resolve by the ``:`` stamp of + THIS inspection; a ``current-password`` fill additionally requires ``type=password``; `` setNewAllowedId(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault() + addAllowedId() + } + }} + placeholder={q.userIdPlaceholder} + value={newAllowedId} + /> + + + + +
+ + +
+ + )} + + +
+ Telegram setup QR code + + {expiresIn ? q.expiresIn(expiresIn) : q.expired} + +
+ + {phase === 'waiting' && ( + + )} +
+
+ + )} + + ) +} diff --git a/apps/desktop/src/app/open-session.test.ts b/apps/desktop/src/app/open-session.test.ts index 4a3f95d7df..bbc99fa1aa 100644 --- a/apps/desktop/src/app/open-session.test.ts +++ b/apps/desktop/src/app/open-session.test.ts @@ -165,6 +165,17 @@ describe('openSession', () => { expect(navigate).not.toHaveBeenCalled() }) + it.each(['stack', 'tab'] as const)('%s uncovers the existing main chat when a page is showing', intent => { + $selectedStoredSessionId.set('s1') + focusOpenSession.mockReturnValue('main') + workspaceIsPageGet.mockReturnValue(true) + + openSession('s1', navigate, intent) + + expect(navigate).toHaveBeenCalledWith('/c/s1') + expect(openSessionTile).not.toHaveBeenCalled() + }) + it('stack opens a tab rather than taking main from a loaded chat', () => { $selectedStoredSessionId.set('s0') focusOpenSession.mockReturnValue(null) diff --git a/apps/desktop/src/app/open-session.ts b/apps/desktop/src/app/open-session.ts index e76a9face2..5fa34b09b5 100644 --- a/apps/desktop/src/app/open-session.ts +++ b/apps/desktop/src/app/open-session.ts @@ -137,6 +137,10 @@ export function openSession( const focused = focusOpenSession(storedSessionId, workspaceScope) if (focused) { + if (focusedSessionNeedsRoute(focused, $workspaceIsPage.get())) { + navigate(sessionRoute(storedSessionId)) + } + return } @@ -158,6 +162,8 @@ export function openSession( openSessionTile(storedSessionId, 'center') } + focusOpenSession(storedSessionId, workspaceScope) + return } diff --git a/apps/desktop/src/app/routes.titlebar-clusters.test.ts b/apps/desktop/src/app/routes.titlebar-clusters.test.ts new file mode 100644 index 0000000000..b023d14448 --- /dev/null +++ b/apps/desktop/src/app/routes.titlebar-clusters.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import { hidesFixedTitlebarClusters, isOverlayView } from './routes' + +describe('hidesFixedTitlebarClusters', () => { + it('hides clusters on contributed full pages and overlays', () => { + expect(hidesFixedTitlebarClusters('extension')).toBe(true) + expect(hidesFixedTitlebarClusters('settings')).toBe(true) + }) + + it('keeps clusters on chat and first-party workspace pages', () => { + expect(hidesFixedTitlebarClusters('chat')).toBe(false) + expect(hidesFixedTitlebarClusters('skills')).toBe(false) + expect(hidesFixedTitlebarClusters('messaging')).toBe(false) + expect(hidesFixedTitlebarClusters('artifacts')).toBe(false) + }) + + it('does not treat extension as an overlay', () => { + expect(isOverlayView('extension')).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/routes.ts b/apps/desktop/src/app/routes.ts index 03a5117951..7e5fbd4718 100644 --- a/apps/desktop/src/app/routes.ts +++ b/apps/desktop/src/app/routes.ts @@ -141,6 +141,16 @@ export function isOverlayView(view: AppView): boolean { return OVERLAY_VIEWS.has(view) } +/** True when TitlebarControls may hide the app's fixed tool clusters. + * Overlays already own the window (clusters AND titleBar slots unmount). + * Contributed full pages (`extension`) hide the app clusters only while the + * page actually mounts `titleBar.*` chrome — those slots are mount-scoped, so + * a plugin page with no titlebar contribution keeps the app controls. + * First-party workspace pages (skills/messaging/artifacts) keep the clusters. */ +export function hidesFixedTitlebarClusters(view: AppView): boolean { + return isOverlayView(view) || view === 'extension' +} + /** The pathname of a router target. Every classifier below reasons about a * PATH, but callers navigate to full targets (`/skills?tab=mcp`), and an * unstripped query reaches the session-id parser — `/skills?tab=mcp` reads as diff --git a/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx b/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx index f906098229..c102e08f89 100644 --- a/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx @@ -10,7 +10,7 @@ import { getQueuedPrompts, parkQueuedPrompts } from '@/store/composer-queue' -import { $sessions, setSessions } from '@/store/session' +import { $sessions, setSessions, setSessionsLoading } from '@/store/session' import { clearAllSessionStates, publishSessionState } from '@/store/session-states' import type { SessionInfo } from '@/types/hermes' @@ -62,6 +62,9 @@ describe('useBackgroundQueueDrain', () => { beforeEach(() => { vi.useRealTimers() clearAllSessionStates() + // Production drain waits for the sidebar list. Tests that assert drain + // behavior are post-load unless they opt into the loading gate. + setSessionsLoading(false) }) afterEach(() => { @@ -71,6 +74,7 @@ describe('useBackgroundQueueDrain', () => { $queuedPromptsBySession.set({}) $parkedQueueSessions.set({}) $sessions.set([]) + setSessionsLoading(true) clearAllSessionStates() }) @@ -222,4 +226,52 @@ describe('useBackgroundQueueDrain', () => { expect(submitText).toHaveBeenCalledTimes(2) expect(getQueuedPrompts('stored-session-a')).toHaveLength(0) }) + + it('does not drain restored queues while the session list is still loading', async () => { + vi.useFakeTimers() + setSessionsLoading(true) + + const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) } + const submitText = vi.fn(async () => true) + + enqueueQueuedPrompt('stored-session-a', { text: 'wait for session list', attachments: [] }) + + render() + + // Four 750ms retries is what used to burn the drain budget and toast on boot. + await act(async () => { + await vi.advanceTimersByTimeAsync(750 * 4) + await Promise.resolve() + }) + + expect(submitText).not.toHaveBeenCalled() + expect(getQueuedPrompts('stored-session-a')).toHaveLength(1) + }) + + it('drains a restored background queue once the session list finishes loading', async () => { + setSessionsLoading(true) + + const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) } + const submitText = vi.fn(async () => true) + + enqueueQueuedPrompt('stored-session-a', { text: 'send after load', attachments: [] }) + + render() + + await new Promise(resolve => window.setTimeout(resolve, 0)) + expect(submitText).not.toHaveBeenCalled() + + setSessionsLoading(false) + + await waitFor(() => { + expect(submitText).toHaveBeenCalledWith('send after load', { + attachments: [], + fromQueue: true, + sessionId: 'rt-session-a', + storedSessionId: 'stored-session-a' + }) + }) + + await waitFor(() => expect(getQueuedPrompts('stored-session-a')).toHaveLength(0)) + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts b/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts index 8573ab9c8a..46929653ad 100644 --- a/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts +++ b/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts @@ -13,7 +13,7 @@ import { shouldAutoDrain } from '@/store/composer-queue' import { notify } from '@/store/notifications' -import { $sessions, idsShareLineage } from '@/store/session' +import { $sessions, $sessionsLoading, idsShareLineage } from '@/store/session' import { $workingSessionIds } from '@/store/session-states' import type { SubmitTextOptions } from './use-prompt-actions/utils' @@ -46,6 +46,7 @@ export function useBackgroundQueueDrain({ const { t } = useI18n() const queuedPromptsBySession = useStore($queuedPromptsBySession) const parkedQueueSessions = useStore($parkedQueueSessions) + const sessionsLoading = useStore($sessionsLoading) const workingSessionIds = useStore($workingSessionIds) const submitTextRef = useRef(submitText) const drainingSessionIdsRef = useRef(new Set()) @@ -151,7 +152,10 @@ export function useBackgroundQueueDrain({ ) useEffect(() => { - if (!enabled) { + // Preserve the retry budget while session discovery runs at boot, on a + // gateway/profile switch, or during a refresh over an empty list. + // Once discovery settles, submitText can resume by stored id. + if (!enabled || sessionsLoading) { return } @@ -194,6 +198,7 @@ export function useBackgroundQueueDrain({ queuedPromptsBySession, retryTick, selectedStoredSessionId, + sessionsLoading, workingSessionIds ]) } diff --git a/apps/desktop/src/app/session/hooks/use-background-queue-preservation.test.tsx b/apps/desktop/src/app/session/hooks/use-background-queue-preservation.test.tsx new file mode 100644 index 0000000000..8df29f2d2c --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-background-queue-preservation.test.tsx @@ -0,0 +1,72 @@ +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' + +import { + $parkedQueueSessions, + $queuedPromptsBySession, + enqueueQueuedPrompt, + getQueuedPrompts, + MAX_AUTO_DRAIN_ATTEMPTS +} from '@/store/composer-queue' +import { clearNotifications } from '@/store/notifications' +import { $sessions, forgetSessionOwnerHintsForSession, setSessionOwnerHint, setSessionsLoading } from '@/store/session' +import { clearAllSessionStates } from '@/store/session-states' + +import { useBackgroundQueueDrain } from './use-background-queue-drain' + +afterEach(() => { + cleanup() + vi.useRealTimers() + $queuedPromptsBySession.set({}) + $parkedQueueSessions.set({}) + $sessions.set([]) + setSessionsLoading(true) + forgetSessionOwnerHintsForSession('hidden-bot-chat') + clearNotifications() + clearAllSessionStates() +}) + +it.each(['rejection', 'transport error'])( + 'preserves a known hidden session queue after transient %s', + async failure => { + vi.useFakeTimers() + $sessions.set([]) + setSessionsLoading(false) + clearAllSessionStates() + setSessionOwnerHint('hidden-bot-chat', { connectionId: 'remote-a', profile: 'bot' }) + const first = enqueueQueuedPrompt('hidden-bot-chat', { text: 'retry this later', attachments: [] })! + const second = enqueueQueuedPrompt('hidden-bot-chat', { text: 'not attempted yet', attachments: [] })! + const submitText = vi.fn<(text: string) => Promise>() + + if (failure === 'transport error') { + submitText.mockRejectedValue(new Error('WebSocket temporarily disconnected')) + } else { + submitText.mockResolvedValue(false) + } + + const runtimeMap = { current: new Map([['hidden-bot-chat', 'live-runtime']]) } + + renderHook(() => + useBackgroundQueueDrain({ + enabled: true, + runtimeIdByStoredSessionIdRef: runtimeMap, + selectedStoredSessionId: 'foreground', + submitText + }) + ) + + await act(async () => { + await Promise.resolve() + }) + + for (let attempt = 1; attempt < MAX_AUTO_DRAIN_ATTEMPTS; attempt++) { + await act(async () => { + await vi.advanceTimersByTimeAsync(750) + }) + } + + expect(submitText).toHaveBeenCalledTimes(MAX_AUTO_DRAIN_ATTEMPTS) + expect(submitText.mock.calls.every(call => call[0] === first.text)).toBe(true) + expect(getQueuedPrompts('hidden-bot-chat').map(entry => entry.id)).toEqual([first.id, second.id]) + } +) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.test.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.test.ts new file mode 100644 index 0000000000..ef664fe0e0 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { $gateway } from '@/store/gateway' +import { $toursEnabled } from '@/store/tours' + +import { handleDesktopBridgeEvent } from './desktop-bridge' +import type { GatewayEventContext } from './types' + +function previewActContext({ + explicitSid, + isActiveEvent +}: { + explicitSid: string + isActiveEvent: boolean +}): GatewayEventContext { + return { + event: { session_id: explicitSid || undefined, type: 'preview.act.request' }, + explicitSid, + isActiveEvent, + payload: { action: 'elements', request_id: 'request-1' } + } as GatewayEventContext +} + +describe('preview action bridge routing', () => { + afterEach(() => { + $gateway.set(null) + }) + + it('leaves a scoped action request unanswered in a window showing another session', () => { + const request = vi.fn() + $gateway.set({ request } as never) + + expect(handleDesktopBridgeEvent(previewActContext({ explicitSid: 'session-a', isActiveEvent: false }))).toBe(true) + expect(request).not.toHaveBeenCalled() + }) + + it('keeps the legacy fail-fast response for an unscoped inactive request', () => { + const request = vi.fn() + $gateway.set({ request } as never) + + expect(handleDesktopBridgeEvent(previewActContext({ explicitSid: '', isActiveEvent: false }))).toBe(true) + expect(request).toHaveBeenCalledWith('preview.act.respond', { + request_id: 'request-1', + text: JSON.stringify({ + error: 'The in-app browser only takes actions in the session the user is looking at.', + success: false + }) + }) + }) +}) + +function tourContext({ + explicitSid, + isActiveEvent +}: { + explicitSid: string + isActiveEvent: boolean +}): GatewayEventContext { + return { + event: { session_id: explicitSid || undefined, type: 'tour.request' }, + explicitSid, + isActiveEvent, + payload: { action: 'discover', request_id: 'tour-request-1' } + } as GatewayEventContext +} + +describe('tour bridge routing', () => { + afterEach(() => { + $gateway.set(null) + $toursEnabled.set(true) + }) + + it('leaves a scoped request unanswered in another session even when tours are disabled', () => { + const request = vi.fn() + $gateway.set({ request } as never) + $toursEnabled.set(false) + + expect(handleDesktopBridgeEvent(tourContext({ explicitSid: 'session-a', isActiveEvent: false }))).toBe(true) + expect(request).not.toHaveBeenCalled() + }) + + it('keeps the legacy fail-fast response for an unscoped inactive request', () => { + const request = vi.fn() + $gateway.set({ request } as never) + + expect(handleDesktopBridgeEvent(tourContext({ explicitSid: '', isActiveEvent: false }))).toBe(true) + expect(request).toHaveBeenCalledWith('tour.respond', { + request_id: 'tour-request-1', + text: JSON.stringify({ + error: 'Tours only run in the session the user is looking at.', + success: false + }) + }) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts index 1372a1f36e..be9edae842 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/desktop-bridge.ts @@ -45,7 +45,7 @@ const loadPreviewEngine = () => { * (terminal/preview/window), agent terminal streaming, pane reveal, and * message reactions. */ export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean { - const { event, payload, isActiveEvent } = ctx + const { event, payload, explicitSid, isActiveEvent } = ctx if (event.type === 'terminal.read.request') { // read_terminal tool: serialize the renderer's xterm buffer and answer @@ -95,6 +95,13 @@ export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean { const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' if (requestId) { + // Every mounted desktop window can observe the same gateway event. A + // scoped mismatch belongs to another window, so answering here would race + // the owning window and could make this refusal win before its real result. + if (explicitSid && !isActiveEvent) { + return true + } + const answer = (result: unknown) => $gateway.get()?.request('preview.act.respond', { request_id: requestId, @@ -179,6 +186,13 @@ export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean { const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' if (requestId) { + // As with preview actions, only the renderer that owns an explicitly + // scoped request may answer. Inactive windows must stay silent even when + // tours are disabled locally, or their refusal can beat the owner. + if (explicitSid && !isActiveEvent) { + return true + } + const answer = (result: unknown) => $gateway.get()?.request('tour.respond', { request_id: requestId, diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts index bb5420816f..9319993797 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts @@ -12,7 +12,20 @@ import { import { $gateway } from '@/store/gateway' import { setMcpSetupRequest } from '@/store/mcp-setup' import { dispatchNativeNotification } from '@/store/native-notifications' -import { receiveApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts' +import { + $vaultCodeRequests, + $vaultSaveLoginRequests, + $vaultUnlockRequests, + clearVaultCodeRequest, + clearVaultSaveLoginRequest, + clearVaultUnlockRequest, + receiveApprovalRequest, + setSecretRequest, + setSudoRequest, + setVaultCodeRequest, + setVaultSaveLoginRequest, + setVaultUnlockRequest +} from '@/store/prompts' import { requestScrollToBottom } from '@/store/thread-scroll' import type { GatewayEventContext } from './types' @@ -157,6 +170,39 @@ export function handleInputRequestEvent(ctx: GatewayEventContext): boolean { return true } + if (event.type === 'vault.code.expire') { + const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' + const request = sessionId ? $vaultCodeRequests.get()[sessionId] : undefined + + if (requestId && request && request.requestId === requestId) { + clearVaultCodeRequest(sessionId, requestId) + } + + return true + } + + if (event.type === 'vault.save_login.expire') { + const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' + const request = sessionId ? $vaultSaveLoginRequests.get()[sessionId] : undefined + + if (requestId && request && request.requestId === requestId) { + clearVaultSaveLoginRequest(sessionId, requestId) + } + + return true + } + + if (event.type === 'vault.unlock.expire') { + const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' + const request = sessionId ? $vaultUnlockRequests.get()[sessionId] : undefined + + if (requestId && request && request.requestId === requestId) { + clearVaultUnlockRequest(sessionId, requestId) + } + + return true + } + if (event.type === 'clarify.expire') { if (!sessionId) { return true @@ -321,5 +367,82 @@ export function handleInputRequestEvent(ctx: GatewayEventContext): boolean { return true } + if (event.type === 'vault.code.request') { + // Second factor: the site asked for a one-time code and no authenticator key is saved for the login. + const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' + + if (requestId) { + const site = typeof payload?.site === 'string' ? payload.site : '' + const hint = typeof payload?.hint === 'string' ? payload.hint : '' + + setVaultCodeRequest({ hint, requestId, sessionId: sessionId ?? null, site }) + + if (sessionId) { + updateSessionState(sessionId, state => ({ ...state, needsInput: true })) + } + + dispatchNativeNotification({ + body: translateNow('prompts.vaultCodeTitle', site), + kind: 'input', + sessionId, + title: translateNow('notifications.native.inputTitle') + }) + } + + return true + } + + if (event.type === 'vault.save_login.request') { + // The agent is on a sign-in page with no saved login: identifier + masked password card; the + // answer is stored in the encrypted vault by the backend and filled at once (never shown to the model). + const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' + + if (requestId) { + const origin = typeof payload?.origin === 'string' ? payload.origin : '' + const site = typeof payload?.site === 'string' ? payload.site : origin + + setVaultSaveLoginRequest({ origin, requestId, sessionId: sessionId ?? null, site }) + + if (sessionId) { + updateSessionState(sessionId, state => ({ ...state, needsInput: true })) + } + + dispatchNativeNotification({ + body: translateNow('prompts.vaultSaveTitle', site), + kind: 'input', + sessionId, + title: translateNow('notifications.native.inputTitle') + }) + } + + return true + } + + if (event.type === 'vault.unlock.request') { + // External password-manager unlock (agent/vault_backends). Blocked on + // vault.unlock.respond {request_id, password}; "" keeps it locked. + const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' + + if (requestId) { + const backend = typeof payload?.backend === 'string' ? payload.backend : '' + const displayName = typeof payload?.display_name === 'string' ? payload.display_name : backend + + setVaultUnlockRequest({ backend, displayName, requestId, sessionId: sessionId ?? null }) + + if (sessionId) { + updateSessionState(sessionId, state => ({ ...state, needsInput: true })) + } + + dispatchNativeNotification({ + body: translateNow('prompts.vaultUnlockTitle', displayName), + kind: 'input', + sessionId, + title: translateNow('notifications.native.inputTitle') + }) + } + + return true + } + return false } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.test.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.test.ts new file mode 100644 index 0000000000..7d655fe74e --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.test.ts @@ -0,0 +1,105 @@ +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useStatusSnapshot } from '@/app/shell/hooks/use-status-snapshot' +import { getStatus } from '@/hermes' +import { $setupReadyTick } from '@/store/live-sync' + +import { handleLifecycleEvent } from './lifecycle' +import type { GatewayEventContext } from './types' + +vi.mock(import('@/hermes'), async importOriginal => ({ + ...(await importOriginal()), + getStatus: vi.fn() +})) + +type GatewayRequester = (method: string, params?: Record) => Promise + +function setupReadyContext(fromActiveSource: boolean): GatewayEventContext { + const payload = { + error: '', + finished_at: 1_700_000_100, + free_tier: true, + has_identity: true, + inference_provider: 'nous', + other_providers: false, + provider_configured: true + } + + return { + deps: {} as GatewayEventContext['deps'], + event: { payload, type: 'setup.ready' }, + explicitSid: '', + fromActiveSource: () => fromActiveSource, + isActiveEvent: false, + occurredAt: 1_700_000_100, + payload: payload as GatewayEventContext['payload'], + scheduleConfigRefresh: vi.fn(), + sessionId: null + } +} + +async function flushAsync() { + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) +} + +/** Mount the status snapshot on an open gateway and return its requester with + * the open-time readiness round already consumed. */ +async function mountedStatusSnapshot() { + const requestGateway = vi.fn( + async (method: string) => (method === 'setup.runtime_check' ? { ok: true } : { provider_configured: true }) as never + ) + + renderHook(() => useStatusSnapshot('open', requestGateway as unknown as GatewayRequester)) + await flushAsync() + requestGateway.mockClear() + vi.mocked(getStatus).mockClear() + + return requestGateway +} + +function callsTo(requestGateway: ReturnType, method: string) { + return requestGateway.mock.calls.filter(([called]) => called === method) +} + +describe('handleLifecycleEvent setup.ready', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + vi.mocked(getStatus) + .mockReset() + .mockResolvedValue({} as never) + $setupReadyTick.set(0) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it('claims the event and triggers one free-tier refresh plus one readiness evaluation from the active source', async () => { + const requestGateway = await mountedStatusSnapshot() + + expect(handleLifecycleEvent(setupReadyContext(true))).toBe(true) + await flushAsync() + + expect(callsTo(requestGateway, 'free_tier.status')).toHaveLength(1) + expect(callsTo(requestGateway, 'setup.runtime_check')).toHaveLength(1) + expect(callsTo(requestGateway, 'setup.status')).toHaveLength(1) + // The push is a readiness seam, not a status tick. + expect(getStatus).not.toHaveBeenCalled() + }) + + it('claims but ignores setup.ready from a non-active source', async () => { + const requestGateway = await mountedStatusSnapshot() + + expect(handleLifecycleEvent(setupReadyContext(false))).toBe(true) + await flushAsync() + + expect(requestGateway).not.toHaveBeenCalled() + expect($setupReadyTick.get()).toBe(0) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts index 2395498d94..16633910dc 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts @@ -6,6 +6,7 @@ import { notifyPetChanged, notifyPlatformsChanged, notifySessionsChanged, + notifySetupReady, type PetChangeMeta, setChangeEventsAvailable } from '@/store/live-sync' @@ -17,7 +18,7 @@ import { ingestBackendSkin } from '@/themes/backend-sync' import type { GatewayEventContext } from './types' -/** gateway.ready / skin.changed / change-watcher broadcasts / session.reclaimed. */ +/** gateway.ready / setup.ready / skin.changed / change-watcher broadcasts / session.reclaimed. */ export function handleLifecycleEvent(ctx: GatewayEventContext): boolean { const { deps, event, payload, fromActiveSource } = ctx @@ -32,6 +33,20 @@ export function handleLifecycleEvent(ctx: GatewayEventContext): boolean { return true } + if (event.type === 'setup.ready') { + // The boot bootstrap (hermes_cli/free_tier_bootstrap.py) resolved the + // free-tier identity and the inference route, and broadcast once. The + // payload is only a hint — the status snapshot re-reads `setup.status` / + // `setup.runtime_check` / `free_tier.status` through its own scoped + // requester so the chip, strip and onboarding react now rather than on + // the next ambient tick. Only the active source's boot matters here. + if (fromActiveSource()) { + notifySetupReady() + } + + return true + } + if (event.type === 'skin.changed') { // A runtime skin switch (Hermes activating an authored skin, or `/skin` // on another surface). Only the active source+profile's change repaints. diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/message-stream.ts index 97fb4a65a5..d7aa70ac73 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/message-stream.ts @@ -1,6 +1,7 @@ import type { BillingBlock } from '@hermes/shared' import { burstVibeHearts } from '@/components/chat/vibe-hearts' +import { reportFirstBuildTurnComplete } from '@/components/onboarding-chat/first-build' import { translateNow } from '@/i18n' import { coerceGatewayText, coerceThinkingText } from '@/lib/chat-runtime' import { playCompletionSound } from '@/lib/completion-sound' @@ -351,6 +352,10 @@ export function handleMessageStreamEvent(ctx: GatewayEventContext): boolean { completeAssistantMessage(sessionId, finalText, payload?.response_previewed, failure, occurredAt) + // Onboarding's first build: between turns is the only moment Setup may + // put a check-in into that session (no-op everywhere else). + reportFirstBuildTurnComplete(sessionId, finalText) + // Structured billing wall forwarded by the gateway (out of credits / // payment required) — cache it + raise a billing-specific toast. if (payload?.billing) { diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/tools.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/tools.ts index 21cd272de1..546e168c08 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/tools.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/tools.ts @@ -1,3 +1,4 @@ +import { reportFirstBuildToolComplete } from '@/components/onboarding-chat/first-build' import { invalidateSlashCompletions } from '@/lib/slash-completion-cache' import { refreshBackgroundProcesses } from '@/store/composer-status' import { flashPetActivity, setPetActivity } from '@/store/pet' @@ -68,6 +69,9 @@ export function handleToolEvent(ctx: GatewayEventContext): boolean { if (sessionId) { flushQueuedDeltas(sessionId) upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type, occurredAt) + // Onboarding's first build paces its check-ins off real work done + // (no-op in every other session). + reportFirstBuildToolComplete(sessionId) if (isActiveEvent) { setPetActivity({ toolRunning: false }) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/create-overrides.ts b/apps/desktop/src/app/session/hooks/use-session-actions/create-overrides.ts new file mode 100644 index 0000000000..9d49ea7c41 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-actions/create-overrides.ts @@ -0,0 +1,48 @@ +/** + * Per-create overrides, translated to `session.create` params and folded over + * the ones `desktopSessionCreateParams` derived from the visible selection. + * + * Reasoning effort rides alone here, with no model pin: the guided onboarding + * chat wants `minimal` on whatever model the backend already resolved for the + * profile. A model override would be a different kind of thing — the + * composer's model and provider are a PAIR, so overriding one without the + * other mints a session pointing a provider at a model it does not serve — and + * no caller needs one. + */ +export interface SessionCreateOverrides { + reasoningEffort?: string + title?: string +} + +export interface SessionSeedMessage { + content: string + display_kind?: 'hidden' + role: 'assistant' | 'user' +} + +export interface SessionCreateOverrideParams { + messages?: SessionSeedMessage[] + reasoning_effort?: string + title?: string +} + +export function sessionCreateOverrideParams( + overrides: SessionCreateOverrides | undefined, + seedMessages?: SessionSeedMessage[] +): SessionCreateOverrideParams { + const params: SessionCreateOverrideParams = {} + + if (overrides?.title) { + params.title = overrides.title + } + + if (overrides?.reasoningEffort) { + params.reasoning_effort = overrides.reasoningEffort + } + + if (seedMessages?.length) { + params.messages = seedMessages + } + + return params +} diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 1766b1765b..a57fd538dc 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -111,6 +111,7 @@ import { $sessionTiles, closeSessionTile, dropSessionState, + focusOpenSession, holdSessionOwnerUntilForeground, openSessionTile, patchSessionTile, @@ -137,6 +138,7 @@ import type { ClientSessionState, SidebarNavItem } from '../../../types' import { sessionContextDrift } from '../session-context-drift' import { singleFlightSessionResume } from '../use-prompt-actions/single-flight-resume' +import { sessionCreateOverrideParams, type SessionCreateOverrides, type SessionSeedMessage } from './create-overrides' import { pendingClarifyToolPayload, restorePendingClarifyFromSnapshot } from './restore-pending-clarify' import { createPersistedDisplayTranscriptProvenance, @@ -531,7 +533,16 @@ export function useSessionActions({ ) const createBackendSessionForSend = useCallback( - async (preview: string | null = null): Promise => { + async ( + preview: string | null = null, + seedMessages?: SessionSeedMessage[], + // Create the session titled or at a pinned reasoning effort (guided + // onboarding mints its welcome chat this way). The owning profile is NOT + // an override — point $newChatProfile at it first (selectProfile-style) + // so the create lands on that profile's own backend and every later + // ambient RPC follows. + createOverrides?: SessionCreateOverrides + ): Promise => { const startingStoredSessionId = selectedStoredSessionIdRef.current const startingRouteToken = getRouteToken() @@ -561,7 +572,11 @@ export function useSessionActions({ // reduce the owner to a bare profile name that later RPCs dial on a // different socket than the one that minted the runtime. const capturedRoute = resolveNewChatOwnerRoute() - const params = await desktopSessionCreateParams(cwd, capturedRoute) + + const params = { + ...(await desktopSessionCreateParams(cwd, capturedRoute)), + ...sessionCreateOverrideParams(createOverrides, seedMessages) + } // Lease the owner socket for the whole create → owner-publication // sequence (#93602 primitive). The per-request lease inside @@ -836,7 +851,7 @@ export function useSessionActions({ setWorkspaceCwdOwner(stored) } - revealTreePane(`session-tile:${stored}`) + focusOpenSession(stored, workspaceScope) if (listed) { broadcastSessionsChanged() diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index c3a38d4638..cafeed77a4 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -1,3 +1,4 @@ +import { resolveSessionRpcOwner } from '@/app/contrib/wiring-routing' import { textWithoutReferenceLines } from '@/components/assistant-ui/reference-kinds' import { getSession } from '@/hermes' import { assistantTextPart, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages' @@ -15,6 +16,9 @@ import { $messagingSessions, $sessions, commitWorkspaceCwdForSelectedSession, + getSessionOwnerHint, + knownSessionOwner, + ownerLookupSessionRows, releaseWorkspaceCwdOwner, sessionMatchesStoredId, setCronSessions, @@ -34,6 +38,7 @@ import { setYoloActive } from '@/store/session' import type { SessionProfileRoute } from '@/store/session-request-router' +import { sessionTileOwnerRoute } from '@/store/session-states' // Re-exported for the many session-actions/tile call sites that already import // it from here; the canonical definition lives in @/store/session. @@ -1570,6 +1575,17 @@ export async function resolveSessionOwner(storedSessionId: null | string): Promi return undefined } + const owner = resolveSessionRpcOwner({ + routingSessionId: storedSessionId, + tileOwnerRoute: sessionTileOwnerRoute, + sessionOwnerHint: getSessionOwnerHint, + sessionRowOwner: id => knownSessionOwner(ownerLookupSessionRows(), id) + }) + + if (owner) { + return owner + } + const row = await resolveStoredSession(storedSessionId) return sessionOwnerRouteFromRow(row) ?? (row?.profile?.trim() || undefined) diff --git a/apps/desktop/src/app/settings/about-settings.tsx b/apps/desktop/src/app/settings/about-settings.tsx index f27c0be8f9..81f797078c 100644 --- a/apps/desktop/src/app/settings/about-settings.tsx +++ b/apps/desktop/src/app/settings/about-settings.tsx @@ -1,53 +1,232 @@ import { useStore } from '@nanostores/react' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' -import { UpdateStatusCard, VersionHero } from '@/components/update-status' -import { VersionDetails } from '@/components/version-details' -import { useI18n } from '@/i18n' -import { RefreshCw } from '@/lib/icons' -import { $connection } from '@/store/session' -import { $desktopVersion, checkBackendUpdates, refreshDesktopVersion } from '@/store/updates' +import { BrandMark } from '@/components/brand-mark' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { type Translations, useI18n } from '@/i18n' +import { AlertTriangle, CheckCircle2, ExternalLink, Loader2, RefreshCw } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { + $desktopVersion, + $updateApply, + $updateChecking, + $updateStatus, + checkUpdates, + openUpdatesWindow, + refreshDesktopVersion, + startActiveUpdate +} from '@/store/updates' -import { SectionHeading, SettingsContent } from './primitives' +import { ListRow, SectionHeading, SettingsContent } from './primitives' import { UninstallSection } from './uninstall-section' +const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases' +const INSTALLER_URL = 'https://hermes-agent.nousresearch.com/' + +function relativeTime(ms: number | undefined, a: Translations['settings']['about']) { + if (!ms) { + return a.never + } + + const diff = Date.now() - ms + + if (diff < 60_000) { + return a.justNow + } + + if (diff < 3_600_000) { + return a.minAgo(Math.round(diff / 60_000)) + } + + if (diff < 86_400_000) { + return a.hoursAgo(Math.round(diff / 3_600_000)) + } + + return a.daysAgo(Math.round(diff / 86_400_000)) +} + export function AboutSettings() { const { t } = useI18n() - const u = t.updates + const a = t.settings.about const version = useStore($desktopVersion) - const connection = useStore($connection) - const remote = connection?.mode === 'remote' + const status = useStore($updateStatus) + const apply = useStore($updateApply) + const checking = useStore($updateChecking) + const [justChecked, setJustChecked] = useState(false) // The version atom is loaded once at app boot, which makes About show a // stale number after a self-update (the running binary is current, the // displayed string is not). Re-read on mount so opening About always - // reflects the running build. In remote mode also seed the backend update - // state so the backend card opens answered instead of on "never checked". + // reflects the running build. useEffect(() => { void refreshDesktopVersion() + }, []) - if (remote) { - void checkBackendUpdates() - } - }, [connection, remote]) + const behind = status?.behind ?? 0 + // behind is null when the exact count is unknowable (shallow clone): the + // backend flags that case via updateAvailable instead of a number. + const updateAvailable = behind > 0 || Boolean(status?.updateAvailable) + const supported = status?.supported !== false + const applying = apply.applying || apply.stage === 'restart' + + const handleCheck = async () => { + setJustChecked(false) + const next = await checkUpdates({ force: true }) + setJustChecked(Boolean(next)) + } + + let statusLine: string + let statusTone: 'idle' | 'available' | 'error' = 'idle' + + if (!supported) { + statusLine = status?.message ?? a.cantUpdate + statusTone = 'error' + } else if (status?.error) { + statusLine = status.message ? `${a.cantReach} ${status.message}` : a.cantReach + statusTone = 'error' + } else if (applying) { + statusLine = a.installing + statusTone = 'available' + } else if (updateAvailable) { + statusLine = behind > 0 ? a.updateReady(behind) : a.updateReadyUnknown + statusTone = 'available' + } else if (status) { + statusLine = a.onLatest + } else { + statusLine = a.tapCheck + } return ( - +
+ +
+

{a.heading}

+

+ {version?.appVersion ? a.version(version.appVersion) : a.versionUnavailable} +

+
+ {(version?.bundleOutOfSync || version?.bundleSwapPending) && ( +
+
+ +
+ {version?.bundleSwapPending ? ( + // The updated app is already on disk — the updater swapped it + // under this running process — so a restart loads it. Saying + // "App build out of date" here would repeat the contradiction + // this banner is meant to resolve: the Updates card below + // already reports the runtime as current. + <> +

{a.bundleSwapPending}

+

{a.bundleSwapPendingDesc}

+ + + ) : ( + <> +

{a.bundleOutOfSync}

+

{a.bundleOutOfSyncDesc}

+ + + )} +
+
+
+ )} +
- + -
- - {/* The desktop client and a remote backend update independently — in - remote mode the statusbar shows both pills, so About shows both - states too. The backend has no GitHub release notes link of its - own; the client card already carries it. */} - {remote && } +
+
+ {statusTone === 'available' ? ( + + ) : statusTone === 'error' ? null : ( + + )} +
+

{statusLine}

+

+ {a.lastChecked(relativeTime(status?.fetchedAt, a))} + {justChecked && !checking ? a.justNowSuffix : ''} +

+
+
+ +
+ + + {updateAvailable && supported && !applying && ( + <> + + + + )} + + +
- {version && } +
diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index ca16163903..c9c6512eee 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -24,7 +24,12 @@ import { $reactionsEnabled, setReactionsEnabled } from '@/store/reactions-enable import { $reasoningCollapsedByDefault, setReasoningCollapsedByDefault } from '@/store/reasoning-disclosure' import { $sessionListDensity, type SessionListDensity, setSessionListDensity } from '@/store/session-list-density' import { $tabStripDefault, setTabStripDefault, type TabStripDefault } from '@/store/tabstrip-prefs' -import { $retiredTips, $tipsEnabled, resetTips, setTipsEnabled } from '@/store/tips' +import { $spentTipCount, $tipsEnabled, resetTips, setTipsEnabled } from '@/store/tips' +import { + $titlebarAppActionsSide, + setTitlebarAppActionsSide, + type TitlebarAppActionsSide +} from '@/store/titlebar-app-actions' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' import { $toursEnabled, setToursEnabled } from '@/store/tours' import { @@ -397,6 +402,7 @@ export function AppearanceSettings() { const reasoningCollapsedByDefault = useStore($reasoningCollapsedByDefault) const sessionListDensity = useStore($sessionListDensity) const tabStripDefault = useStore($tabStripDefault) + const titlebarAppActionsSide = useStore($titlebarAppActionsSide) const zoomPercent = useStore($zoomPercent) const embedMode = useStore($embedMode) const embedAllowed = useStore($embedAllowed) @@ -407,7 +413,7 @@ export function AppearanceSettings() { const reactionsEnabled = useStore($reactionsEnabled) const tipsEnabled = useStore($tipsEnabled) const toursEnabled = useStore($toursEnabled) - const retiredTips = useStore($retiredTips) + const spentTips = useStore($spentTipCount) const vibeHeartsEnabled = useStore($vibeHeartsEnabled) const backdrop = useStore($backdrop) const introSplash = useStore($introSplash) @@ -486,6 +492,11 @@ export function AppearanceSettings() { { id: 'never', label: a.tabStripNever } ] as const satisfies readonly { id: TabStripDefault; label: string }[] + const appActionsOptions = [ + { id: 'right', label: a.appActionsRight }, + { id: 'left', label: a.appActionsLeft } + ] as const satisfies readonly { id: TitlebarAppActionsSide; label: string }[] + const embedOptions = [ { id: 'ask', label: a.embedsAsk }, { id: 'always', label: a.embedsAlways }, @@ -661,6 +672,22 @@ export function AppearanceSettings() { title={a.tabStripTitle} /> + { + triggerHaptic('selection') + setTitlebarAppActionsSide(id) + }} + options={appActionsOptions} + value={titlebarAppActionsSide} + /> + } + description={a.appActionsDesc} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.appActions)} + title={a.appActionsTitle} + /> + {/* Linux has neither half of this setting (see TRANSLUCENCY_SUPPORTED), so the row is absent there rather than offering a dead lever. */} {TRANSLUCENCY_SUPPORTED && ( @@ -842,9 +869,9 @@ export function AppearanceSettings() { ]} value={tipsEnabled ? 'on' : 'off'} /> - {/* The ✕ on a tip is permanent, so this is the only way back. - It appears once there is something to bring back. */} - {retiredTips.length > 0 && ( + {/* A tip shows once (✕ or timer), so this is the only way to a + second lap. It appears once there is something to bring back. */} + {spentTips > 0 && ( )}
diff --git a/apps/desktop/src/app/settings/billing/current-plan-card.tsx b/apps/desktop/src/app/settings/billing/current-plan-card.tsx index c9118e574b..ce879dfd7c 100644 --- a/apps/desktop/src/app/settings/billing/current-plan-card.tsx +++ b/apps/desktop/src/app/settings/billing/current-plan-card.tsx @@ -33,7 +33,7 @@ export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void
{plan.action && ( - )} diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx index 040d37b3b4..37674e01cc 100644 --- a/apps/desktop/src/app/settings/billing/index.tsx +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -87,18 +87,25 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) {
{notice.message}
- {notice.action && ( - - )} + {notice.action && + (notice.action.onSelect ? ( + // In-app action (free-tier sign-in): a plain button, no external-link + // glyph — nothing leaves the app. + + ) : ( + + ))}
) } @@ -517,6 +524,11 @@ function BillingSettingsContent({ {view.plan && ( setSubView('plans')} plan={view.plan} /> + {view.planFootnote && ( +
+ {view.planFootnote} +
+ )}
)} diff --git a/apps/desktop/src/app/settings/billing/types.ts b/apps/desktop/src/app/settings/billing/types.ts index 78d888fe73..3d25389eab 100644 --- a/apps/desktop/src/app/settings/billing/types.ts +++ b/apps/desktop/src/app/settings/billing/types.ts @@ -7,8 +7,8 @@ import type { BillingMonthlyCap, BillingMutationResponse, BillingRefusalCode, - BillingStateResponse, ChargeFailureReason, + BillingStateResponse as SharedBillingStateResponse, SubscriptionPreviewResponse, SubscriptionStateResponse, SubscriptionTierOption, @@ -16,6 +16,18 @@ import type { UsageModelData } from '@hermes/shared/billing' +/** + * The gateway's `billing.state` payload as THIS app reads it: the shared shape + * plus the free-tier fields newer gateways add. When `free_tier` is true there + * is no account behind the call — `logged_in` is false and there is no balance, + * card or usage — so the free-tier branch must be read before the logged-out + * one. Both fields are absent on older gateways. + */ +export type BillingStateResponse = SharedBillingStateResponse & { + free_tier?: boolean + free_tier_model?: null | string +} + export type { BillingAutoReload, BillingCardInfo, @@ -25,8 +37,8 @@ export type { BillingMonthlyCap, BillingMutationResponse, BillingRefusalCode, - BillingStateResponse, ChargeFailureReason, + SharedBillingStateResponse, SubscriptionPreviewResponse, SubscriptionStateResponse, SubscriptionTierOption, diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts index a132e057db..6a9c2349cb 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts @@ -171,6 +171,32 @@ describe('deriveBillingView', () => { expect(view.usageRows).toEqual([]) }) + it('derives the free-tier view before the logged-out one, with nothing to pay', () => { + // A free-tier install is logged_in:false, so this branch must win — otherwise + // the generic "connect your account" notice sends the user to the portal. + const view = deriveBillingView( + okBilling({ ...loggedOutBillingState, free_tier: true, free_tier_model: 'nous/welcome' }), + okSubscription(loggedOutSubscriptionState) + ) + + expect(view.status).toBe('free_tier') + expect(view.notice).toMatchObject({ title: "You're on the Nous free tier", tone: 'info' }) + expect(view.notice?.action?.label).toBe('Sign in') + expect(view.summary).toEqual([ + { label: 'Plan', value: 'Free tier' }, + { label: 'Model', value: 'nous/welcome' }, + { label: 'Connectors', tone: 'primary', value: 'Included' } + ]) + expect(view.plan).toMatchObject({ tierName: 'Nous · free tier' }) + expect(view.plan?.action).toBeUndefined() + expect(view.planFootnote).toContain('no balance and nothing to pay') + expect(view.paymentRow).toBeUndefined() + expect(view.topupRow).toBeUndefined() + expect(view.refillRow).toBeUndefined() + expect(view.tiers).toEqual([]) + expect(view.usageRows).toEqual([]) + }) + it('derives a refusal notice when billing.state is unavailable', () => { const view = deriveBillingView(endpointUnavailableBilling, okSubscription(todaySubscriptionState)) diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts index 3ccc8693ac..288c31ecf7 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -1,6 +1,8 @@ import { useQuery } from '@tanstack/react-query' import { fmtDate } from '@/lib/time' +import { FREE_TIER_MODEL } from '@/store/free-tier' +import { openFreeTierSignIn } from '@/store/free-tier-sign-in' import type { BillingRefusal, BillingResult } from './api' import { useBillingApi } from './api' @@ -30,16 +32,17 @@ const BILLING_QUERY_OPTIONS = { } as const export interface BillingSummaryItemView { - label: 'Auto-refill' | 'Balance' | 'Plan' + label: 'Auto-refill' | 'Balance' | 'Connectors' | 'Model' | 'Plan' tone?: 'muted' | 'primary' value: string } export interface BillingNoticeView { - action?: { - label: string - url: string - } + /** Either an external portal hop (`url`) or an in-app action (`onSelect`) — + * a discriminated pair, so a consumer never has to guard for "both" or + * "neither". */ + action?: + { label: string; onSelect: () => void; url?: undefined } | { label: string; onSelect?: undefined; url: string } message: string title: string /** `warn` = an actionable blocker (e.g. no card); `info` = neutral guidance. */ @@ -96,7 +99,18 @@ export type BillingPlanCardView = { pending?: PendingPlanTransition price?: string tierName: string -} & ({ action: { label: string }; link?: undefined } | { action?: undefined; link: { label: string; url: string } }) +} & ( + | { + // `onSelect` overrides the card's default "open the plans grid" action — + // the free-tier card signs in instead. Absent = the plans grid. + action: { label: string; onSelect?: () => void } + link?: undefined + } + | { action?: undefined; link: { label: string; url: string } } + // The free-tier card is the "what you get" text alone: the page's one Sign in lives on the + // notice above it, so the card carries neither an action nor a link. + | { action?: undefined; link?: undefined } +) interface BillingPlanTierBase { creditsDisplay?: string @@ -137,9 +151,11 @@ export interface BillingView { paymentRow?: BillingAccountRowView /** Current-plan card (Plan section). Absent until billing.state resolves. */ plan?: BillingPlanCardView + /** Small print under the Plan section. Only the free-tier view sets it. */ + planFootnote?: string /** Automatic-refill section row. */ refillRow?: BillingAccountRowView - status: 'loading' | 'logged_out' | 'normal' | 'refusal' + status: 'free_tier' | 'loading' | 'logged_out' | 'normal' | 'refusal' summary: BillingSummaryItemView[] /** Live tier catalog for the plans sub-view (empty when unavailable). */ tiers: BillingPlanTierView[] @@ -196,6 +212,13 @@ export function deriveBillingView( const billing = stateResult.data const subscription = subscriptionResult?.ok ? subscriptionResult.data : null + // Read BEFORE the logged-out branch: a free-tier install has no account, so + // `logged_in` is false and the generic "connect your account" notice would + // otherwise win and tell the user to go to the portal. + if (billing.free_tier) { + return freeTierView(billing) + } + if (!billing.logged_in || subscription?.logged_in === false) { return { notice: { @@ -297,6 +320,38 @@ function emptySummary(): BillingSummaryItemView[] { ] } +/** + * The no-account state: nothing is owed, nothing is owned, and every money + * control would be a lie. So the page collapses to one notice, a three-item + * summary, and a single plan card whose only action is signing in — no payment, + * credits, auto-refill or usage sections at all. + */ +function freeTierView(billing: BillingStateResponse): BillingView { + return { + notice: { + action: { label: 'Sign in', onSelect: openFreeTierSignIn }, + message: 'Sign in with a Nous account to unlock more models and tools.', + title: "You're on the Nous free tier", + tone: 'info' + }, + plan: { + caption: + 'Runs on nous/welcome with connectors included. Signing in keeps your connectors and adds the tools that need an account and every other model.', + tierName: 'Nous · free tier' + }, + planFootnote: + 'The free tier has no balance and nothing to pay. Payment and usage appear when you sign in with a Nous account.', + status: 'free_tier', + summary: [ + { label: 'Plan', value: 'Free tier' }, + { label: 'Model', value: billing.free_tier_model ?? FREE_TIER_MODEL }, + { label: 'Connectors', tone: 'primary', value: 'Included' } + ], + tiers: [], + usageRows: [] + } +} + function refusalNotice(refusal: BillingRefusal): BillingNoticeView { const resolved = resolveRefusal(refusal) const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined diff --git a/apps/desktop/src/app/settings/combobox-input.tsx b/apps/desktop/src/app/settings/combobox-input.tsx index 63dd426068..b092bdade5 100644 --- a/apps/desktop/src/app/settings/combobox-input.tsx +++ b/apps/desktop/src/app/settings/combobox-input.tsx @@ -81,7 +81,7 @@ export function ComboboxInput({ e.preventDefault()} > diff --git a/apps/desktop/src/app/settings/config-settings.test.tsx b/apps/desktop/src/app/settings/config-settings.test.tsx index a6cb42ec55..3b388d1cd3 100644 --- a/apps/desktop/src/app/settings/config-settings.test.tsx +++ b/apps/desktop/src/app/settings/config-settings.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { cleanup, render, screen } from '@testing-library/react' import { atom } from 'nanostores' import { createRef } from 'react' import { MemoryRouter } from 'react-router' @@ -78,14 +78,14 @@ describe('ConfigSettings autosave', () => { toggle.click() await vi.advanceTimersByTimeAsync(700) - await waitFor(() => expect(saveHermesConfig).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(saveHermesConfig).toHaveBeenCalledTimes(1)) expect(saveHermesConfig.mock.calls[0][0]).toEqual({ checkpoints: { enabled: true } }) // Revert: flip it back to its original value and let autosave fire again. toggle.click() await vi.advanceTimersByTimeAsync(700) - await waitFor(() => expect(saveHermesConfig).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(saveHermesConfig).toHaveBeenCalledTimes(2)) // Must still explicitly send the reverted value — diffing against the // never-advanced page-load baseline would produce an empty patch here // (the field is back to its original value) and leave disk stuck at diff --git a/apps/desktop/src/app/settings/connections-registry.test.tsx b/apps/desktop/src/app/settings/connections-registry.test.tsx index e6cf553fbc..6a5b4007f7 100644 --- a/apps/desktop/src/app/settings/connections-registry.test.tsx +++ b/apps/desktop/src/app/settings/connections-registry.test.tsx @@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DesktopConnectionsRegistry } from '@/global' +import { _resetFleetRosterForTests, refreshFleetRoster } from '@/store/fleet-roster' import { $connection } from '@/store/session' import { @@ -68,6 +69,21 @@ afterEach(() => { }) describe('ConnectionsRegistrySection', () => { + it('refreshes a cached roster immediately after a successful connection test', async () => { + _resetFleetRosterForTests() + const getAgentRoster = vi.fn().mockResolvedValue({ agents: [], sources: [] }) + Object.assign(window.hermesDesktop!, { getAgentRoster }) + + try { + await refreshFleetRoster() + render() + await screen.findByText('Homelab') + fireEvent.click(screen.getAllByRole('button', { name: /^test$/i })[0]) + await waitFor(() => expect(getAgentRoster).toHaveBeenCalledTimes(2)) + } finally { + _resetFleetRosterForTests() + } + }) it('distinguishes the current connection from the registry primary', async () => { render() diff --git a/apps/desktop/src/app/settings/connections-registry.tsx b/apps/desktop/src/app/settings/connections-registry.tsx index 765f9ecd1b..a6af04357c 100644 --- a/apps/desktop/src/app/settings/connections-registry.tsx +++ b/apps/desktop/src/app/settings/connections-registry.tsx @@ -35,6 +35,7 @@ import { } from '@/lib/icons' import { coerceRemoteUrlScheme } from '@/lib/remote-url' import { $activeConnectionId, setConnectionsRegistry } from '@/store/connections' +import { refreshFleetRoster } from '@/store/fleet-roster' import { notify, notifyError } from '@/store/notifications' import { EmptyState, ListRow, Pill, SectionHeading, ToggleRow } from './primitives' @@ -554,6 +555,9 @@ export function ConnectionsRegistrySection() { if (reachable) { notify({ title: conn.label, message: s.testOk }) + // A successful Test may have warmed a cold OAuth session that the + // roster missed; explicit recovery should bypass its cache window. + void refreshFleetRoster({ force: true }) } else { notifyError(new Error(result.error || conn.label), s.testFailed) } diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index c019f82b43..3d2eb0b21c 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -18,10 +18,10 @@ import { Info, Keyboard, KeyRound, - Package, RefreshCw, Search, Settings2, + ShieldLock, Upload, Wrench, Zap @@ -31,6 +31,7 @@ import { typeToFocusChar } from '@/lib/keybinds/composer-focus-keys' import { cn } from '@/lib/utils' import { $commandPaletteOpen, openCommandPalettePage } from '@/store/command-palette' import { confirm } from '@/store/confirm' +import { $activeConnectionId } from '@/store/connections' import { bindingsFor } from '@/store/keybinds' import { $localModelsEnabled } from '@/store/local-models-flag' import { notifyError } from '@/store/notifications' @@ -40,7 +41,6 @@ import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { OverlayIconButton } from '../overlays/overlay-chrome' import { OverlayMain, OverlayNav, type OverlayNavGroup, OverlaySplitLayout } from '../overlays/overlay-split-layout' import { OverlayView } from '../overlays/overlay-view' -import { SKILLS_ROUTE } from '../routes' import { AboutSettings } from './about-settings' import { AppearanceSettings } from './appearance-settings' @@ -50,11 +50,12 @@ import { SECTIONS } from './constants' import { GatewaySettings } from './gateway-settings' import { KeybindSettings } from './keybind-settings' import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings' +import { movedSettingsTabRedirect } from './moved-tabs' import { NotificationsSettings } from './notifications-settings' -import { PluginsSettings } from './plugins-settings' import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings' import { SessionsSettings } from './sessions-settings' import type { SettingsPageProps, SettingsView as SettingsViewId } from './types' +import { vaultOwnerKey, VaultSettings } from './vault-settings' const SETTINGS_VIEWS: readonly SettingsViewId[] = [ ...SECTIONS.map(s => `config:${s.id}` as SettingsViewId), @@ -65,30 +66,28 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ 'connections', 'keybinds', 'keys', + 'vault', 'notifications', 'billing', - 'plugins', 'sessions', 'about' ] export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) { const scopeProfile = useStore($settingsScopeProfile) + const activeConnectionId = useStore($activeConnectionId) const { t } = useI18n() const navigate = useNavigate() const { hash, pathname, search } = useLocation() - // MCP moved out of Settings into Capabilities (/skills?tab=mcp). Keep old - // `/settings?tab=mcp` deep links working — `useRouteEnumParam` would silently - // coerce the unknown tab to the default view otherwise. Preserve `server=` so - // an old bookmark still lands on (and highlights) the selected server. + // MCP and Plugins moved out of Settings into Capabilities. Keep old + // `/settings?tab=mcp|plugins` deep links working — `useRouteEnumParam` would + // silently coerce the unknown tab to the default view otherwise. useEffect(() => { - const params = new URLSearchParams(search) + const redirect = movedSettingsTabRedirect(search) - if (params.get('tab') === 'mcp') { - const server = params.get('server') - const suffix = server ? `&server=${encodeURIComponent(server)}` : '' - navigate(`${SKILLS_ROUTE}?tab=mcp${suffix}`, { replace: true }) + if (redirect) { + navigate(redirect, { replace: true }) } }, [navigate, search]) @@ -173,16 +172,33 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set const navGroups: OverlayNavGroup[] = useMemo( () => [ - ...SECTIONS.map(s => { + ...SECTIONS.flatMap(s => { const view = `config:${s.id}` as SettingsViewId - return { + const entry = { active: activeView === view, icon: s.icon, id: view, label: t.settings.sections[s.id] ?? s.label, onSelect: () => setActiveView(view) } + + // Credential Vault lives beside the Browser section: it feeds the + // browser's model-blind vault fill, so the two are one mental unit. + if (s.id === 'browser') { + return [ + entry, + { + active: activeView === 'vault', + icon: ShieldLock, + id: 'vault', + label: t.settings.nav.vault, + onSelect: () => setActiveView('vault') + } + ] + } + + return [entry] }), { active: activeView === 'notifications', @@ -281,13 +297,6 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set label: t.settings.nav.apiKeys, onSelect: () => setActiveView('keys') }, - { - active: activeView === 'plugins', - icon: Package, - id: 'plugins', - label: t.settings.nav.plugins, - onSelect: () => setActiveView('plugins') - }, { active: activeView === 'sessions', icon: Archive, @@ -334,8 +343,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set // Fake search pill riding the card's top edge, dead-center and half off it. // Clicking (or just typing) opens the ⌘K palette scoped to settings; while // the palette is up the pill hands over to it — grows slightly and fades, - // then fades back when the palette closes. It renders as chrome, not an - // input — no border, recessed fill, live ⌘K hint. + // then fades back when the palette closes. It sits outside the raised card, + // so it needs its own opaque glass surface to mask the content underneath. const searchCombo = bindingsFor('nav.commandPalette')[0] const paletteOpen = useStore($commandPaletteOpen) @@ -345,6 +354,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set 'flex h-(--titlebar-control-height) items-center gap-1.5 rounded-full border border-(--ui-stroke-secondary) bg-(--ui-chat-surface-background) px-2.5 text-(--ui-text-tertiary) shadow-sm transition-all duration-200 ease-out hover:text-foreground motion-reduce:transition-none', paletteOpen && 'pointer-events-none scale-110 opacity-0' )} + data-glass-opaque="" onClick={() => { triggerHaptic('open') openCommandPalettePage('settings') @@ -422,8 +432,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set ) : activeView === 'billing' ? ( - ) : activeView === 'plugins' ? ( - + ) : activeView === 'vault' ? ( + ) : ( ) diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index c965910168..bace810813 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -421,7 +421,13 @@ describe('ModelSettings', () => { base_url: 'http://byron.local:11434/v1', local_endpoint: true }, - { task: 'vision', provider: 'openai', model: 'gpt-4o-mini', base_url: 'https://api.example.com/v1', local_endpoint: false } + { + task: 'vision', + provider: 'openai', + model: 'gpt-4o-mini', + base_url: 'https://api.example.com/v1', + local_endpoint: false + } ] }) diff --git a/apps/desktop/src/app/settings/moved-tabs.test.ts b/apps/desktop/src/app/settings/moved-tabs.test.ts new file mode 100644 index 0000000000..8f2c7ee6c2 --- /dev/null +++ b/apps/desktop/src/app/settings/moved-tabs.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' + +import { movedSettingsTabRedirect } from './moved-tabs' + +describe('movedSettingsTabRedirect', () => { + it('sends the retired Settings plugin/MCP tabs to Capabilities, keeping the row selector', () => { + expect(movedSettingsTabRedirect('?tab=plugins')).toBe('/skills?tab=plugins') + expect(movedSettingsTabRedirect('?tab=plugins&plugin=demo%2Fplugin')).toBe( + '/skills?tab=plugins&plugin=demo%2Fplugin' + ) + expect(movedSettingsTabRedirect('?tab=mcp&server=github')).toBe('/skills?tab=mcp&server=github') + }) + + it('leaves live Settings tabs alone', () => { + expect(movedSettingsTabRedirect('?tab=providers&pview=keys')).toBeNull() + expect(movedSettingsTabRedirect('')).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/settings/moved-tabs.ts b/apps/desktop/src/app/settings/moved-tabs.ts new file mode 100644 index 0000000000..f9ba7b6cf2 --- /dev/null +++ b/apps/desktop/src/app/settings/moved-tabs.ts @@ -0,0 +1,23 @@ +import { SKILLS_ROUTE } from '../routes' + +// Settings tabs that now live in Capabilities → the row-selector param each +// carries (`?server=` for MCP, `?plugin=` for Plugins). Old bookmarks and +// palette links keep resolving to the same row on the new page. +const MOVED_TO_CAPABILITIES: Record = { mcp: 'server', plugins: 'plugin' } + +/** The Capabilities URL an old `/settings?tab=` query should land on, + * or null when the tab still belongs to Settings. */ +export function movedSettingsTabRedirect(search: string): null | string { + const params = new URLSearchParams(search) + const tab = params.get('tab') + const rowParam = tab ? MOVED_TO_CAPABILITIES[tab] : undefined + + if (!tab || rowParam === undefined) { + return null + } + + const row = params.get(rowParam) + const suffix = row ? `&${rowParam}=${encodeURIComponent(row)}` : '' + + return `${SKILLS_ROUTE}?tab=${tab}${suffix}` +} diff --git a/apps/desktop/src/app/settings/plugin-install-modal.test.tsx b/apps/desktop/src/app/settings/plugin-install-modal.test.tsx index 246b8b8308..187d0b3574 100644 --- a/apps/desktop/src/app/settings/plugin-install-modal.test.tsx +++ b/apps/desktop/src/app/settings/plugin-install-modal.test.tsx @@ -3,7 +3,13 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import { MemoryRouter } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { requestGateway } = vi.hoisted(() => ({ requestGateway: vi.fn() })) +// The host tab lists installed plugins on mount; only an `install` action counts as installing. +const { requestGateway } = vi.hoisted(() => ({ + requestGateway: vi.fn(async (_method: string, _params?: Record): Promise => ({ + plugins: [] + })) +})) + vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ useGatewayRequest: () => ({ requestGateway }) })) @@ -21,17 +27,18 @@ import { import { $activeGatewayProfile } from '@/store/profile' import { $connection, $gatewayState } from '@/store/session' +import { PluginsTab } from '../skills/plugins-tab' + import { PluginInstallModal } from './plugin-install-modal' -import { PluginsSettings } from './plugins-settings' const probePluginRepo = vi.fn() const installDesktopPlugin = vi.fn() const renderFlow = () => render( - + - + @@ -78,8 +85,16 @@ describe('Install from Git entry flow', () => { : 'Installs into the default backend (~/.hermes/plugins/)' ) ).toBeTruthy() - expect(screen.getByText("Installs into this app's local desktop-plugins folder")).toBeTruthy() - expect(requestGateway).not.toHaveBeenCalled() + // Local backend: the desktop half is copied out of the installed package + // (one source of truth). Remote backend: cloned separately, as before. + expect( + screen.getByText( + mode === 'remote' + ? "Installs into this app's local desktop-plugins folder" + : 'Loaded into this app from the package above — same for every profile' + ) + ).toBeTruthy() + expect(requestGateway).not.toHaveBeenCalledWith('plugins.manage', expect.objectContaining({ action: 'install' })) expect(installDesktopPlugin).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) expect($pluginInstallRequest.get()).toBeNull() @@ -106,7 +121,30 @@ describe('Install from Git entry flow', () => { const boxes = screen.getAllByRole('checkbox') expect(boxes.map(box => box.getAttribute('aria-checked'))).toEqual(['false', 'true']) expect(probePluginRepo).toHaveBeenCalledTimes(1) - expect(requestGateway).not.toHaveBeenCalled() + expect(requestGateway).not.toHaveBeenCalledWith('plugins.manage', expect.objectContaining({ action: 'install' })) expect(installDesktopPlugin).not.toHaveBeenCalled() }) + + it('pins a custom install to a full commit SHA and refuses anything shorter', async () => { + probePluginRepo.mockResolvedValue({ ok: true, agent: true, desktop: false, warnings: [] }) + requestGateway.mockImplementation(async method => + method === 'plugins.manage' ? { ok: true, plugin_name: 'plugin', plugins: [] } : { plugins: [] } + ) + renderFlow() + act(() => openPluginInstallRequest({ repo: 'https://github.com/example/plugin' })) + const pin = await screen.findByRole('textbox', { name: 'Pin to commit (optional)' }) + const install = screen.getByRole('button', { name: 'Install' }) as HTMLButtonElement + fireEvent.change(pin, { target: { value: 'main' } }) + expect(install.disabled).toBe(true) + const sha = 'ABCDEF0123456789abcdef0123456789abcdef01' + fireEvent.change(pin, { target: { value: ` ${sha} ` } }) + expect(install.disabled).toBe(false) + fireEvent.click(install) + await waitFor(() => + expect(requestGateway).toHaveBeenCalledWith( + 'plugins.manage', + expect.objectContaining({ action: 'install', ref: sha.toLowerCase() }) + ) + ) + }) }) diff --git a/apps/desktop/src/app/settings/plugin-install-modal.tsx b/apps/desktop/src/app/settings/plugin-install-modal.tsx index 7c1f3cf8b8..5aaa636c62 100644 --- a/apps/desktop/src/app/settings/plugin-install-modal.tsx +++ b/apps/desktop/src/app/settings/plugin-install-modal.tsx @@ -22,7 +22,7 @@ import { useI18n } from '@/i18n' import { ExternalLink } from '@/lib/external-link' import { AlertTriangle } from '@/lib/icons' import { resolvePluginSourceLinks } from '@/lib/plugin-source-urls' -import { installAgentPlugin, loadAgentPlugins } from '@/store/agent-plugins' +import { COMMIT_SHA_RE, installAgentPlugin, loadAgentPlugins } from '@/store/agent-plugins' import { notify } from '@/store/notifications' import { $pluginInstallRequest, @@ -57,6 +57,7 @@ export function PluginInstallModal() { const [installDesktop, setInstallDesktop] = useState(true) const [enableAgent, setEnableAgent] = useState(true) const [forceReinstall, setForceReinstall] = useState(false) + const [pinRef, setPinRef] = useState('') const [installing, setInstalling] = useState(false) const [installError, setInstallError] = useState(null) const probeToken = useRef(0) @@ -69,6 +70,7 @@ export function PluginInstallModal() { setInstallDesktop(true) setEnableAgent(true) setForceReinstall(false) + setPinRef('') setInstalling(false) setInstallError(null) }, []) @@ -157,7 +159,20 @@ export function PluginInstallModal() { const profileLabel = request?.profile || activeProfile || profileScope || 'default' const agentTargetHint = - connection?.mode === 'remote' ? m.agentTargetRemote(profileLabel) : m.agentTargetLocal(profileLabel) + connection?.mode === 'remote' + ? m.agentTargetRemote(profileLabel) + : m.agentTargetLocal( + profileLabel, + request?.profile && request.profile !== 'default' + ? `~/.hermes/profiles/${request.profile}/plugins/` + : '~/.hermes/plugins/' + ) + + // A unified package installed into a local backend carries its own desktop + // half; the app copies that half out of the package folder. Only a remote + // backend (whose plugins/ folder this machine cannot read) or a desktop-only + // repo needs a separate desktop clone. + const desktopHalfFromPackage = Boolean(probe?.agent && installAgent && connection?.mode !== 'remote') const sourceLinks = useMemo(() => (request ? resolvePluginSourceLinks(request.repo) : null), [request]) @@ -195,6 +210,7 @@ export function PluginInstallModal() { force: forceReinstall, enable: enableAgent, catalogName: request.catalogName, + ref: pinRefTrimmed || undefined, profile: request.profile }) @@ -226,18 +242,32 @@ export function PluginInstallModal() { } if (installDesktop && probe.desktop) { - const installFn = window.hermesDesktop?.installDesktopPlugin + if (agentInstalled && desktopHalfFromPackage) { + // Unified package into a LOCAL backend: the desktop half ships inside + // the package folder Electron just watched land. Materialise it from + // there (one source of truth, follows updates/uninstall) instead of + // cloning a second, standalone copy under another folder name. + const touched = (await window.hermesDesktop?.reconcileDesktopPlugins?.()) ?? [] - if (!installFn) { - errors.push(m.desktopUnavailable) - } else { - const result = await installFn({ identifier: request.repo, force: forceReinstall }) + successes.push(m.desktopSuccess(probe.agentName ?? request.repo)) - if (result.ok) { - successes.push(m.desktopSuccess(result.pluginName ?? request.repo)) + if (touched.length > 0) { await discoverRuntimePlugins() + } + } else { + const installFn = window.hermesDesktop?.installDesktopPlugin + + if (!installFn) { + errors.push(m.desktopUnavailable) } else { - errors.push(result.error || m.desktopFailed) + const result = await installFn({ identifier: request.repo, force: forceReinstall }) + + if (result.ok) { + successes.push(m.desktopSuccess(result.pluginName ?? request.repo)) + await discoverRuntimePlugins() + } else { + errors.push(result.error || m.desktopFailed) + } } } } @@ -280,6 +310,8 @@ export function PluginInstallModal() { const open = request !== null && !onSettings const busy = phase === 'probing' || installing + const pinRefTrimmed = pinRef.trim().toLowerCase() + const pinRefInvalid = pinRefTrimmed !== '' && !COMMIT_SHA_RE.test(pinRefTrimmed) return ( {request.catalogName ? m.reviewedHeading : m.securityHeading} -

- {request.catalogName ? m.reviewedIntro : m.securityIntro} -

+

{request.catalogName ? m.reviewedIntro : m.securityIntro}

{sourceLinks && ( @@ -415,8 +445,8 @@ export function PluginInstallModal() { {m.desktopLabel} - {m.desktopTarget} - {probe.desktopName ? ` · ${probe.desktopName}` : ''} + {desktopHalfFromPackage ? m.desktopTargetFromPackage : m.desktopTarget} + {desktopHalfFromPackage ? '' : probe.desktopName ? ` · ${probe.desktopName}` : ''} @@ -435,7 +465,9 @@ export function PluginInstallModal() { className="mt-0.5 size-3.5 shrink-0 text-amber-600 dark:text-amber-400" /> - {[...(probe.warnings ?? []), probe.insecure ? m.insecureWarning : ''].filter(Boolean).join(' ')} + {[...new Set([...(probe.warnings ?? []), probe.insecure ? m.insecureWarning : ''])] + .filter(Boolean) + .join(' ')} )} @@ -457,6 +489,28 @@ export function PluginInstallModal() { )} + + {!request.catalogName && probe.agent && ( + + )} )} @@ -477,7 +531,10 @@ export function PluginInstallModal() { {m.reviewRepository} ) : ( - )} diff --git a/apps/desktop/src/app/settings/plugins-settings.test.tsx b/apps/desktop/src/app/settings/plugins-settings.test.tsx deleted file mode 100644 index 355770de44..0000000000 --- a/apps/desktop/src/app/settings/plugins-settings.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { cleanup, render, screen } from '@testing-library/react' -import { MemoryRouter } from 'react-router' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { requestGateway } = vi.hoisted(() => ({ - requestGateway: vi.fn(async () => ({ plugins: [] })) -})) - -vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ - useGatewayRequest: () => ({ requestGateway }) -})) - -import { $pluginRecords } from '@/contrib/plugins-store' -import { $agentPlugins, $agentPluginsStatus } from '@/store/agent-plugins' -import { $gatewayState } from '@/store/session' - -import { PluginsSettings } from './plugins-settings' - -const renderSettings = () => - render( - - - - ) - -beforeEach(() => { - requestGateway.mockClear() - $pluginRecords.set({}) - $agentPlugins.set([]) - $agentPluginsStatus.set('ready') - $gatewayState.set('idle') -}) - -afterEach(() => { - cleanup() - vi.restoreAllMocks() -}) - -describe('PluginsSettings', () => { - it('points agent-plugin management at Capabilities instead of duplicating the list', () => { - // Agent plugins are profile-scoped and managed in Capabilities → Plugins; - // Settings keeps desktop plugins only, plus a pointer. - $agentPlugins.set([ - { - description: 'Should NOT be listed here anymore', - key: 'demo-plugin', - name: 'demo-plugin', - source: 'git', - status: 'enabled', - version: '1.0.0' - } - ]) - - renderSettings() - - expect(screen.queryByText('demo-plugin')).toBeNull() - expect(screen.getByText(/managed per profile in Capabilities/)).toBeTruthy() - expect(screen.getByRole('link', { name: /Capabilities/ }).getAttribute('href')).toContain('/skills?tab=plugins') - }) - - it('flags a unified-root desktop half whose agent half is missing on this backend', () => { - $pluginRecords.set({ - 'pixel-overlay': { - id: 'pixel-overlay', - name: 'Pixel Overlay', - kind: 'disk', - status: 'loaded', - file: '/home/user/.hermes/plugins/pixel-overlay/desktop/plugin.js' - } - }) - $agentPlugins.set([]) // connected backend has no agent half - $agentPluginsStatus.set('ready') - - renderSettings() - - expect(screen.getByText('agent half missing here')).toBeTruthy() - }) - - it('does not flag when the agent half exists on the connected backend', () => { - $pluginRecords.set({ - 'pixel-overlay': { - id: 'pixel-overlay', - name: 'Pixel Overlay', - kind: 'disk', - status: 'loaded', - file: '/home/user/.hermes/plugins/pixel-overlay/desktop/plugin.js' - } - }) - $agentPlugins.set([ - { - description: '', - key: 'pixel-overlay', - name: 'pixel-overlay', - source: 'user', - status: 'enabled', - version: '1.0.0' - } - ]) - - renderSettings() - - expect(screen.queryByText('agent half missing here')).toBeNull() - }) - - it('does not flag standalone desktop plugins (not from the unified root)', () => { - $pluginRecords.set({ - standalone: { - id: 'standalone', - name: 'Standalone Theme', - kind: 'disk', - status: 'loaded', - file: '/home/user/.config/hermes-desktop/desktop-plugins/standalone/plugin.js' - } - }) - $agentPlugins.set([]) - - renderSettings() - - expect(screen.queryByText('agent half missing here')).toBeNull() - }) - - it('loads the connected backend plugin list once the gateway opens (badge data)', () => { - $gatewayState.set('open') - - renderSettings() - - expect(requestGateway).toHaveBeenCalledWith('plugins.manage', expect.objectContaining({ action: 'list' })) - }) -}) diff --git a/apps/desktop/src/app/settings/plugins-settings.tsx b/apps/desktop/src/app/settings/plugins-settings.tsx deleted file mode 100644 index af3c882b45..0000000000 --- a/apps/desktop/src/app/settings/plugins-settings.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import { useStore } from '@nanostores/react' -import { type ReactNode, useEffect } from 'react' -import { Link } from 'react-router' - -import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' -import { Button } from '@/components/ui/button' -import { Codicon } from '@/components/ui/codicon' -import { Switch } from '@/components/ui/switch' -import { Tip } from '@/components/ui/tooltip' -import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store' -import { discoverRuntimePlugins } from '@/contrib/runtime-loader' -import { useI18n } from '@/i18n' -import { triggerHaptic } from '@/lib/haptics' -import { FolderOpen, Monitor, Package, RefreshCw } from '@/lib/icons' -import { $agentPlugins, $agentPluginsStatus, loadAgentPlugins } from '@/store/agent-plugins' -import { notifyError } from '@/store/notifications' -import { openPluginInstallRequest } from '@/store/plugin-install-request' -import { $gatewayState } from '@/store/session' - -import { EmptyState, Pill, SettingsContent, SettingsSection } from './primitives' -import { useDeepLinkHighlight } from './use-deep-link-highlight' - -const KIND_ORDER: Record = { disk: 0, runtime: 1, bundled: 2 } - -/** Deep-link anchor for a plugin row (`?tab=plugins&plugin=`). */ -export const pluginElementId = (target: string) => `plugin-${target}` - -function reveal(file: string) { - void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined) -} - -async function revealPluginsDir() { - try { - // Electron owns the local plugin root — deriving it from the backend's - // hermes_home breaks against a remote backend (#66899). - const dir = await window.hermesDesktop?.desktopPluginsRoot?.() - - if (!dir) { - notifyError('Desktop plugins are unavailable', 'Could not resolve the plugins folder') - - return - } - - // openDir (not reveal): the door often doesn't exist on first use, and - // showItemInFolder on a missing path silently no-ops (esp. Windows). - const result = await window.hermesDesktop?.openDir?.(dir) - - if (result && !result.ok) { - notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder') - } - } catch (err) { - notifyError(err, 'Could not resolve the plugins folder') - } -} - -// Compact row: name + pills and a wrapping description on the left, controls -// pinned top-right. Same type scale as ListRow, without its wide control grid. -function PluginLine({ - title, - description, - controls, - id -}: { - title: ReactNode - description?: ReactNode - controls: ReactNode - id?: string -}) { - return ( -
-
-
- {title} -
- {description && ( -
- {description} -
- )} -
-
{controls}
-
- ) -} - -/** Folder name when a desktop plugin entry lives in the UNIFIED agent-plugins - * root (`~/.hermes/plugins//desktop/plugin.js`) — i.e. it is the - * desktop half of a bundled agent+desktop package. Null for standalone - * desktop plugins. */ -function unifiedPackageName(file?: string): null | string { - if (!file) { - return null - } - - const match = /[\\/]plugins[\\/]([^\\/]+)[\\/]desktop[\\/]plugin\.js$/.exec(file) - - return match ? match[1] : null -} - -/** Open the dual-target install modal pre-filled to install ONLY the agent - * half of a bundled package (drift repair). Provenance comes from the - * package's catalog sidecar when present; otherwise the git remote of the - * plugin folder is unknown and we fall back to asking the user via the - * standard flow with the folder name as identifier hint. */ -async function repairAgentHalf(record: PluginRecord, packageName: string) { - let repo = '' - let catalogName: string | undefined - let sha: string | undefined - - try { - const pluginDir = record.file?.replace(/[\\/]desktop[\\/]plugin\.js$/, '') - - const raw = pluginDir - ? await window.hermesDesktop?.readFileText?.(`${pluginDir}/.hermes-catalog.json`) - : null - - if (raw) { - const sidecar = JSON.parse(typeof raw === 'string' ? raw : (raw as { content?: string }).content ?? '') as { - catalog_name?: string - repo?: string - sha?: string - } - - repo = sidecar.repo ?? '' - catalogName = sidecar.catalog_name - sha = sidecar.sha - } - } catch { - // No sidecar (raw-git bundled install) — fall through to the name hint. - } - - openPluginInstallRequest({ - catalogName, - legacyHint: 'agent', - repo: repo || packageName, - sha - }) -} - -function PluginRow({ record, agentHalfMissing }: { record: PluginRecord; agentHalfMissing?: boolean }) { - const { t } = useI18n() - const p = t.settings.plugins - - return ( - - {record.file && ( - - - - )} - { - triggerHaptic('selection') - void setPluginEnabled(record.id, on) - }} - /> - - } - description={ - record.status === 'error' ? ( - {record.error} - ) : ( - (record.description ?? record.file ?? record.id) - ) - } - id={pluginElementId(record.id)} - title={ - <> - {record.name} - {p.kinds[record.kind]} - {record.status === 'error' && {p.failed}} - {agentHalfMissing && ( - - - - )} - - } - /> - ) -} - -export function PluginsSettings() { - const { t } = useI18n() - const p = t.settings.plugins - const records = useStore($pluginRecords) - const { requestGateway } = useGatewayRequest() - const gatewayState = useStore($gatewayState) - // The agent-plugin list for the CURRENTLY connected backend's active - // profile — used only to flag bundled packages whose desktop half is local - // but whose agent half is not installed where the app is now pointing (one - // desktop app, N agents: switching gateway/profile makes this drift visible - // instead of silent). Management of agent plugins lives in Capabilities → - // Plugins; this page keeps just the badge. - const agentRows = useStore($agentPlugins) - const agentStatus = useStore($agentPluginsStatus) - const agentNames = new Set(agentRows.flatMap(row => [row.name, row.key ?? row.name])) - - useEffect(() => { - if (gatewayState !== 'open') { - return - } - - void loadAgentPlugins(requestGateway) - }, [gatewayState, requestGateway]) - - // Deep-link from settings search (?plugin=): rows render as soon - // as their store hydrates, so "ready" is simply target-present; the polling - // in the hook rides out the async list loads (agent rows arrive via RPC). - useDeepLinkHighlight({ - param: 'plugin', - ready: () => true, - elementId: pluginElementId - }) - - const rows = Object.values(records).sort( - (a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.name.localeCompare(b.name) - ) - - return ( - -
- -
- -

{p.blurb}

- -
- - -
- - {rows.length === 0 ? ( - - ) : ( -
- {rows.map(record => { - const packageName = unifiedPackageName(record.file) - - return ( - - ) - })} -
- )} -
- - -

- {p.agent.movedToCapabilities}{' '} - - {p.agent.openCapabilities} - -

-
-
- ) -} diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx index 86562e26cb..f81d726ebb 100644 --- a/apps/desktop/src/app/settings/providers-settings.tsx +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -157,13 +157,16 @@ function OAuthPicker({ const select = (p: OAuthProvider) => startManualProviderOAuth(p.id, profile) - const featured = ordered.find(p => p.id === FEATURED_ID && !p.status?.logged_in) ?? null + // The free tier holds a token but no account: it is never "connected"; the featured Nous row + // names it (Nous · free tier) and offers the sign-in that keeps its connectors. + const isConnected = (p: OAuthProvider) => Boolean(p.status?.logged_in) && p.status?.free_tier !== true + const featured = ordered.find(p => p.id === FEATURED_ID && !isConnected(p)) ?? null const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered // Keep connected accounts grouped and always visible; only the unconnected // providers hide behind the disclosure, so the page leads with what's set up. // Both lists preserve `sortProviders` order (curated priority, then name). - const connected = rest.filter(p => p.status?.logged_in) - const others = rest.filter(p => !p.status?.logged_in) + const connected = rest.filter(isConnected) + const others = rest.filter(p => !isConnected(p)) const collapsible = others.length > 0 const showOthers = !collapsible || showAll diff --git a/apps/desktop/src/app/settings/searchable-select.test.tsx b/apps/desktop/src/app/settings/searchable-select.test.tsx index 61f0d77ffa..bf4b363cff 100644 --- a/apps/desktop/src/app/settings/searchable-select.test.tsx +++ b/apps/desktop/src/app/settings/searchable-select.test.tsx @@ -83,6 +83,22 @@ describe('SearchableSelect', () => { expect(screen.queryByText('System default')).toBeNull() }) + it('lets the option list size to its content instead of the shrink-wrapped trigger', () => { + // The trigger shrink-wraps to its current value inside the settings grid + // (~120px for "Europe/Berlin"); pinning the popover width to it clipped + // every IANA row after "Africa/A…". The popover may grow to cover a wider + // trigger, but must never be capped at the trigger's width. + render() + + fireEvent.click(screen.getByRole('combobox')) + + const content = screen.getByRole('listbox', { hidden: true }).closest('[data-slot="popover-content"]') + const classes = content?.className.split(/\s+/) ?? [] + + expect(classes.some(c => c.startsWith('min-w-') && c.includes('radix-popover-trigger-width'))).toBe(true) + expect(classes.some(c => /^w-[[(].*radix-popover-trigger-width/.test(c))).toBe(false) + }) + it('shows the placeholder when the value is blank', () => { render() diff --git a/apps/desktop/src/app/settings/searchable-select.tsx b/apps/desktop/src/app/settings/searchable-select.tsx index 8f679b48ba..b557bb000a 100644 --- a/apps/desktop/src/app/settings/searchable-select.tsx +++ b/apps/desktop/src/app/settings/searchable-select.tsx @@ -88,7 +88,11 @@ export function SearchableSelect({ - + {/* min-w, not w: the trigger shrink-wraps to its current value inside the + settings grid, so a width pinned to it clipped every IANA row after + "Africa/A…". The popover keeps its own width and only grows to cover a + trigger wider than that. */} + diff --git a/apps/desktop/src/app/settings/settings-search.ts b/apps/desktop/src/app/settings/settings-search.ts index cf8e269a0c..dc827865cd 100644 --- a/apps/desktop/src/app/settings/settings-search.ts +++ b/apps/desktop/src/app/settings/settings-search.ts @@ -11,6 +11,7 @@ import type { DesktopConfigSection, SettingsView } from './types' export type CredentialSettingsView = 'settings' | 'tools' export const APPEARANCE_SETTING_IDS = { + appActions: 'appearance.app-actions', backdrop: 'appearance.backdrop', embeds: 'appearance.embeds', introSplash: 'appearance.intro-splash', @@ -26,7 +27,6 @@ export interface SettingsSearchTarget { field?: string key?: string keysView?: CredentialSettingsView - plugin?: string providerView?: 'accounts' | 'custom-endpoints' | 'keys' setting?: string view: SettingsView @@ -221,9 +221,5 @@ export function settingsSearchTargetQuery(target: SettingsSearchTarget): string params.set('key', target.key) } - if (target.plugin) { - params.set('plugin', target.plugin) - } - return params.toString() } diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index 5ecdf27a4a..b8481bb47b 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -12,9 +12,9 @@ export type SettingsView = | 'keybinds' | 'keys' | 'notifications' - | 'plugins' | 'providers' | 'sessions' + | 'vault' | `config:${string}` export type EnvPatch = Partial> diff --git a/apps/desktop/src/app/settings/use-settings-search.ts b/apps/desktop/src/app/settings/use-settings-search.ts index d2607d32c0..99db78e05b 100644 --- a/apps/desktop/src/app/settings/use-settings-search.ts +++ b/apps/desktop/src/app/settings/use-settings-search.ts @@ -6,7 +6,7 @@ import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' import { $pluginRecords } from '@/contrib/plugins-store' import { getEnvVars, getHermesConfigSchema } from '@/hermes' import { useI18n } from '@/i18n' -import { Package, Palette, Settings2, Wrench } from '@/lib/icons' +import { type IconComponent, Monitor, Package, Palette, Settings2, Wrench } from '@/lib/icons' import { $agentPlugins, isDesktopRelevantPlugin, loadAgentPlugins } from '@/store/agent-plugins' import { $gatewayState } from '@/store/session' import { TRANSLUCENCY_SUPPORTED } from '@/store/translucency' @@ -21,6 +21,17 @@ import { type SettingsSearchEntry } from './settings-search' +/** An installed plugin row, deep-linkable as `/skills?tab=plugins&plugin=`. */ +export interface PluginSearchEntry { + context: string + description?: string + icon: IconComponent + id: string + keywords: string[] + label: string + plugin: string +} + /** * The granular settings-search catalog (appearance controls, config fields, * credentials) for the command palette's Settings page. Page destinations stay @@ -71,26 +82,26 @@ export function useSettingsSearchCatalog(enabled: boolean) { } }, [enabled, gatewayState, requestGateway]) - const pluginContext = t.settings.nav.plugins - - const pluginEntries: SettingsSearchEntry[] = [ + // Installed plugin rows (both halves) — they live on Capabilities → Plugins, + // so each entry carries the `?plugin=` row selector for that page. + const pluginEntries: PluginSearchEntry[] = [ ...Object.values(desktopPluginRecords).map(record => ({ - context: pluginContext, + context: t.settings.plugins.title, description: record.description, - icon: Package, + icon: Monitor, id: `plugin:desktop:${record.id}`, - keywords: ['plugin', 'extension', record.id], + keywords: ['plugin', 'extension', 'desktop', record.id], label: record.name, - target: { plugin: record.id, view: 'plugins' as const } + plugin: record.id })), ...agentPlugins.filter(isDesktopRelevantPlugin).map(row => ({ - context: pluginContext, + context: t.skills.plugins.agentTitle, description: row.description || undefined, icon: Package, id: `plugin:agent:${row.key ?? row.name}`, - keywords: ['plugin', 'extension', ...(row.key ? [row.key] : [])], + keywords: ['plugin', 'extension', 'agent', ...(row.key ? [row.key] : [])], label: row.name, - target: { plugin: row.key ?? row.name, view: 'plugins' as const } + plugin: row.key ?? row.name })) ] @@ -187,6 +198,15 @@ export function useSettingsSearchCatalog(enabled: boolean) { label: appearance.toolViewTitle, target: { setting: APPEARANCE_SETTING_IDS.toolView, view: 'config:appearance' } }, + { + context: appearanceContext, + description: appearance.appActionsDesc, + icon: Palette, + id: `setting:${APPEARANCE_SETTING_IDS.appActions}`, + keywords: ['titlebar', 'settings gear', 'layout', 'HUD', 'left', 'right', 'tabs'], + label: appearance.appActionsTitle, + target: { setting: APPEARANCE_SETTING_IDS.appActions, view: 'config:appearance' } + }, { context: appearanceContext, description: appearance.embedsDesc, diff --git a/apps/desktop/src/app/settings/vault-settings.owner.test.tsx b/apps/desktop/src/app/settings/vault-settings.owner.test.tsx new file mode 100644 index 0000000000..f388470daf --- /dev/null +++ b/apps/desktop/src/app/settings/vault-settings.owner.test.tsx @@ -0,0 +1,157 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import { stubResizeObserver } from '@/test/jsdom' + +// Every vault RPC is routed to the OWNER profile's socket; the mock records which profile each +// call targeted so the tests can prove a draft never crosses owners. +const { calls } = vi.hoisted(() => ({ + calls: [] as { method: string; params: Record; profile: string }[] +})) + +let respond: (profile: string, method: string) => Promise = async () => ({}) + +vi.mock('@/store/gateway', async importActual => ({ + ...(await importActual>()), + requestGatewayForProfile: (profile: string, method: string, params?: Record) => { + calls.push({ method, params: params ?? {}, profile }) + + return respond(profile, method) + } +})) +vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() })) +vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() })) + +import { useStore } from '@nanostores/react' + +import { queryClient } from '@/lib/query-client' +import { $activeGatewayProfile } from '@/store/profile' +import { $gatewayState } from '@/store/session' +import { $settingsScopeProfile } from '@/store/settings-scope' + +import { vaultOwnerKey, VaultSettings } from './vault-settings' + +stubResizeObserver() + +const sources = [ + { name: 'bitwarden', display_name: 'Bitwarden', enabled: true, needs_unlock: true, unlocked: false, installed: true } +] + +// Mirrors the production mount site (settings/index.tsx): the panel is keyed by its owner, so an +// owner change remounts it and every dialog/draft is gone by construction. +function KeyedVault() { + const profile = useStore($settingsScopeProfile) + + return +} + +function mount() { + return render( + + + + + + ) +} + +beforeEach(() => { + calls.length = 0 + queryClient.clear() + $activeGatewayProfile.set('default') + $gatewayState.set('open') + respond = async (_profile, method) => + method === 'vault.sources' ? { sources } : method === 'vault.list' ? { items: [] } : { ok: true } +}) + +afterEach(() => { + cleanup() + queryClient.clear() +}) + +it('a master-password draft is wiped on a profile switch and never submitted to the new owner', async () => { + mount() + fireEvent.click(await screen.findByRole('button', { name: 'Unlock' })) + fireEvent.change(screen.getByPlaceholderText('Master password'), { target: { value: 'password-for-A' } }) + + act(() => $activeGatewayProfile.set('other-profile')) + + await waitFor(() => expect(screen.queryByPlaceholderText('Master password')).toBeNull()) + expect(calls.filter(c => c.method === 'vault.unlock')).toHaveLength(0) + // Reads for the new owner target the new profile, not the old one. + await waitFor(() => expect(calls.some(c => c.profile === 'other-profile' && c.method === 'vault.list')).toBe(true)) +}) + +it('a late list response from profile A never paints under profile B', async () => { + let resolveA!: (value: unknown) => void + const held = new Promise(r => (resolveA = r)) + + respond = async (profile, method) => { + if (method === 'vault.sources') { + return { sources } + } + + if (profile === 'default' && method === 'vault.list') { + return held + } + + return { items: [] } + } + + mount() + await waitFor(() => expect(calls.some(c => c.profile === 'default' && c.method === 'vault.list')).toBe(true)) + + act(() => $activeGatewayProfile.set('other-profile')) + await waitFor(() => expect(calls.some(c => c.profile === 'other-profile' && c.method === 'vault.list')).toBe(true)) + + await act(async () => { + resolveA({ + items: [ + { + id: 'a', + kind: 'login', + label: 'A-only private account', + origin: 'https://a.example', + identifier: 'a@example.com', + created_at: '' + } + ] + }) + await held + }) + expect(screen.queryByText('A-only private account')).toBeNull() +}) + +it('vault.add secrets never enter the mutation cache', async () => { + respond = async (_profile, method) => + method === 'vault.sources' ? { sources } : method === 'vault.list' ? { items: [] } : { id: 'created' } + const view = mount() + fireEvent.click(await screen.findByRole('button', { name: 'Add' })) + + for (const [label, value] of [ + ['Label', 'fixture'], + ['Site origin', 'https://example.com'], + ['Identifier', 'fixture@example.com'], + ['Password', 'fixture-retained-password'] + ] as const) { + fireEvent.change(screen.getByLabelText(label), { target: { value } }) + } + + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(calls.some(c => c.method === 'vault.add')).toBe(true)) + expect((calls.find(c => c.method === 'vault.add')!.params.secret as Record).password).toBe( + 'fixture-retained-password' + ) + await waitFor(() => expect(screen.queryByLabelText('Password')).toBeNull()) + view.unmount() + expect( + JSON.stringify( + queryClient + .getMutationCache() + .getAll() + .map(m => m.state.variables) + ) + ).not.toContain('fixture-retained-password') +}) diff --git a/apps/desktop/src/app/settings/vault-settings.test.tsx b/apps/desktop/src/app/settings/vault-settings.test.tsx new file mode 100644 index 0000000000..b5965180b8 --- /dev/null +++ b/apps/desktop/src/app/settings/vault-settings.test.tsx @@ -0,0 +1,204 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { stubResizeObserver } from '@/test/jsdom' + +const { requestGateway } = vi.hoisted(() => ({ + requestGateway: vi.fn() +})) + +// The panel routes every RPC through the owner profile's socket (never the ambient gateway); +// the mock receives (method, params) after the profile argument. +vi.mock('@/store/gateway', async importActual => ({ + ...(await importActual>()), + requestGatewayForProfile: (_profile: string, method: string, params?: Record) => + requestGateway(method, params ?? {}) +})) + +import { queryClient } from '@/lib/query-client' +import { $gatewayState } from '@/store/session' + +import { VaultSettings } from './vault-settings' + +stubResizeObserver() + +const renderVault = (route = '/settings?tab=vault') => + render( + + + + + + ) + +const LOGIN_ITEM = { + id: 'vault_abc123', + kind: 'login', + label: 'GitHub work', + origin: 'https://github.com', + created_at: '2026-08-01T12:00:00+00:00', + identifier: 'me@example.com', + identifier_type: 'email' +} + +beforeEach(() => { + requestGateway.mockReset() + queryClient.clear() + $gatewayState.set('open') +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('VaultSettings', () => { + it('shows the empty state when the vault has no items', async () => { + requestGateway.mockResolvedValue({ items: [] }) + renderVault() + + await waitFor(() => expect(screen.getByText('Nothing saved yet')).toBeTruthy()) + expect(requestGateway).toHaveBeenCalledWith('vault.list', {}) + }) + + it('lists items with label, kind badge, identifier, and origin — never passwords', async () => { + requestGateway.mockResolvedValue({ items: [LOGIN_ITEM] }) + renderVault() + + await waitFor(() => expect(screen.getByText('GitHub work')).toBeTruthy()) + expect(screen.getByText('Login')).toBeTruthy() + // Identifier is agent-visible metadata and now shows in the row. + expect(screen.getByText('me@example.com')).toBeTruthy() + expect(screen.getByText('https://github.com')).toBeTruthy() + }) + + it('opens the Add dialog pre-filled from deep-link query params (never secrets)', async () => { + requestGateway.mockResolvedValue({ items: [] }) + renderVault('/settings?tab=vault&kind=login&label=github&origin=https://github.com') + + await waitFor(() => expect(screen.getByLabelText('Label')).toBeTruthy()) + expect((screen.getByLabelText('Label') as HTMLInputElement).value).toBe('github') + expect((screen.getByLabelText('Site origin') as HTMLInputElement).value).toBe('https://github.com') + // The password field always starts empty — a secret can never arrive via link. + expect((screen.getByLabelText('Password') as HTMLInputElement).value).toBe('') + }) + + it('validates the origin before submitting a login item', async () => { + requestGateway.mockResolvedValue({ items: [] }) + renderVault() + + fireEvent.click(await screen.findByRole('button', { name: 'Add' })) + fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'x' } }) + fireEvent.change(screen.getByLabelText('Site origin'), { target: { value: 'not-a-url' } }) + fireEvent.change(screen.getByLabelText('Identifier'), { target: { value: 'me@example.com' } }) + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'pw' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(screen.getByText('Enter a valid URL like https://example.com.')).toBeTruthy()) + expect(requestGateway).not.toHaveBeenCalledWith('vault.add', expect.anything()) + }) + + it('submits vault.add and refetches the list on success', async () => { + requestGateway.mockImplementation(async (method: string) => + method === 'vault.list' ? { items: [] } : { id: 'vault_new' } + ) + renderVault() + + fireEvent.click(await screen.findByRole('button', { name: 'Add' })) + fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'GitHub work' } }) + fireEvent.change(screen.getByLabelText('Site origin'), { target: { value: 'https://github.com' } }) + fireEvent.change(screen.getByLabelText('Identifier'), { target: { value: 'me@example.com' } }) + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 's3cret' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => + expect(requestGateway).toHaveBeenCalledWith('vault.add', { + kind: 'login', + label: 'GitHub work', + origin: 'https://github.com', + secret: { + identifier_type: 'email', + identifier: 'me@example.com', + password: 's3cret' + } + }) + ) + }) + + it('deletes an item through the confirm dialog', async () => { + requestGateway.mockImplementation(async (method: string) => + method === 'vault.list' ? { items: [LOGIN_ITEM] } : { removed: true } + ) + renderVault() + + await waitFor(() => expect(screen.getByText('GitHub work')).toBeTruthy()) + fireEvent.click(screen.getByRole('button', { name: 'Remove saved item' })) + await waitFor(() => expect(screen.getByText('Delete this item?')).toBeTruthy()) + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + + await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('vault.remove', { id: 'vault_abc123' })) + }) + + it('unlocks a password manager from Settings; the master password leaves only via vault.unlock', async () => { + const sources = [ + { + name: 'onepassword', + display_name: '1Password', + enabled: true, + needs_unlock: true, + unlocked: false, + installed: true + }, + { + name: 'bitwarden', + display_name: 'Bitwarden', + enabled: false, + needs_unlock: true, + unlocked: false, + installed: false + } + ] + + requestGateway.mockImplementation(async (method: string) => { + if (method === 'vault.list') { + // An external item has no delete affordance; its manager is shown as a source badge instead. + return { items: [{ ...LOGIN_ITEM, id: 'op:xyz', label: 'GitHub via 1Password', backend: 'onepassword' }] } + } + + if (method === 'vault.sources') { + return { sources: sources.map(source => ({ ...source })) } + } + + if (method === 'vault.unlock') { + sources[0] = { ...sources[0], unlocked: true } + + return { unlocked: true } + } + + return {} + }) + renderVault() + + await waitFor(() => expect(screen.getByText('GitHub via 1Password')).toBeTruthy()) + expect(screen.queryByRole('button', { name: 'Remove saved item' })).toBeNull() + // A manager that isn't installed has nothing to switch (detection is automatic); the installed one can be unlocked. + expect(screen.queryByRole('switch', { name: 'Bitwarden' })).toBeNull() + expect(screen.getByRole('switch', { name: '1Password' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Unlock' })) + await waitFor(() => expect(screen.getByText('Unlock 1Password')).toBeTruthy()) + + fireEvent.change(screen.getByPlaceholderText('Master password'), { target: { value: 'correct horse' } }) + fireEvent.click( + screen.getByRole('button', { name: 'Unlock' }).closest('form')!.querySelector('button[type=submit]')! + ) + + await waitFor(() => + expect(requestGateway).toHaveBeenCalledWith('vault.unlock', { name: 'onepassword', password: 'correct horse' }) + ) + await waitFor(() => expect(screen.getByText('Unlocked')).toBeTruthy()) + expect(screen.queryByPlaceholderText('Master password')).toBeNull() + expect(screen.getByRole('button', { name: 'Lock' })).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/app/settings/vault-settings.tsx b/apps/desktop/src/app/settings/vault-settings.tsx new file mode 100644 index 0000000000..1cb6d1c08c --- /dev/null +++ b/apps/desktop/src/app/settings/vault-settings.tsx @@ -0,0 +1,810 @@ +import { useStore } from '@nanostores/react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useSearchParams } from 'react-router' + +import { Button } from '@/components/ui/button' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { EmptyState } from '@/components/ui/empty-state' +import { Field } from '@/components/ui/field' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { KeyRound, Lock, Plus, ShieldLock, Trash2 } from '@/lib/icons' +import { $activeConnectionId } from '@/store/connections' +import { requestGatewayForProfile } from '@/store/gateway' +import { notify, notifyError } from '@/store/notifications' +import { $gatewayState } from '@/store/session' +import { $settingsScopeProfile } from '@/store/settings-scope' + +import { CONTROL_TEXT } from './constants' +import { ListRow, Pill, SectionHeading, SettingsContent } from './primitives' + +// Vault data is private to one (connection, profile); the cache key carries that owner so a +// late response from profile A can never paint under profile B. +export const vaultOwnerKey = (connectionId: null | string, profile: string) => `${connectionId ?? ''}::${profile}` +const vaultQueryKey = (owner: string) => ['vault-items', owner] as const +const vaultSourcesQueryKey = (owner: string) => ['vault-sources', owner] as const + +export type VaultSourceName = 'bitwarden' | 'local' | 'onepassword' + +/** One login source as reported by `vault.sources` — the backend is authoritative for enabled/unlocked. */ +export interface VaultSource { + name: VaultSourceName + display_name: string + enabled: boolean + needs_unlock: boolean + unlocked: boolean + installed: boolean +} + +export type VaultKind = 'address' | 'login' | 'payment' +const VAULT_KINDS: readonly VaultKind[] = ['login', 'payment', 'address'] +const IDENTIFIER_TYPES = ['email', 'phone', 'username'] as const +type IdentifierType = (typeof IDENTIFIER_TYPES)[number] + +interface VaultItem { + id: string + kind: string + label: string + origin: null | string + created_at: string + identifier?: null | string + identifier_type?: null | string + backend?: VaultSourceName + has_otp?: boolean +} + +/** Add-dialog prefill from a deep link (`/settings?tab=vault&kind=…`). NEVER secrets. */ +export interface VaultPrefill { + kind?: string + label?: string + origin?: string +} + +function isVaultKind(value: string | undefined): value is VaultKind { + return !!value && (VAULT_KINDS as readonly string[]).includes(value) +} + +function isValidOrigin(value: string): boolean { + try { + const url = new URL(value) + + return (url.protocol === 'https:' || url.protocol === 'http:') && !!url.hostname + } catch { + return false + } +} + +const EMPTY_FORM = { + kind: 'login' as VaultKind, + label: '', + origin: '', + identifierType: 'email' as IdentifierType, + identifier: '', + password: '', + otpSecret: '', + cardNumber: '', + cardName: '', + expMonth: '', + expYear: '', + cvc: '', + postal: '', + line1: '', + line2: '', + city: '', + state: '', + country: '' +} + +type VaultForm = typeof EMPTY_FORM + +function buildSecret(form: VaultForm): Record { + if (form.kind === 'login') { + // identifier_type/identifier are stored as agent-visible metadata by the + // vault store; only the password stays in the encrypted secret payload. + return { + identifier_type: form.identifierType, + identifier: form.identifier.trim(), + password: form.password, + ...(form.otpSecret.trim() ? { otp_secret: form.otpSecret.trim() } : {}) + } + } + + if (form.kind === 'payment') { + return { + card_number: form.cardNumber.replace(/\s+/g, ''), + cardholder_name: form.cardName.trim(), + exp_month: form.expMonth.trim(), + exp_year: form.expYear.trim(), + cvc: form.cvc, + billing_postal_code: form.postal.trim() + } + } + + const secret: Record = { + address_line1: form.line1.trim(), + city: form.city.trim(), + postal_code: form.postal.trim(), + country: form.country.trim() + } + + if (form.line2.trim()) { + secret.address_line2 = form.line2.trim() + } + + if (form.state.trim()) { + secret.state = form.state.trim() + } + + return secret +} + +export function VaultSettings() { + const { t } = useI18n() + const v = t.settings.vault + const gatewayState = useStore($gatewayState) + const queryClient = useQueryClient() + // The owner this panel edits: every RPC below goes through the owner's socket with an explicit + // profile — never the ambient foreground gateway. The mount site keys the panel by this same + // owner, so a profile switch / connection swap remounts it: dialogs close and drafts (including a + // typed master password) are gone by construction rather than by cleanup code. + const scopeProfile = useStore($settingsScopeProfile) + const connectionId = useStore($activeConnectionId) + const owner = vaultOwnerKey(connectionId, scopeProfile) + + const requestGateway = useCallback( + (method: string, params: Record = {}) => + requestGatewayForProfile(scopeProfile, method, params), + [scopeProfile] + ) + + const VAULT_QUERY_KEY = useMemo(() => vaultQueryKey(owner), [owner]) + const VAULT_SOURCES_QUERY_KEY = useMemo(() => vaultSourcesQueryKey(owner), [owner]) + const [searchParams, setSearchParams] = useSearchParams() + + const [addOpen, setAddOpen] = useState(false) + const [form, setForm] = useState(EMPTY_FORM) + const [formError, setFormError] = useState(null) + const [pendingDelete, setPendingDelete] = useState(null) + const [unlockTarget, setUnlockTarget] = useState(null) + const [masterPassword, setMasterPassword] = useState('') + const [unlockError, setUnlockError] = useState(null) + // Secrets never become mutation variables (react-query retains those after settle); they live + // in refs the mutationFn consumes and wipes. + const pendingMasterPassword = useRef('') + const pendingSecret = useRef>(null) + + const { data: sourcesData } = useQuery({ + enabled: gatewayState === 'open', + queryKey: VAULT_SOURCES_QUERY_KEY, + queryFn: async () => { + const result = await requestGateway<{ sources: VaultSource[] }>('vault.sources', {}) + + return result.sources + } + }) + + const externalSources = useMemo(() => (sourcesData ?? []).filter(s => s.needs_unlock), [sourcesData]) + + const invalidateVault = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: VAULT_QUERY_KEY }) + void queryClient.invalidateQueries({ queryKey: VAULT_SOURCES_QUERY_KEY }) + }, [queryClient]) + + const setSourceEnabled = useMutation({ + mutationFn: ({ name, enabled }: { name: VaultSourceName; enabled: boolean }) => + requestGateway<{ enabled: boolean }>('vault.source.set', { name, enabled }), + onSuccess: invalidateVault, + onError: err => notifyError(err, v.sources.toggleFailed) + }) + + const lockSource = useMutation({ + mutationFn: (name: VaultSourceName) => requestGateway<{ locked: boolean }>('vault.lock', { name }), + onSuccess: invalidateVault + }) + + // The master password lives only in this dialog's state; it is cleared the moment the request + // returns (success or failure) and never touches a store or the transcript. + const closeUnlock = useCallback(() => { + setUnlockTarget(null) + setMasterPassword('') + setUnlockError(null) + }, []) + + const unlockSource = useMutation({ + mutationFn: ({ name }: { name: VaultSourceName }) => { + const password = pendingMasterPassword.current + pendingMasterPassword.current = '' + + return requestGateway<{ unlocked: boolean }>('vault.unlock', { name, password }) + }, + onSuccess: (_result, { name }) => { + triggerHaptic('submit') + const source = externalSources.find(s => s.name === name) + notify({ kind: 'success', message: v.sources.unlocked(source?.display_name ?? name) }) + closeUnlock() + invalidateVault() + }, + onError: err => { + setMasterPassword('') + setUnlockError(err instanceof Error ? err.message : String(err)) + } + }) + + const { data, error, isPending } = useQuery({ + enabled: gatewayState === 'open', + queryKey: VAULT_QUERY_KEY, + queryFn: async () => { + const result = await requestGateway<{ items: VaultItem[] }>('vault.list', {}) + + return result.items + } + }) + + useEffect(() => { + if (error) { + notifyError(error, v.loadFailed) + } + }, [error, v.loadFailed]) + + const items = useMemo(() => data ?? [], [data]) + + // Clears the secret fields with the rest of the form — the password/CVC + // never outlive the dialog. + const closeAdd = useCallback(() => { + setAddOpen(false) + setForm(EMPTY_FORM) + setFormError(null) + }, []) + + const openAdd = useCallback((prefill?: VaultPrefill) => { + setForm({ + ...EMPTY_FORM, + kind: isVaultKind(prefill?.kind) ? prefill.kind : 'login', + label: prefill?.label ?? '', + origin: prefill?.origin ?? '' + }) + setFormError(null) + setAddOpen(true) + }, []) + + // Deep link (`hermes://open/settings?tab=vault&kind=login&label=…&origin=…`, + // e.g. relayed by the agent when a login is missing): open the Add dialog + // pre-filled from the query params — metadata only, never a secret — then + // drop the params so a refresh doesn't re-open it. + useEffect(() => { + const kind = searchParams.get('kind') ?? undefined + const label = searchParams.get('label') ?? undefined + const origin = searchParams.get('origin') ?? undefined + + if (!kind && !label && !origin) { + return + } + + openAdd({ kind, label, origin }) + const next = new URLSearchParams(searchParams) + next.delete('kind') + next.delete('label') + next.delete('origin') + setSearchParams(next, { replace: true }) + }, [openAdd, searchParams, setSearchParams]) + + const invalidate = useCallback(() => queryClient.invalidateQueries({ queryKey: VAULT_QUERY_KEY }), [queryClient]) + + const addMutation = useMutation({ + mutationFn: async (payload: { kind: VaultKind; label: string; origin?: string }) => { + const secret = pendingSecret.current + pendingSecret.current = null + + return requestGateway<{ id: string }>('vault.add', { ...payload, secret: secret ?? {} }) + }, + onSuccess: () => { + triggerHaptic('success') + notify({ kind: 'info', message: v.added }) + closeAdd() + void invalidate() + }, + onError: err => { + setFormError(String(err instanceof Error ? err.message : err)) + } + }) + + const submitAdd = useCallback(() => { + setFormError(null) + + if (!form.label.trim()) { + setFormError(v.labelRequired) + + return + } + + // Every kind is filled only on the origin it was saved for; a card without an origin is unfillable. + const origin = form.origin.trim() + + if (!isValidOrigin(origin)) { + setFormError(v.originInvalid) + + return + } + + if (form.kind === 'login' && (!form.identifier.trim() || !form.password)) { + setFormError(v.loginFieldsRequired) + + return + } + + pendingSecret.current = buildSecret(form) + addMutation.mutate({ + kind: form.kind, + label: form.label.trim(), + ...(origin ? { origin } : {}) + }) + }, [addMutation, form, v.labelRequired, v.loginFieldsRequired, v.originInvalid]) + + const deleteItem = useCallback( + async (item: VaultItem) => { + await requestGateway<{ removed: boolean }>('vault.remove', { id: item.id }) + triggerHaptic('success') + void invalidate() + }, + [invalidate, requestGateway] + ) + + const kindLabel = useCallback((kind: string) => v.kinds[kind as VaultKind] ?? kind, [v.kinds]) + + const sourceLabel = useCallback( + (name: VaultSourceName) => externalSources.find(s => s.name === name)?.display_name ?? name, + [externalSources] + ) + + const formatCreated = useCallback((iso: string) => { + const parsed = new Date(iso) + + return Number.isNaN(parsed.getTime()) ? iso : parsed.toLocaleDateString() + }, []) + + return ( + + openAdd()} size="sm" type="button" variant="outline"> + + {v.add} + + } + icon={ShieldLock} + meta={items.length > 0 ? v.count(items.length) : undefined} + title={v.title} + /> +

+ {v.blurb} +

+ + {!isPending && items.length === 0 && } + + {items.map(item => ( + {sourceLabel(item.backend)} + ) : ( + + ) + } + description={ + // identifier · origin · date, separated so the row scans as three facts; the origin is + // omitted when the label already IS the host (save-on-page items are labelled by host). + + {item.identifier && {v.identifierShown(item.identifier)}} + {item.origin && item.origin.replace(/^https?:\/\//, '') !== item.label && ( + <> + {item.identifier && ( + + · + + )} + {item.origin} + + )} + + · + + {v.createdOn(formatCreated(item.created_at))} + + } + key={item.id} + title={ + + {item.label} + {kindLabel(item.kind)} + {item.has_otp && {v.twoFactorBadge}} + + } + /> + ))} + + {/* Password managers */} +
+ +
+

+ {v.sources.blurb} +

+ {externalSources.map(source => ( + + {source.enabled && + (source.unlocked ? ( + + ) : ( + + ))} + {source.installed && ( + { + triggerHaptic('selection') + setSourceEnabled.mutate({ name: source.name, enabled }) + }} + /> + )} + + } + description={ + !source.installed + ? v.sources.notInstalled(source.display_name) + : source.enabled + ? source.unlocked + ? v.sources.unlockedDesc + : v.sources.lockedDesc + : v.sources.disabledDesc + } + key={source.name} + title={ + + {source.display_name} + + {!source.installed + ? v.sources.statusNotDetected + : !source.enabled + ? v.sources.statusOff + : source.unlocked + ? v.sources.statusUnlocked + : v.sources.statusLocked} + + + } + /> + ))} + + {/* Unlock dialog */} + !open && closeUnlock()} open={unlockTarget !== null}> + + + {v.sources.unlockTitle(unlockTarget?.display_name ?? '')} + {v.sources.unlockDescription} + +
{ + e.preventDefault() + + if (unlockTarget && masterPassword) { + pendingMasterPassword.current = masterPassword + setMasterPassword('') + unlockSource.mutate({ name: unlockTarget.name }) + } + }} + > + setMasterPassword(e.target.value)} + placeholder={v.sources.masterPasswordPlaceholder} + type="password" + value={masterPassword} + /> + {unlockError &&

{unlockError}

} + + + + +
+
+
+ + {/* Add dialog */} + !open && closeAdd()} open={addOpen}> + + + {v.addTitle} + {v.addDescription} + + +
{ + e.preventDefault() + submitAdd() + }} + > +
+ + + + + setForm(f => ({ ...f, label: e.target.value }))} + placeholder={v.labelPlaceholder} + value={form.label} + /> + +
+ + + setForm(f => ({ ...f, origin: e.target.value }))} + placeholder={form.kind === 'login' ? v.originPlaceholder : v.originPlaceholderCheckout} + value={form.origin} + /> + + + {form.kind === 'login' && ( + <> +
+ + + + + setForm(f => ({ ...f, identifier: e.target.value }))} + value={form.identifier} + /> + +
+ + setForm(f => ({ ...f, password: e.target.value }))} + type="password" + value={form.password} + /> + + + setForm(f => ({ ...f, otpSecret: e.target.value }))} + placeholder={v.otpPlaceholder} + type="password" + value={form.otpSecret} + /> +

{v.otpHint}

+
+ + )} + + {form.kind === 'payment' && ( + <> + + setForm(f => ({ ...f, cardNumber: e.target.value }))} + type="password" + value={form.cardNumber} + /> + + + setForm(f => ({ ...f, cardName: e.target.value }))} + value={form.cardName} + /> + +
+ + setForm(f => ({ ...f, expMonth: e.target.value }))} + placeholder="MM" + value={form.expMonth} + /> + + + setForm(f => ({ ...f, expYear: e.target.value }))} + placeholder="YYYY" + value={form.expYear} + /> + + + setForm(f => ({ ...f, cvc: e.target.value }))} + type="password" + value={form.cvc} + /> + + + setForm(f => ({ ...f, postal: e.target.value }))} + value={form.postal} + /> + +
+ + )} + + {form.kind === 'address' && ( + <> + + setForm(f => ({ ...f, line1: e.target.value }))} + value={form.line1} + /> + + + setForm(f => ({ ...f, line2: e.target.value }))} + value={form.line2} + /> + +
+ + setForm(f => ({ ...f, city: e.target.value }))} + value={form.city} + /> + + + setForm(f => ({ ...f, state: e.target.value }))} + value={form.state} + /> + +
+
+ + setForm(f => ({ ...f, postal: e.target.value }))} + value={form.postal} + /> + + + setForm(f => ({ ...f, country: e.target.value }))} + value={form.country} + /> + +
+ + )} + + {formError &&

{formError}

} + + + + + +
+
+
+ + {/* Delete confirmation */} + setPendingDelete(null)} + onConfirm={async () => { + if (pendingDelete) { + await deleteItem(pendingDelete) + } + }} + open={pendingDelete !== null} + title={v.deleteTitle} + /> +
+ ) +} diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts index 94859b09ad..f05979d306 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts @@ -2,6 +2,7 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getStatus } from '@/hermes' +import { $setupReadyTick, notifySetupReady } from '@/store/live-sync' import { deferred } from '../../../test/deferred' @@ -25,6 +26,7 @@ beforeEach(() => { vi.mocked(getStatus) .mockReset() .mockResolvedValue({} as never) + $setupReadyTick.set(0) }) afterEach(() => { @@ -54,7 +56,8 @@ describe('useStatusSnapshot', () => { await flushAsync() expect(getStatus).toHaveBeenCalledOnce() - expect(requestGateway).toHaveBeenCalledTimes(2) + // One refresh round = setup.status + setup.runtime_check + free_tier.status. + expect(requestGateway).toHaveBeenCalledTimes(3) }) it('keeps the last authoritative readiness through a transient RPC failure', async () => { @@ -199,13 +202,16 @@ describe('useStatusSnapshot', () => { renderHook(() => useStatusSnapshot('open', requestGateway)) await flushAsync() - expect(requestGatewayMock).toHaveBeenCalledTimes(2) + // Open runs the readiness legs once: setup.status, setup.runtime_check, free_tier.status. + expect(getStatus).toHaveBeenCalledOnce() + expect(requestGatewayMock).toHaveBeenCalledTimes(3) await act(async () => { await vi.advanceTimersByTimeAsync(60_000) }) - expect(requestGatewayMock).toHaveBeenCalledTimes(2) + expect(getStatus).toHaveBeenCalledOnce() + expect(requestGatewayMock).toHaveBeenCalledTimes(3) await act(async () => { setup.resolve({ provider_configured: true }) @@ -216,11 +222,55 @@ describe('useStatusSnapshot', () => { await act(async () => { await vi.advanceTimersByTimeAsync(59_999) }) - expect(requestGatewayMock).toHaveBeenCalledTimes(2) + expect(getStatus).toHaveBeenCalledOnce() await act(async () => { await vi.advanceTimersByTimeAsync(1) }) - expect(requestGatewayMock).toHaveBeenCalledTimes(4) + + // The periodic tick is status-only: readiness and the free-tier verdict + // arrive by `setup.ready` push plus the one-shots on open and on return. + expect(getStatus).toHaveBeenCalledTimes(2) + expect(requestGatewayMock).toHaveBeenCalledTimes(3) + }) + + it('re-reads readiness and the free-tier verdict once per setup.ready, off the status tick', async () => { + const requestGatewayMock = vi.fn( + async (method: string) => + (method === 'setup.runtime_check' ? { ok: true } : { provider_configured: true }) as never + ) + + const requestGateway = requestGatewayMock as unknown as GatewayRequester + + renderHook(() => useStatusSnapshot('open', requestGateway)) + await flushAsync() + requestGatewayMock.mockClear() + vi.mocked(getStatus).mockClear() + + await act(async () => { + notifySetupReady() + await vi.advanceTimersByTimeAsync(0) + }) + + const methods = requestGatewayMock.mock.calls.map(([method]) => method) + expect(methods.filter(method => method === 'free_tier.status')).toHaveLength(1) + expect(methods.filter(method => method === 'setup.runtime_check')).toHaveLength(1) + expect(methods.filter(method => method === 'setup.status')).toHaveLength(1) + expect(getStatus).not.toHaveBeenCalled() + }) + + it('ignores setup.ready while the gateway is not open', async () => { + const requestGatewayMock = vi.fn(async () => ({}) as never) + const requestGateway = requestGatewayMock as unknown as GatewayRequester + + renderHook(() => useStatusSnapshot('connecting', requestGateway)) + await flushAsync() + + await act(async () => { + notifySetupReady() + await vi.advanceTimersByTimeAsync(0) + }) + + expect(requestGatewayMock).not.toHaveBeenCalled() }) }) diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts index 5fdc639177..bf8b3b6b16 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react' import { getStatus } from '@/hermes' import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness' +import { refreshFreeTierStatus, setFreeTierRoute } from '@/store/free-tier' +import { $setupReadyTick } from '@/store/live-sync' import type { StatusResponse } from '@/types/hermes' // Statusbar health is ambient chrome, not live data — nothing the user acts on @@ -38,28 +40,65 @@ export function useStatusSnapshot( const scheduleRefresh = () => { if (!cancelled) { - timer = window.setTimeout(() => void refresh(), REFRESH_MS) + timer = window.setTimeout(() => void refresh({ readiness: false }), REFRESH_MS) } } - const refresh = async () => { + const isViewed = () => // macOS commonly leaves an occluded BrowserWindow `visible`; focus is // the missing signal that prevents status + readiness RPCs while the // user is working in another app. - if (document.visibilityState !== 'visible' || !document.hasFocus()) { + document.visibilityState === 'visible' && document.hasFocus() + + // Inference readiness + the free-tier verdict. Not on the periodic tick: + // both change only at seams the backend announces (`setup.ready` at boot) + // or that this window crosses (open, return from another app), so they + // run once per seam instead of every 60s. + const refreshReadiness = async () => { + if (gatewayState !== 'open') { + return + } + + // The free-tier verdict is a local, zero-network read that writes + // straight to its own store and swallows its failures — nothing here + // waits on it or reads the result. + const [inferenceResult] = await Promise.allSettled([ + evaluateRuntimeReadiness(requestGateway), + refreshFreeTierStatus(requestGateway) + ]) + + if (cancelled || inferenceResult.status !== 'fulfilled') { + return + } + + const inference = inferenceResult.value + + if (inference.source !== 'fallback') { + // runtime_check/setup_status returned an authoritative boolean. + // A fallback means both RPCs failed or returned no boolean, so it + // is a transient/unknown transport state, not proof that inference + // became unconfigured. Keep the last authoritative result instead + // of flashing "Inference not ready" during a gateway flap. + setInferenceStatus(inference) + setFreeTierRoute(inference.freeTier) + } + } + + const refresh = async ({ readiness }: { readiness: boolean }) => { + if (!isViewed()) { scheduleRefresh() return } try { - // Wait for both legs before scheduling the next refresh. setInterval + // Wait for every leg before scheduling the next refresh. setInterval // allowed a slow runtime check to overlap with later polls, which // multiplied load on an already-busy gateway and let stale failures // race newer healthy results. - const [statusResult, inferenceResult] = await Promise.allSettled([ + const [statusResult] = await Promise.allSettled([ getStatus(), - gatewayState === 'open' ? evaluateRuntimeReadiness(requestGateway) : Promise.resolve(null) + readiness ? refreshReadiness() : Promise.resolve() ]) if (cancelled) { @@ -69,42 +108,34 @@ export function useStatusSnapshot( if (statusResult.status === 'fulfilled') { setStatusSnapshot(statusResult.value) } - - if (inferenceResult.status === 'fulfilled') { - const inference = inferenceResult.value - - if (inference === null) { - setInferenceStatus(null) - } else if (inference.source !== 'fallback') { - // runtime_check/setup_status returned an authoritative boolean. - // A fallback means both RPCs failed or returned no boolean, so it - // is a transient/unknown transport state, not proof that inference - // became unconfigured. Keep the last authoritative result instead - // of flashing "Inference not ready" during a gateway flap. - setInferenceStatus(inference) - } - } } finally { scheduleRefresh() } } const onReturn = () => { - if (document.visibilityState === 'visible' && document.hasFocus() && !cancelled) { + if (isViewed() && !cancelled) { if (timer !== undefined) { window.clearTimeout(timer) } - void refresh() + void refresh({ readiness: true }) } } + // `setup.ready` (routed by the gateway-event lifecycle handler for the + // active source only) says the boot bootstrap just settled the route: one + // readiness round now, so the chip/strip/onboarding move at once. Rides + // outside the status tick so it neither resets nor waits on the timer. + const unsubscribeSetupReady = $setupReadyTick.listen(() => void refreshReadiness()) + document.addEventListener('visibilitychange', onReturn) window.addEventListener('focus', onReturn) - void refresh() + void refresh({ readiness: true }) return () => { cancelled = true + unsubscribeSetupReady() document.removeEventListener('visibilitychange', onReturn) window.removeEventListener('focus', onReturn) diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 1714e62e57..7b3f7f3b7d 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -10,6 +10,7 @@ import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' import { useContextBreakdown } from '@/app/shell/hooks/use-context-breakdown' import { useSystemResourcesStatusbarItem } from '@/app/shell/system-resources-statusbar' import { $paneVisible, togglePaneVisible } from '@/components/pane-shell/tree/store' +import { Badge } from '@/components/ui/badge' import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { useI18n } from '@/i18n' @@ -33,6 +34,8 @@ import { useStoreSelector } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' import { resolveVersionStatus } from '@/lib/version-status' import { copyFilePath, revealFile } from '@/store/file-actions' +import { $freeTierStatus, FREE_TIER_MODEL } from '@/store/free-tier' +import { openFreeTierSignIn } from '@/store/free-tier-sign-in' import { revealFileInTree } from '@/store/layout' import { $activeGatewayProfile } from '@/store/profile' import { $projectTree, projectNameForCwd } from '@/store/projects' @@ -100,6 +103,7 @@ export function useStatusbarItems({ }: StatusbarItemsOptions) { const { t } = useI18n() const copy = t.shell.statusbar + const freeTierCopy = t.freeTier const fileMenu = t.fileMenu const primaryActiveSessionId = useStore($activeSessionId) const activeGatewayProfile = useStore($activeGatewayProfile) @@ -132,6 +136,9 @@ export function useStatusbarItems({ Object.values(bySession).reduce((sum, items) => sum + failedSubagentCount(items), 0) ) + // Backend truth for the free-tier chip. Refreshed on the ambient status + // cadence (use-status-snapshot), never polled from here. + const freeTier = useStore($freeTierStatus) const updateStatus = useStore($updateStatus) const updateApply = useStore($updateApply) const backendUpdateStatus = useStore($backendUpdateStatus) @@ -452,6 +459,34 @@ export function useStatusbarItems({ toggleLabel: copy.gateway, variant: 'menu' }, + { + // The model id is the quiet part; the sign-in is the action, so it is + // solid and set off by a gap instead of touching the label. + detail: ( + + + {freeTier?.model ?? FREE_TIER_MODEL} + + {/* The class merger drops Badge's own leading-none behind the size's + font-size class, so the badge grows to the inherited 1.5 leading and + overhangs an 11px label. Restating it here keeps it 11.6px tall. */} + + {freeTierCopy.signIn} + + + ), + // Shown while a free-tier identity exists and the tier is on: it names the + // identity that carries the connectors (and inference when nothing else + // does), and it is the persistent way in to the sign-in. + hidden: !freeTier?.available, + icon: , + id: 'free-tier', + label: freeTierCopy.providerName, + onSelect: () => openFreeTierSignIn(), + title: freeTierCopy.statusLabel(freeTier?.model ?? FREE_TIER_MODEL), + toggleLabel: copy.toggleFreeTier, + variant: 'action' + }, { hidden: !currentCwd, icon: , @@ -535,9 +570,12 @@ export function useStatusbarItems({ commandCenterOpen, copy, currentCwd, + freeTierCopy, fileMenu.copyPath, fileMenu.revealFileManager, fileMenu.revealInSidebar, + freeTier?.available, + freeTier?.model, gatewayMenuContent, gatewayClassName, gatewayDetail, diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx index 586ace0a59..47d6633c1a 100644 --- a/apps/desktop/src/app/shell/model-catalog-menu.tsx +++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx @@ -21,7 +21,7 @@ import { Skeleton } from '@/components/ui/skeleton' import type { HermesGateway } from '@/hermes' import { getLocalModelsStatus } from '@/hermes' import { useI18n } from '@/i18n' -import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' +import { catalogProviderMatches, modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { displayModelName, modelDisplayParts } from '@/lib/model-status-label' import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort' import { foldIncludes, normalize } from '@/lib/text' @@ -44,14 +44,6 @@ import type { LocalModelLoadProgress, ModelOptionProvider, ModelOptionsResponse import { type FastControl, ModelEditSubmenu, resolveFastControl } from './model-edit-submenu' -/** Whether a catalog row represents the session's current provider. Custom - * providers report the canonical `custom:` identity from `model.options` - * while the row's slug is the bare config key, so exact slug equality never - * matches — check the row's alias set too (#87035). */ -function isCurrentProvider(provider: ModelOptionProvider, currentProvider: string): boolean { - return provider.slug === currentProvider || (provider.aliases?.includes(currentProvider) ?? false) -} - // Lets the host dropdown (model-pill, a kanban field trigger, …) hand the panel // a way to dismiss itself so clicking a model row commits + closes, while the // hover-revealed edit submenu (reasoning/fast) stays open to play with (its @@ -352,14 +344,10 @@ export function ModelCatalogMenu({ const rowIsCurrent = (row: KbRow) => row.kind === 'moa' ? current.provider === 'moa' && row.preset === current.model - : isCurrentProvider(row.provider, current.provider) && + : catalogProviderMatches(row.provider, current.provider) && (row.family.id === current.model || row.family.fastId === current.model) - const autoIndex = q - ? kbRows.length > 0 - ? 0 - : -1 - : kbRows.findIndex(row => rowIsCurrent(row) || (row.kind === 'family' && row.family.fastId === current.model)) + const autoIndex = q ? (kbRows.length > 0 ? 0 : -1) : kbRows.findIndex(row => rowIsCurrent(row)) const kbIndex = kbOverride !== null && kbOverride < kbRows.length ? kbOverride : autoIndex const kbActiveKey = kbIndex >= 0 ? kbRows[kbIndex].key : null @@ -387,7 +375,7 @@ export function ModelCatalogMenu({ return } - if (!rowIsCurrent(row) && row.family.fastId !== current.model) { + if (!rowIsCurrent(row)) { void selectFamily(row.family, row.provider) } @@ -494,7 +482,7 @@ export function ModelCatalogMenu({ // The active id may be the base or its -fast sibling; either // way this one family row represents both. const activeId = - isCurrentProvider(group.provider, current.provider) && + catalogProviderMatches(group.provider, current.provider) && (current.model === family.id || current.model === family.fastId) ? current.model : null @@ -771,7 +759,7 @@ function groupModels( // stable curated order, so selecting a model can't shuffle the list. While // SEARCHING the pin is skipped: a query means "show me matches". const activeId = - !q && isCurrentProvider(provider, current.provider) && current.model + !q && catalogProviderMatches(provider, current.provider) && current.model ? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id : undefined diff --git a/apps/desktop/src/app/shell/model-menu-panel.test.tsx b/apps/desktop/src/app/shell/model-menu-panel.test.tsx index bbcb87dd2f..dbe972dc05 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.test.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.test.tsx @@ -465,6 +465,65 @@ describe('ModelMenuPanel provider collapse', () => { }) expect(onSelectModel).not.toHaveBeenCalled() }) + + it('does not rewrite the provider when Refresh Models lists the same model id elsewhere', async () => { + $currentProvider.set('zhipu') + $currentModel.set('glm-4.5-air') + + const catalog = { + model: 'glm-4.5-air', + provider: 'zhipu', + providers: [ + { models: ['glm-4.5-air', 'gpt-5.5'], name: 'OpenRouter', slug: 'openrouter' }, + { models: ['glm-4.5-air', 'glm-5-turbo'], name: '智谱2', slug: 'zhipu' }, + MOA_PROVIDER + ] + } + + getGlobalModelOptions.mockResolvedValue(catalog) + + const { content, onSelectModel } = renderPanel() + + await content.findAllByText(/Glm 4\.5 Air/i) + fireEvent.click(await content.findByText('Refresh models')) + + await vi.waitFor(() => { + expect(getGlobalModelOptions).toHaveBeenCalledTimes(2) + }) + expect(onSelectModel).not.toHaveBeenCalled() + }) + + it('marks only the matching provider row current when two providers share a model id', async () => { + $currentProvider.set('zhipu') + $currentModel.set('glm-4.5-air') + getGlobalModelOptions.mockResolvedValue({ + model: 'glm-4.5-air', + provider: 'zhipu', + providers: [ + { models: ['glm-4.5-air', 'gpt-5.5'], name: 'OpenRouter', slug: 'openrouter' }, + { models: ['glm-4.5-air', 'glm-5-turbo'], name: '智谱2', slug: 'zhipu' }, + MOA_PROVIDER + ] + }) + + const { content, onSelectModel } = renderPanel() + + const rows = await content.findAllByText(/Glm 4\.5 Air/i) + const items = [...new Set(rows.map(row => row.closest('[role="menuitem"]')))] + + expect(items).toHaveLength(2) + + const checked = items.filter(item => item?.querySelector('.codicon-check')) + expect(checked).toHaveLength(1) + expect(checked[0]?.closest('[role="group"]')?.textContent).toContain('智谱2') + expect( + items.find(item => !item?.querySelector('.codicon-check'))?.closest('[role="group"]')?.textContent + ).toContain('OpenRouter') + + const input = screen.getByRole('textbox', { name: 'Search models' }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onSelectModel).not.toHaveBeenCalled() + }) }) describe('ModelMenuPanel refresh reconcile × guarded-switch confirm handshake', () => { diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index eca7f2163a..452c881143 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -116,7 +116,7 @@ export function ModelMenuPanel({ // Group / credential swaps can return a catalog that no longer contains // the session's current model. The store + currentPickerSelection would // otherwise keep painting the stale id (it is not in the new list). - const switchTo = reconcileSelectionAfterCatalogRefresh(optionsModel, next.providers) + const switchTo = reconcileSelectionAfterCatalogRefresh(optionsModel, next.providers, optionsProvider) if (switchTo) { await onSelectModel({ ...switchTo, sessionId: activeSessionId || null }) diff --git a/apps/desktop/src/app/shell/titlebar-controls.test.tsx b/apps/desktop/src/app/shell/titlebar-controls.test.tsx new file mode 100644 index 0000000000..6dbda5030d --- /dev/null +++ b/apps/desktop/src/app/shell/titlebar-controls.test.tsx @@ -0,0 +1,171 @@ +// @vitest-environment jsdom +import { act, cleanup, render, screen, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { registry } from '@/contrib/registry' +import { I18nProvider } from '@/i18n' +import { setTitlebarAppActionsSide } from '@/store/titlebar-app-actions' + +import { ROUTES_AREA } from '../routes' + +import { TitlebarControls, type TitlebarTool } from './titlebar-controls' + +const PLUGIN_TOOL: TitlebarTool = { icon: , id: 'plugin-tool', label: 'plugin tool' } + +function renderControls(pathname: string, props?: { leftTools?: TitlebarTool[]; tools?: TitlebarTool[] }) { + return render( + + + {}} tools={props?.tools} /> + + + ) +} + +const windowControls = () => screen.queryByLabelText('Window controls') +const appControls = () => screen.queryByLabelText('App controls') +const pluginChrome = () => screen.queryByText('plugin-chrome') +const pluginTool = () => screen.queryByLabelText('plugin tool') + +describe('TitlebarControls fixed clusters', () => { + let dispose: () => void + + beforeEach(() => { + dispose = registry.registerMany([ + { + area: ROUTES_AREA, + data: { path: '/kanban' }, + id: 'test-kanban-route', + render: () => null + }, + { + area: ROUTES_AREA, + data: { path: '/plain' }, + id: 'test-plain-route', + render: () => null + } + ]) + }) + + afterEach(() => { + dispose() + cleanup() + }) + + it('keeps the app clusters on a contributed page that mounts no titlebar chrome', () => { + renderControls('/plain') + + expect(windowControls()).not.toBeNull() + expect(appControls()).not.toBeNull() + }) + + it('keeps the app clusters on chat', () => { + renderControls('/') + + expect(windowControls()).not.toBeNull() + expect(appControls()).not.toBeNull() + }) + + it('hides the app clusters on an overlay', () => { + renderControls('/settings') + + expect(windowControls()).toBeNull() + expect(appControls()).toBeNull() + }) + + it('keeps the app clusters on a first-party workspace page', () => { + renderControls('/skills') + + expect(windowControls()).not.toBeNull() + expect(appControls()).not.toBeNull() + }) + + it('a titleBar.tools item alone does not claim the band', () => { + renderControls('/plain', { leftTools: [PLUGIN_TOOL] }) + + expect(windowControls()).not.toBeNull() + expect(pluginTool()).not.toBeNull() + }) + + describe('when the page projects titlebar chrome', () => { + let disposeChrome: () => void + + beforeEach(() => { + disposeChrome = registry.register({ + area: 'titleBar.center', + id: 'test-plugin-chrome', + render: () => plugin-chrome + }) + }) + + afterEach(() => { + // The mounted controls subscribe to titleBar.* areas — dispose inside + // act so the unmount-time registry update doesn't warn. + act(() => disposeChrome()) + }) + + it('hides the app clusters on a contributed full-page route', () => { + renderControls('/kanban') + + expect(windowControls()).toBeNull() + expect(appControls()).toBeNull() + }) + + it('keeps plugin titlebar contributions on a contributed full-page route', () => { + renderControls('/kanban') + + expect(pluginChrome()).not.toBeNull() + expect(windowControls()).toBeNull() + expect(appControls()).toBeNull() + }) + + it('keeps contributed titlebar tools on a chrome-owning page', () => { + renderControls('/kanban', { leftTools: [PLUGIN_TOOL] }) + + expect(pluginTool()).not.toBeNull() + }) + + it('hides plugin titlebar contributions on an overlay', () => { + renderControls('/settings') + + expect(pluginChrome()).toBeNull() + }) + }) +}) + +describe('titlebar app-action cluster', () => { + afterEach(() => { + setTitlebarAppActionsSide('right') + cleanup() + }) + + it('defaults settings, layout, and HUD to the right so the left titlebar stays free for tabs', () => { + renderControls('/') + + const left = screen.getByLabelText('Window controls') + const right = screen.getByLabelText('App controls') + + expect(within(right).getByLabelText('Open settings')).toBeTruthy() + expect(within(right).getByLabelText('Layout editor')).toBeTruthy() + expect(within(right).getByLabelText('HUD mode')).toBeTruthy() + + expect(within(left).queryByLabelText('Open settings')).toBeNull() + expect(within(left).queryByLabelText('Layout editor')).toBeNull() + expect(within(left).queryByLabelText('HUD mode')).toBeNull() + expect(within(left).getByLabelText(/Hide sidebar|Show sidebar/)).toBeTruthy() + }) + + it('moves settings, layout, and HUD to the left when the appearance setting says left', () => { + setTitlebarAppActionsSide('left') + renderControls('/') + + const left = screen.getByLabelText('Window controls') + const right = screen.getByLabelText('App controls') + + expect(within(left).getByLabelText('Open settings')).toBeTruthy() + expect(within(left).getByLabelText('Layout editor')).toBeTruthy() + expect(within(left).getByLabelText('HUD mode')).toBeTruthy() + expect(within(right).queryByLabelText('Open settings')).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index 632e7a9bf0..023e4e32d4 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -8,12 +8,13 @@ import { resetLayoutTree } from '@/components/pane-shell/tree/store' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' +import { Slot } from '@/contrib/react/slot' +import { useContributions } from '@/contrib/react/use-contributions' import { useI18n } from '@/i18n' import { compactNumber } from '@/lib/format' import { triggerHaptic } from '@/lib/haptics' import { formatModifierToken } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' -import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics' import { toggleHud } from '@/store/hud' import { $fileBrowserOpen, @@ -24,8 +25,9 @@ import { toggleSidebarOpen } from '@/store/layout' import { $unreadSessionCount } from '@/store/session-dot-state' +import { $titlebarAppActionsSide } from '@/store/titlebar-app-actions' -import { appViewForPath, isOverlayView } from '../routes' +import { appViewForPath, hidesFixedTitlebarClusters, isOverlayView } from '../routes' import { TITLEBAR_ICON_BADGE_SCALE, @@ -134,25 +136,21 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: const navigate = useNavigate() const location = useLocation() const modHeld = useModifierHeld() - const hapticsMuted = useStore($hapticsMuted) const fileBrowserOpen = useStore($fileBrowserOpen) const panesFlipped = useStore($panesFlipped) const sidebarOpen = useStore($sidebarOpen) const unreadCount = useStore($unreadSessionCount) + const appActionsSide = useStore($titlebarAppActionsSide) const unreadBadge = unreadCount > 0 ? unreadCount : undefined const unreadHint = unreadBadge ? ` · ${t.titlebar.unreadSessions(unreadBadge)}` : '' - const toggleHaptics = () => { - if (!hapticsMuted) { - triggerHaptic('tap') - } - - toggleHapticsMuted() - - if (hapticsMuted) { - window.requestAnimationFrame(() => triggerHaptic('success')) - } - } + // `titleBar.*` slot content is mount-scoped — a page's registers + // only while that surface is up — so a non-empty area means a page is + // actively projecting chrome into the band right now. + const titleBarLeft = useContributions('titleBar.left') + const titleBarCenter = useContributions('titleBar.center') + const titleBarRight = useContributions('titleBar.right') + const pageOwnsTitlebar = titleBarLeft.length + titleBarCenter.length + titleBarRight.length > 0 // POSITIONAL toggles: each button shows/hides everything on its physical // side of the main zone (the layout tree collapses the whole side), so they @@ -164,30 +162,28 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: const leftLabel = leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar const rightLabel = rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar - const leftToolbarTools: TitlebarTool[] = [ - { - actionId: 'view.toggleSidebar', - badge: panesFlipped ? undefined : unreadBadge, - icon: , - id: 'sidebar', - label: `${leftLabel}${panesFlipped ? '' : unreadHint}`, - onSelect: () => { - triggerHaptic('tap') - leftEdge.toggle() - } - }, - { - actionId: 'view.flipPanes', - icon: , - id: 'flip-panes', - label: t.titlebar.swapSidebarSides, - onSelect: () => { - triggerHaptic('tap') - togglePanesFlipped() - } - }, - ...leftTools - ] + const sidebarTool: TitlebarTool = { + actionId: 'view.toggleSidebar', + badge: panesFlipped ? undefined : unreadBadge, + icon: , + id: 'sidebar', + label: `${leftLabel}${panesFlipped ? '' : unreadHint}`, + onSelect: () => { + triggerHaptic('tap') + leftEdge.toggle() + } + } + + const flipTool: TitlebarTool = { + actionId: 'view.flipPanes', + icon: , + id: 'flip-panes', + label: t.titlebar.swapSidebarSides, + onSelect: () => { + triggerHaptic('tap') + togglePanesFlipped() + } + } const rightSidebarTool: TitlebarTool = { actionId: 'view.toggleRightSidebar', @@ -202,8 +198,19 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: tour: 'right-pane-toggle' } - // Static system tools — always pinned to the screen's right edge. + // Static system tools — always pinned to the screen's right edge so the + // left titlebar stays free for tabs (#107351). const systemTools: TitlebarTool[] = [ + { + actionId: 'nav.settings', + icon: , + id: 'settings', + label: t.titlebar.openSettings, + onSelect: () => { + triggerHaptic('open') + onOpenSettings() + } + }, { className: 'group/tool', // Hover + held ⌘/Ctrl morphs the glyph into its reset form (see @@ -237,64 +244,70 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: triggerHaptic('open') toggleHud(hudTargetSessionId()) } - }, - { - active: hapticsMuted, - icon: , - id: 'haptics', - label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics, - onSelect: toggleHaptics - }, - { - actionId: 'nav.settings', - icon: , - id: 'settings', - label: t.titlebar.openSettings, - onSelect: () => { - triggerHaptic('open') - onOpenSettings() - } } ] - // While a full-screen overlay (settings, command center, …) is open it should - // visually own the window. These control clusters are `fixed` at a higher - // z-index than the overlay card, so they'd otherwise bleed over it — hide them - // and let the overlay's own chrome (close button, drag region) take over. - if (isOverlayView(appViewForPath(location.pathname))) { + const view = appViewForPath(location.pathname) + + // Overlays own the window. These clusters are `fixed` at a higher z-index + // than the overlay card, so they'd otherwise bleed over it — hide them (and + // the nested titleBar slots) and let the overlay's own chrome take over. + if (isOverlayView(view)) { return null } - const visibleSystemTools = systemTools.filter(tool => !tool.hidden) + const titlebarSlots = ( + <> + + + + + ) + + const leftClusterClass = cn( + titlebarToolClusterClass, + 'left-(--titlebar-controls-left) top-(--titlebar-controls-top) translate-y-(--titlebar-controls-y-nudge)' + ) + + // A contributed full page (`extension`) yields the fixed clusters only while + // it actually projects chrome into the band — page-mounted `titleBar.*` slots + // like kanban's board switcher. A page that mounts no titlebar chrome keeps + // the app's controls; an empty claim would leave a bare strip on every plugin + // route. Contributed `titleBar.tools` items keep rendering here too, so a + // chrome-owning page never silently drops a registered item. + if (hidesFixedTitlebarClusters(view) && pageOwnsTitlebar) { + const pageTools = [...leftTools, ...tools].filter(tool => !tool.hidden) + + return ( +
+ {pageTools.map(tool => ( + + ))} + {titlebarSlots} +
+ ) + } + + const visibleLeftTools = ( + appActionsSide === 'left' ? [sidebarTool, ...systemTools, ...leftTools] : [sidebarTool, ...leftTools] + ).filter(tool => !tool.hidden) + + const visibleSystemTools = appActionsSide === 'right' ? systemTools.filter(tool => !tool.hidden) : [] const visiblePaneTools = tools.filter(tool => !tool.hidden) return ( <> -
- {leftToolbarTools - .filter(tool => !tool.hidden) - .map(tool => ( - - ))} +
+ {visibleLeftTools.map(tool => ( + + ))} + +
- {/* - Pane-scoped tools (preview's monitor / devtools / refresh / X) render - as their own fixed cluster. AppShell sets --shell-preview-toolbar-gap - to either the static cluster's width (file-browser closed → cluster - sits flush against system tools) or the file-browser pane's width - (file-browser open → cluster sits flush against the file-browser pane, - i.e. at the preview pane's right edge). No margin hacks needed. - */} {visiblePaneTools.length > 0 && (
{visibleSystemTools.map(tool => ( ))} + +
) diff --git a/apps/desktop/src/app/skills/collective-tab.test.tsx b/apps/desktop/src/app/skills/collective-tab.test.tsx new file mode 100644 index 0000000000..72b03f3ae4 --- /dev/null +++ b/apps/desktop/src/app/skills/collective-tab.test.tsx @@ -0,0 +1,621 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as HermesApi from '@/hermes' + +const getWisdomStatus = vi.fn() +const getWisdomDiscovery = vi.fn() +const getWisdomCandidates = vi.fn() +const getWisdomDrafts = vi.fn() +const getWisdomSkill = vi.fn() +const getWisdomInstallations = vi.fn() +const getWisdomVersionContent = vi.fn() +const suggestWisdomSkill = vi.fn() +const reviewWisdomDraft = vi.fn() +const reviewWisdomPublication = vi.fn() +const saveWisdomPreparedDraft = vi.fn() +const submitWisdomPublication = vi.fn() +const reviseWisdomDraft = vi.fn() +const decideWisdomDraft = vi.fn() +const planWisdomInstall = vi.fn() +const applyWisdomInstall = vi.fn() +const planWisdomUpdate = vi.fn() +const applyWisdomUpdate = vi.fn() +const checkWisdom = vi.fn() +const setupWisdom = vi.fn() +const getActionStatus = vi.fn() + +vi.mock('@/hermes', async importOriginal => ({ + ...(await importOriginal()), + decideWisdomDraft, + getWisdomCandidates, + getWisdomDiscovery, + getWisdomDrafts, + getWisdomSkill, + getWisdomInstallations, + getWisdomVersionContent, + getWisdomStatus, + reviewWisdomDraft, + reviewWisdomPublication, + saveWisdomPreparedDraft, + submitWisdomPublication, + reviseWisdomDraft, + suggestWisdomSkill, + planWisdomInstall, + applyWisdomInstall, + planWisdomUpdate, + applyWisdomUpdate, + checkWisdom, + setupWisdom, + getActionStatus +})) + +vi.mock('@/store/notifications', () => ({ notifyError: vi.fn() })) + +const scope = { connectionId: 'gateway-a', profile: 'research' } +const originalScrollIntoView = Element.prototype.scrollIntoView + +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn() +}) + +afterAll(() => { + Element.prototype.scrollIntoView = originalScrollIntoView +}) + +beforeEach(() => { + checkWisdom.mockResolvedValue({ installations: [] }) +}) + +const systemSpecification = { + hermes: { minimum_version: '0.20.5' }, + platforms: ['macOS'], + architectures: ['arm64'], + model: { capabilities: [], minimum_context_window: null }, + tools: [], + plugins: [], + credentials: [], + connections: [], + filesystem: { read: [], write: [] }, + network: { destinations: [] }, + runtime: { shell: false, browser: false, code: false, sandbox: true }, + hardware: [], + known_limitations: [] +} + +async function renderTab(initialEntry = '/skills?tab=collective') { + const { CollectiveTab } = await import('./collective-tab') + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + return render( + + + + + + ) +} + +afterEach(() => vi.clearAllMocks()) + +function mockInstallations() { + getWisdomInstallations.mockResolvedValue({ installations: [], notifications: [] }) +} + +describe('CollectiveTab', () => { + it('keeps collective reads disabled until disclosure setup is accepted', async () => { + getWisdomStatus + .mockResolvedValueOnce({ configured: false, verified_org_id: null }) + .mockResolvedValueOnce({ configured: true, verified_org_id: 'org-1' }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + mockInstallations() + setupWisdom.mockResolvedValue({ ok: true, name: 'wisdom-setup', pid: 1 }) + getActionStatus.mockResolvedValue({ + name: 'wisdom-setup', + running: false, + exit_code: 0, + pid: 1, + lines: [] + }) + + await renderTab() + expect(await screen.findByText(/Candidate qualification stays on this profile/)).toBeTruthy() + expect(getWisdomDiscovery).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: /set up this profile/ })) + + await waitFor(() => expect(setupWisdom).toHaveBeenCalledWith(scope)) + expect(getActionStatus).toHaveBeenCalledWith('wisdom-setup', 80, scope) + }, 30_000) + + it('scopes reads to the selected connection/profile and renders hostile text as text', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValue({ + next_cursor: null, + skills: [ + { + id: 'skill-1', + slug: '', + author_description: '', + install_count: 0, + latest_version: 1, + state: 'active' + } + ] + }) + + await renderTab() + + expect(await screen.findByText('')).toBeTruthy() + expect(globalThis.document.querySelector('script')).toBeNull() + expect(getWisdomDiscovery).toHaveBeenCalledWith(scope) + expect(getWisdomCandidates).toHaveBeenCalledWith(scope) + }) + + it('refreshes shared-skill discovery on demand', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery + .mockResolvedValueOnce({ + next_cursor: null, + skills: [ + { + id: 'skill-1', + slug: 'existing-skill', + author_description: 'Already visible', + install_count: 0, + latest_version: 1, + state: 'active' + } + ] + }) + .mockResolvedValue({ + next_cursor: null, + skills: [ + { + id: 'skill-1', + slug: 'existing-skill', + author_description: 'Already visible', + install_count: 0, + latest_version: 1, + state: 'active' + }, + { + id: 'skill-2', + slug: 'newly-shared-skill', + author_description: 'Published after the screen opened', + install_count: 0, + latest_version: 1, + state: 'active' + } + ] + }) + + await renderTab() + expect(await screen.findByText('existing-skill')).toBeTruthy() + expect(screen.queryByText('newly-shared-skill')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Refresh shared skills' })) + + expect(await screen.findByText('newly-shared-skill')).toBeTruthy() + expect(getWisdomDiscovery).toHaveBeenCalledTimes(2) + }) + + it('refreshes registry discovery while checking managed updates', async () => { + mockInstallations() + checkWisdom.mockResolvedValue({ installations: [] }) + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValueOnce({ next_cursor: null, skills: [] }).mockResolvedValue({ + next_cursor: null, + skills: [ + { + id: 'skill-2', + slug: 'discovered-during-update-check', + author_description: 'Newly shared', + install_count: 0, + latest_version: 1, + state: 'active' + } + ] + }) + + await renderTab() + fireEvent.click(await screen.findByRole('button', { name: 'Check updates' })) + + expect(await screen.findByText('discovered-during-update-check')).toBeTruthy() + expect(checkWisdom).toHaveBeenCalledWith(scope) + expect(getWisdomDiscovery).toHaveBeenCalledTimes(2) + }) + + it('checks on load and marks installed skills with a pending target version', async () => { + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValue({ + next_cursor: null, + skills: [ + { + id: 'skill-1', + slug: 'gateway-pull-canary', + author_description: 'Managed canary', + install_count: 1, + latest_version: 2, + state: 'active' + } + ] + }) + getWisdomInstallations.mockResolvedValue({ + installations: [ + { + skill_id: 'skill-1', + slug: 'gateway-pull-canary', + version: 1, + update_mode: 'MANUAL', + state: 'active', + target_path: '/managed/gateway-pull-canary' + } + ], + notifications: [] + }) + checkWisdom.mockResolvedValue({ + installations: [ + { + skill_id: 'skill-1', + state: 'update_available', + plan: { skill_id: 'skill-1', version: 2, receipt: 'wup_1' } + } + ] + }) + getWisdomSkill.mockResolvedValue({ + skill: { id: 'skill-1', slug: 'gateway-pull-canary' }, + versions: [{ version: 2 }] + }) + getWisdomVersionContent.mockResolvedValue({ commit: 'sha256:commit', content_hash: 'sha256:content', files: [] }) + + await renderTab() + + await waitFor(() => expect(checkWisdom).toHaveBeenCalledWith(scope)) + expect(await screen.findByText('v2 update available')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Check updates (1)' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: /gateway-pull-canary/ })) + expect(await screen.findByRole('button', { name: 'Review update' })).toBeTruthy() + }) + + it.each(['open', 'moderated'] as const)( + 'reviews and rescans locally before one final %s submission', + async publicationMode => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomCandidates.mockResolvedValue({ + candidates: [ + { + local_skill_id: 'local-1', + name: 'candidate-skill', + eligibility: 'eligible', + reason: null, + qualification: 'manual_selection', + contribution_state: 'new' + } + ] + }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + suggestWisdomSkill.mockResolvedValueOnce({ + network_submission: false, + local_draft_id: 'local:draft', + overlay_path: '/private/overlay', + drafted_description: 'Drafted copy', + system_specification: systemSpecification, + next_step: 'review' + }) + const manifest = JSON.stringify({ schema_version: 1, name: 'candidate-skill', requirements: systemSpecification }) + + const initialReview = { + draft: { id: 'local:draft', slug: 'candidate-skill', state: 'prepared', authorDescription: 'Drafted copy' }, + publication_mode: publicationMode, + effective_policy: {}, + files: [ + { path: 'SKILL.md', mode: 'file', hash: 'sha256:skill', content_utf8: '# Candidate\n' }, + { path: 'skill.manifest.json', mode: 'file', hash: 'sha256:manifest', content_utf8: manifest } + ], + hashes: { + content: 'sha256:content', + author_description: 'sha256:description', + package_manifest: 'sha256:manifest' + }, + receipt: null + } + + const rescannedReview = { + ...initialReview, + draft: { ...initialReview.draft, authorDescription: 'Approved owner copy' }, + hashes: { ...initialReview.hashes, author_description: 'sha256:revised' } + } + + reviewWisdomPublication.mockResolvedValueOnce(initialReview).mockResolvedValueOnce(rescannedReview) + saveWisdomPreparedDraft.mockResolvedValue({ local_draft_id: 'local:draft' }) + submitWisdomPublication.mockResolvedValue({ + draft_id: 'draft-1', + publication_state: publicationMode === 'open' ? 'published' : 'pending_moderation', + portal_url: 'https://portal.example/skill/draft-1' + }) + + await renderTab() + fireEvent.click(await screen.findByText('View all local skills (1)')) + fireEvent.click(await screen.findByRole('button', { name: 'Start contribution' })) + const description = await screen.findByLabelText('Owner-authored description') + fireEvent.change(description, { target: { value: 'Approved owner copy' } }) + const action = publicationMode === 'open' ? 'Publish to team' : 'Submit for approval' + expect(screen.getByRole('button', { name: action })).toHaveProperty('disabled', true) + expect(submitWisdomPublication).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Save changes & rescan' })) + await waitFor(() => expect(screen.getByRole('button', { name: action })).toHaveProperty('disabled', false)) + expect(saveWisdomPreparedDraft).toHaveBeenCalledWith( + 'local:draft', + 'Approved owner copy', + initialReview.files.map(({ path, content_utf8 }) => ({ path, content_utf8 })), + scope + ) + fireEvent.click(screen.getByRole('button', { name: action })) + await waitFor(() => + expect(submitWisdomPublication).toHaveBeenCalledExactlyOnceWith(rescannedReview, scope, undefined) + ) + expect(suggestWisdomSkill).toHaveBeenCalledTimes(1) + expect(suggestWisdomSkill.mock.calls[0][3]).toBe('local-1') + expect((await screen.findByRole('link', { name: 'View in Portal' })).getAttribute('href')).toBe( + 'https://portal.example/skill/draft-1' + ) + } + ) + + it('separates qualified suggestions, manual inventory, and submissions waiting on collective approval', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomCandidates.mockResolvedValue({ + candidates: [ + { + local_skill_id: 'qualified-1', + name: 'qualified-skill', + editorial_name: 'Qualified Skill', + editorial_description: 'A useful skill ready for team review.', + eligibility: 'eligible', + reason: null, + qualification: 'high_usage', + contribution_state: 'new' + }, + { + local_skill_id: 'manual-1', + name: 'manual-only-skill', + eligibility: 'eligible', + reason: null, + qualification: 'manual_selection', + contribution_state: 'new' + } + ] + }) + getWisdomDrafts.mockResolvedValue({ + drafts: [ + { id: 'draft-ready', slug: 'needs-review', state: 'ready' }, + { id: 'draft-pending', slug: 'waiting-on-admin', state: 'pending_moderation' }, + { id: 'draft-published', slug: 'already-shared', state: 'published' }, + { id: 'draft-invalid', slug: 'old-revision', state: 'invalidated' } + ] + }) + + await renderTab() + + expect(await screen.findByText('needs-review')).toBeTruthy() + expect(screen.getByText('Ready for your review')).toBeTruthy() + expect(screen.getByText('waiting-on-admin')).toBeTruthy() + expect(screen.getByText('Waiting for collective administrator approval')).toBeTruthy() + expect( + screen.getByText('Drafts awaiting your review and submissions waiting for collective approval.') + ).toBeTruthy() + expect(screen.getByText('1 qualified suggestion')).toBeTruthy() + expect(screen.getByText('Qualified Skill')).toBeTruthy() + expect(screen.getByText('A useful skill ready for team review.')).toBeTruthy() + expect(screen.getAllByRole('button', { name: 'Start contribution' })).toHaveLength(1) + expect(screen.getByText('View all local skills (1)')).toBeTruthy() + expect(screen.queryByText('already-shared')).toBeNull() + expect(screen.queryByText('old-revision')).toBeNull() + }) + + it('edits owner copy and Markdown through a rescanned successor before approval', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ + drafts: [{ id: 'draft-1', slug: 'editable-skill', state: 'ready', authorDescription: 'Original copy' }] + }) + const manifest = `${JSON.stringify({ schema_version: 1, name: 'editable-skill', requirements: systemSpecification })}\n` + + const initialReview = { + draft: { + id: 'draft-1', + slug: 'editable-skill', + state: 'ready', + authorDescription: 'Original copy', + scanVerdict: 'PASS' + }, + effective_policy: {}, + publication_mode: 'moderated', + files: [ + { path: 'SKILL.md', mode: 'file', hash: 'sha256:skill', content_utf8: '# Original\n' }, + { path: 'skill.manifest.json', mode: 'file', hash: 'sha256:manifest', content_utf8: manifest } + ], + hashes: { + content: 'sha256:content', + author_description: 'sha256:description', + package_manifest: 'sha256:manifest' + }, + receipt: null + } + + const revisedReview = { + ...initialReview, + draft: { ...initialReview.draft, id: 'draft-2', authorDescription: 'Revised copy' }, + files: [{ ...initialReview.files[0], content_utf8: '# Revised\n' }, initialReview.files[1]], + hashes: { ...initialReview.hashes, content: 'sha256:revised' } + } + + reviewWisdomPublication.mockResolvedValueOnce(initialReview).mockResolvedValueOnce(revisedReview) + reviseWisdomDraft.mockResolvedValue({ draft: revisedReview.draft, local_scan: {}, notice: 'rescanned' }) + + await renderTab() + fireEvent.click(await screen.findByRole('button', { name: /editable-skill.*View details/ })) + fireEvent.change(await screen.findByLabelText('Owner-authored description'), { + target: { value: 'Revised copy' } + }) + fireEvent.change(screen.getByLabelText('Edit SKILL.md'), { target: { value: '# Revised\n' } }) + expect(screen.getByRole('button', { name: 'Submit for approval' })).toHaveProperty('disabled', true) + fireEvent.click(screen.getByRole('button', { name: 'Save changes & rescan' })) + + await waitFor(() => expect(reviseWisdomDraft).toHaveBeenCalledTimes(1)) + expect(reviseWisdomDraft).toHaveBeenCalledWith( + 'draft-1', + 'Revised copy', + [ + { path: 'SKILL.md', content_utf8: '# Revised\n' }, + { path: 'skill.manifest.json', content_utf8: manifest } + ], + initialReview.hashes, + scope + ) + await waitFor(() => + expect(screen.getByRole('button', { name: 'Submit for approval' })).toHaveProperty('disabled', false) + ) + }) + + it('requires a verified plan before applying a managed install', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValue({ + next_cursor: null, + skills: [ + { + id: 'skill-1', + slug: 'managed-skill', + author_description: 'Does work', + install_count: 0, + latest_version: 2, + state: 'active' + } + ] + }) + getWisdomSkill.mockResolvedValue({ skill: { id: 'skill-1', slug: 'managed-skill' }, versions: [{ version: 2 }] }) + getWisdomVersionContent.mockResolvedValue({ commit: 'sha256:commit', content_hash: 'sha256:content', files: [] }) + planWisdomInstall + .mockResolvedValueOnce({ + receipt: 'wip_1', + skill_id: 'skill-1', + version: 2, + compatibility: { outcome: 'compatible' } + }) + .mockResolvedValueOnce({ + receipt: 'wip_auto', + skill_id: 'skill-1', + version: 2, + update_mode: 'AUTO_WITH_NOTICE', + compatibility: { outcome: 'compatible' } + }) + applyWisdomInstall.mockResolvedValue({ installed: true }) + + await renderTab() + fireEvent.click(await screen.findByRole('button', { name: /managed-skill/ })) + fireEvent.click(await screen.findByRole('button', { name: 'Install…' })) + const preview = await screen.findByRole('region', { name: 'Confirm install' }) + const dialog = screen.getByRole('dialog', { name: 'Verified managed action plan' }) + + expect(preview.textContent).toContain('wip_1') + fireEvent.click(within(dialog).getByRole('combobox', { name: 'Future updates' })) + fireEvent.click(await screen.findByRole('option', { name: 'Automatic with notice' })) + await waitFor(() => expect(planWisdomInstall).toHaveBeenLastCalledWith('skill-1', scope, 'AUTO_WITH_NOTICE')) + expect(preview.textContent).toContain('wip_auto') + + const confirm = screen.getByRole('button', { name: 'Confirm install' }) + expect(preview.contains(confirm)).toBe(false) + fireEvent.click(confirm) + await waitFor(() => expect(applyWisdomInstall).toHaveBeenCalledWith('wip_auto', false, scope)) + }) + + it('opens the verified update plan from a notification deep link', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomSkill.mockResolvedValue({ + skill: { id: 'skill-1', slug: 'managed-skill' }, + versions: [{ version: 3 }] + }) + getWisdomVersionContent.mockResolvedValue({ commit: 'sha256:commit', content_hash: 'sha256:content', files: [] }) + planWisdomUpdate.mockResolvedValue({ + receipt: 'wup_notification', + skill_id: 'skill-1', + version: 3, + compatibility: { outcome: 'compatible' } + }) + + await renderTab('/skills?tab=collective&wisdomAction=update&wisdomSkillId=skill-1') + + await waitFor(() => expect(planWisdomUpdate).toHaveBeenCalledWith('skill-1', scope)) + expect((await screen.findByRole('region', { name: 'Confirm update' })).textContent).toContain('wup_notification') + expect(applyWisdomUpdate).not.toHaveBeenCalled() + }) + + it('plans a pasted Portal install link before allowing an install', async () => { + const portalLink = + 'http://127.0.0.1:3111/orgs/wisdom-local/wisdom/skills/0a192cc7-486e-426d-a6b4-493119c1c011?version=1' + + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomSkill.mockResolvedValue({ + skill: { id: '0a192cc7-486e-426d-a6b4-493119c1c011', slug: 'gateway-pull-canary' }, + versions: [] + }) + planWisdomInstall.mockResolvedValue({ + receipt: 'wip_from_link', + skill_id: '0a192cc7-486e-426d-a6b4-493119c1c011', + version: 1, + update_mode: 'AUTO_WITH_NOTICE', + compatibility: { outcome: 'compatible' } + }) + applyWisdomInstall.mockResolvedValue({ installed: true }) + + await renderTab() + fireEvent.change(await screen.findByLabelText('Install from link or skill ID'), { + target: { value: portalLink } + }) + fireEvent.click(screen.getByRole('combobox', { name: 'Future updates' })) + fireEvent.click(await screen.findByRole('option', { name: 'Automatic with notice' })) + fireEvent.click(screen.getByRole('button', { name: 'Review install' })) + + await waitFor(() => expect(planWisdomInstall).toHaveBeenCalledWith(portalLink, scope, 'AUTO_WITH_NOTICE')) + expect(applyWisdomInstall).not.toHaveBeenCalled() + expect(await screen.findByText(/wip_from_link/)).toBeTruthy() + const dialog = screen.getByRole('dialog', { name: 'Verified managed action plan' }) + expect(within(dialog).getByRole('combobox', { name: 'Future updates' }).textContent).toContain( + 'Automatic with notice' + ) + fireEvent.click(screen.getByRole('button', { name: 'Confirm install' })) + await waitFor(() => expect(applyWisdomInstall).toHaveBeenCalledWith('wip_from_link', false, scope)) + }) +}) diff --git a/apps/desktop/src/app/skills/collective-tab.tsx b/apps/desktop/src/app/skills/collective-tab.tsx new file mode 100644 index 0000000000..b9ace5fd25 --- /dev/null +++ b/apps/desktop/src/app/skills/collective-tab.tsx @@ -0,0 +1,1017 @@ +import { useQuery } from '@tanstack/react-query' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useLocation, useNavigate } from 'react-router' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { WisdomCheckBadge, WisdomReviewTables } from '@/components/wisdom-checks' +import { WisdomMediationCard } from '@/components/wisdom-mediation-card' +import { WisdomNotificationSettings } from '@/components/wisdom-notification-settings' +import { WisdomNotificationsCard } from '@/components/wisdom-notifications-card' +import { WisdomPublicationReview } from '@/components/wisdom-publication-review' +import { WisdomSyncStatus } from '@/components/wisdom-sync-status' +import { + acknowledgeWisdomNotifications, + applyWisdomInstall, + applyWisdomUpdate, + checkWisdom, + getActionStatus, + getWisdomCandidates, + getWisdomDiscovery, + getWisdomDrafts, + getWisdomInstallations, + getWisdomSkill, + getWisdomStatus, + getWisdomVersionContent, + planWisdomInstall, + planWisdomUpdate, + type ProfileScope, + profileScopeKey, + scanWisdom, + setupWisdom, + suggestWisdomSkill, + uninstallWisdomSkill, + type WisdomActionPlan, + type WisdomCandidate, + type WisdomCheckResult, + type WisdomReviewCheck, + type WisdomUpdateMode +} from '@/hermes' +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' +import { notifyError } from '@/store/notifications' + +import { DetailColumn, ListColumn, ListStrip, MasterDetail } from '../master-detail' + +const TERMINAL_DRAFT_STATES = new Set(['published', 'declined', 'invalidated', 'rejected']) +const UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000 + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} +} + +const candidateDisplayName = (candidate: WisdomCandidate): string => candidate.editorial_name?.trim() || candidate.name + +const candidateDisplayDescription = (candidate: WisdomCandidate): string => + candidate.editorial_description?.trim() || '' + +async function waitForWisdomAction(name: string, profile: ProfileScope): Promise { + for (let attempt = 0; attempt < 1200; attempt += 1) { + const status = await getActionStatus(name, 80, profile) + + if (!status.running) { + if (status.exit_code !== 0) { + throw new Error(status.lines.at(-1) || `Collective Wisdom action failed (${status.exit_code ?? 'unknown'})`) + } + + return + } + + await new Promise(resolve => setTimeout(resolve, 500)) + } + + throw new Error('Collective Wisdom action timed out') +} + +export function CollectiveTab({ profile, query }: { profile: ProfileScope; query: string }) { + const { t } = useI18n() + const copy = t.skills.collective + const location = useLocation() + const navigate = useNavigate() + const scope = profileScopeKey(profile) + const [selectedId, setSelectedId] = useState(null) + + const [publicationDraftId, setPublicationDraftId] = useState(null) + const [busy, setBusy] = useState(null) + const [showManualCandidates, setShowManualCandidates] = useState(false) + const [installReference, setInstallReference] = useState('') + const [installUpdateMode, setInstallUpdateMode] = useState<'' | WisdomUpdateMode>('') + + const [actionPlan, setActionPlan] = useState< + null | (WisdomActionPlan & { action: 'install' | 'uninstall' | 'update' }) + >(null) + + const [actionPlanReference, setActionPlanReference] = useState(null) + + const [acceptSensitive, setAcceptSensitive] = useState(false) + const [acceptPartial, setAcceptPartial] = useState(false) + const [preserveModified, setPreserveModified] = useState(false) + + useEffect(() => { + setSelectedId(null) + setPublicationDraftId(null) + setShowManualCandidates(false) + setInstallReference('') + setInstallUpdateMode('') + setActionPlan(null) + setActionPlanReference(null) + setAcceptSensitive(false) + setAcceptPartial(false) + setPreserveModified(false) + setBusy(null) + }, [scope]) + + const status = useQuery({ + queryKey: ['wisdom-status', scope], + queryFn: () => getWisdomStatus(profile), + staleTime: 30_000 + }) + + const discovery = useQuery({ + queryKey: ['wisdom-discovery', scope], + queryFn: () => getWisdomDiscovery(profile), + staleTime: 30_000, + enabled: status.data?.configured === true + }) + + const candidates = useQuery({ + queryKey: ['wisdom-candidates', scope], + queryFn: () => getWisdomCandidates(profile), + staleTime: 15_000, + enabled: status.data?.configured === true + }) + + const drafts = useQuery({ + queryKey: ['wisdom-drafts', scope], + queryFn: () => getWisdomDrafts(profile), + staleTime: 15_000, + enabled: status.data?.configured === true + }) + + const detail = useQuery({ + queryKey: ['wisdom-detail', scope, selectedId], + queryFn: () => getWisdomSkill(selectedId || '', profile), + enabled: status.data?.configured === true && Boolean(selectedId), + staleTime: 30_000 + }) + + const installations = useQuery({ + queryKey: ['wisdom-installations', scope], + queryFn: () => getWisdomInstallations(profile), + staleTime: 10_000, + enabled: status.data?.configured === true + }) + + const refetchInstallations = installations.refetch + + const updateCheck = useQuery({ + queryKey: ['wisdom-update-check', scope], + queryFn: () => checkWisdom(profile), + enabled: status.data?.configured === true, + staleTime: UPDATE_CHECK_INTERVAL_MS, + refetchInterval: UPDATE_CHECK_INTERVAL_MS, + refetchIntervalInBackground: false, + refetchOnWindowFocus: true + }) + + useEffect(() => { + if (!updateCheck.dataUpdatedAt) { + return + } + + void refetchInstallations().catch(error => notifyError(error, 'Wisdom installation refresh failed')) + }, [refetchInstallations, updateCheck.dataUpdatedAt]) + + const latestSelectedVersion = useMemo( + () => Math.max(0, ...(detail.data?.versions ?? []).map(version => Number(version.version) || 0)), + [detail.data?.versions] + ) + + const content = useQuery({ + queryKey: ['wisdom-content', scope, selectedId, latestSelectedVersion], + queryFn: () => getWisdomVersionContent(selectedId || '', latestSelectedVersion, profile), + enabled: Boolean(selectedId && latestSelectedVersion), + staleTime: 60_000 + }) + + const rows = useMemo(() => { + const needle = query.trim().toLocaleLowerCase() + + return (discovery.data?.skills ?? []).filter( + skill => + !needle || + skill.slug.toLocaleLowerCase().includes(needle) || + (skill.author_description ?? '').toLocaleLowerCase().includes(needle) + ) + }, [discovery.data?.skills, query]) + + const pendingUpdates = useMemo( + () => + new Map( + (updateCheck.data?.installations ?? []) + .filter(item => item.state === 'update_available') + .map(item => [item.skill_id, item] as const) + ), + [updateCheck.data?.installations] + ) + + const activeDrafts = useMemo( + () => (drafts.data?.drafts ?? []).filter(draft => !TERMINAL_DRAFT_STATES.has(draft.state)), + [drafts.data?.drafts] + ) + + const { manualCandidates, qualifiedCandidates } = useMemo(() => { + const all = candidates.data?.candidates ?? [] + + return { + manualCandidates: all.filter(candidate => candidate.qualification === 'manual_selection'), + qualifiedCandidates: all.filter(candidate => candidate.qualification !== 'manual_selection') + } + }, [candidates.data?.candidates]) + + const filterCandidates = useCallback( + (items: WisdomCandidate[]) => { + const needle = query.trim().toLocaleLowerCase() + + return items + .filter(candidate => + !needle + ? true + : [candidate.name, candidateDisplayName(candidate), candidateDisplayDescription(candidate)].some(value => + value.toLocaleLowerCase().includes(needle) + ) + ) + .toSorted((left, right) => candidateDisplayName(left).localeCompare(candidateDisplayName(right))) + }, + [query] + ) + + const visibleQualifiedCandidates = useMemo( + () => filterCandidates(qualifiedCandidates), + [filterCandidates, qualifiedCandidates] + ) + + const visibleManualCandidates = useMemo( + () => filterCandidates(manualCandidates), + [filterCandidates, manualCandidates] + ) + + const refreshContributionData = useCallback(async () => { + await Promise.all([candidates.refetch(), drafts.refetch(), discovery.refetch()]) + }, [candidates, discovery, drafts]) + + const candidateSummary = (candidate: WisdomCandidate): string => { + if (candidate.eligibility !== 'eligible') { + return candidate.reason || copy.localOnly + } + + const qualification = + candidate.qualification === 'manual_selection' + ? copy.localOnly + : candidate.notice_variant === 'first' + ? copy.qualificationFirst(candidate.organization_name) + : copy.qualificationReturning + + return candidate.contribution_state === 'prepared' ? `${qualification} ${copy.savedLocally}` : qualification + } + + const prepare = async (candidate: WisdomCandidate) => { + setBusy(candidate.local_skill_id) + + try { + const result = await suggestWisdomSkill(candidate.name, profile, undefined, candidate.local_skill_id) + setPublicationDraftId('network_submission' in result ? result.local_draft_id : result.draft.id) + } catch (error) { + notifyError(error, 'Collective Wisdom preparation failed') + } finally { + setBusy(null) + } + } + + const openReview = (draftId: string) => setPublicationDraftId(draftId) + + const installed = installations.data?.installations.find( + item => item.skill_id === selectedId && item.state === 'active' + ) + + const selectedUpdate = selectedId ? pendingUpdates.get(selectedId) : undefined + + const planManagedActionForSkill = useCallback( + async (skillId: string, action: 'install' | 'uninstall' | 'update') => { + setSelectedId(skillId) + setBusy(skillId) + + try { + const plan = + action === 'install' + ? await planWisdomInstall(skillId, profile, installUpdateMode || undefined) + : action === 'update' + ? await planWisdomUpdate(skillId, profile) + : { skill_id: skillId, state: 'confirm_uninstall' } + + setActionPlan({ ...plan, action }) + setActionPlanReference(action === 'install' ? skillId : null) + setAcceptSensitive(false) + setAcceptPartial(false) + setPreserveModified(false) + } catch (error) { + notifyError(error, `Wisdom ${action} planning failed`) + } finally { + setBusy(null) + } + }, + [installUpdateMode, profile] + ) + + const planManagedAction = async (action: 'install' | 'uninstall' | 'update') => { + if (selectedId) { + await planManagedActionForSkill(selectedId, action) + } + } + + useEffect(() => { + if (!status.data?.configured) { + return + } + + const params = new URLSearchParams(location.search) + const action = params.get('wisdomAction') + const skillId = params.get('wisdomSkillId') + + if ((action !== 'install' && action !== 'update') || !skillId) { + return + } + + params.delete('wisdomAction') + params.delete('wisdomSkillId') + const search = params.toString() + + navigate( + { hash: location.hash, pathname: location.pathname, search: search ? `?${search}` : '' }, + { replace: true } + ) + void planManagedActionForSkill(skillId, action) + }, [location.hash, location.pathname, location.search, navigate, planManagedActionForSkill, status.data?.configured]) + + const planReferencedInstall = async () => { + const reference = installReference.trim() + + if (!reference) { + return + } + + setBusy('install-reference') + + try { + const plan = await planWisdomInstall(reference, profile, installUpdateMode || undefined) + setSelectedId(plan.skill_id) + setActionPlan({ ...plan, action: 'install' }) + setActionPlanReference(reference) + setAcceptSensitive(false) + setAcceptPartial(false) + setPreserveModified(false) + } catch (error) { + notifyError(error, 'Wisdom install planning failed') + } finally { + setBusy(null) + } + } + + const replanInstallUpdateMode = async (value: string) => { + if (!actionPlan || actionPlan.action !== 'install') { + return + } + + const previousMode = installUpdateMode + const nextMode = value === 'DEFAULT' ? '' : (value as WisdomUpdateMode) + const reference = actionPlanReference || actionPlan.skill_id + + setInstallUpdateMode(nextMode) + setBusy('install-mode') + + try { + const plan = await planWisdomInstall(reference, profile, nextMode || undefined) + setActionPlan({ ...plan, action: 'install' }) + setAcceptSensitive(false) + setAcceptPartial(false) + setPreserveModified(false) + } catch (error) { + setInstallUpdateMode(previousMode) + notifyError(error, 'Wisdom install planning failed') + } finally { + setBusy(null) + } + } + + const applyManagedAction = async () => { + if (!actionPlan) { + return + } + + setBusy(actionPlan.skill_id) + + try { + if (actionPlan.action === 'uninstall') { + await uninstallWisdomSkill(actionPlan.skill_id, profile) + } else if (!actionPlan.receipt) { + throw new Error('Verified action receipt is missing') + } else if (actionPlan.action === 'install') { + await applyWisdomInstall(actionPlan.receipt, acceptPartial, profile) + } else { + await applyWisdomUpdate(actionPlan.receipt, { acceptPartial, acceptSensitive, preserveModified }, profile) + } + + setActionPlan(null) + setActionPlanReference(null) + + if (actionPlan.action === 'install') { + setInstallReference('') + setInstallUpdateMode('') + } + + await Promise.all([installations.refetch(), discovery.refetch(), detail.refetch(), updateCheck.refetch()]) + } catch (error) { + notifyError(error, `Wisdom ${actionPlan.action} failed`) + } finally { + setBusy(null) + } + } + + const setupProfile = async () => { + setBusy('setup') + + try { + const action = await setupWisdom(profile) + await waitForWisdomAction(action.name, profile) + await status.refetch() + await Promise.all([discovery.refetch(), candidates.refetch(), drafts.refetch(), installations.refetch()]) + } catch (error) { + notifyError(error, 'Collective Wisdom setup failed') + } finally { + setBusy(null) + } + } + + if (status.isPending) { + return
{copy.loading}
+ } + + if (status.isError) { + const error = status.error + + return ( +
+ {copy.unavailable} {error instanceof Error ? error.message : ''} +
+ ) + } + + if (!status.data.configured) { + return ( +
+
+
+

{copy.title}

+

{copy.setup}

+
+

{copy.setupDisclosure}

+ {status.data.error && ( +
+ {status.data.error} +
+ )} + +
+
+ ) + } + + if (discovery.isPending || candidates.isPending || drafts.isPending || installations.isPending) { + return
{copy.loading}
+ } + + if (discovery.isError || candidates.isError || drafts.isError || installations.isError) { + const error = discovery.error || candidates.error || drafts.error || installations.error + + return ( +
+ {copy.unavailable} {error instanceof Error ? error.message : ''} +
+ ) + } + + const statusCopy = status.data?.verified_org_id ? `${status.data.verified_org_id} · ${copy.orgWide}` : copy.setup + + return ( +
+
+ +
+
+
{copy.title}
+
{statusCopy}
+
+
+ + + +
+
+
{ + event.preventDefault() + void planReferencedInstall() + }} + > +
+ + setInstallReference(event.target.value)} + placeholder={copy.installReferencePlaceholder} + size="sm" + value={installReference} + /> +

+ {copy.installReferenceHelp} +

+
+
+ + +

+ {copy.updateModeHelp} +

+
+ +
+ + + { + try { + await acknowledgeWisdomNotifications(profile) + await installations.refetch() + } catch (error) { + notifyError(error, 'Could not acknowledge Wisdom notifications') + } + }} + onPlanAction={(action, event) => planManagedActionForSkill(event.skill_id, action)} + /> +
+ + {copy.sharedSkills(rows.length)}} + right={ + qualifiedCandidates.length > 0 ? ( + + {copy.localCandidates(qualifiedCandidates.length)} + + ) : undefined + } + /> + } + > + {rows.map(skill => ( + + ))} + {rows.length === 0 && ( +
{copy.noShared}
+ )} +
+ +
+ {((candidates.data?.candidates.length ?? 0) > 0 || activeDrafts.length > 0) && ( +
+
+

{copy.potential}

+

{copy.potentialHelp}

+
+ {visibleQualifiedCandidates.map(candidate => ( +
+
+
{candidateDisplayName(candidate)}
+ {candidateDisplayDescription(candidate) && ( +
+ {candidateDisplayDescription(candidate)} +
+ )} +
+ {candidateSummary(candidate)} +
+ {candidate.professionalism_check && ( +
+ +
+ )} +
+ +
+ ))} + {visibleQualifiedCandidates.length === 0 && ( +

{copy.noSuggestions}

+ )} + {manualCandidates.length > 0 && ( +
+ + {showManualCandidates && ( + <> +

+ {copy.browseLocalHelp} +

+
+ {visibleManualCandidates.map(candidate => ( +
+
+
+ {candidateDisplayName(candidate)} +
+ {candidateDisplayDescription(candidate) && ( +
+ {candidateDisplayDescription(candidate)} +
+ )} +
+ {candidateSummary(candidate)} +
+ {candidate.professionalism_check && ( +
+ +
+ )} +
+ +
+ ))} +
+ + )} +
+ )} +
+
+ +
+

{copy.ownerReview}

+

{copy.ownerReviewHelp}

+
+ {activeDrafts.length === 0 ? ( +

{copy.noDrafts}

+ ) : ( + activeDrafts.map(draft => ( + + )) + )} +
+
+
+ )} + + {detail.data && ( +
+

{String(detail.data.skill.slug || detail.data.skill.id)}

+

+ {String(detail.data.skill.authorDescription || detail.data.skill.author_description || '')} +

+ +

{copy.versionHistory}

+
+                  {JSON.stringify(
+                    {
+                      latest_version: detail.data.latest_version_detail,
+                      version_history: detail.data.versions,
+                      local_compatibility: detail.data.local_compatibility
+                    },
+                    null,
+                    2
+                  )}
+                
+ {content.data && ( +
+
content {content.data.content_hash}
+ {content.data.files.map(file => ( +
+ + {file.path} · {file.hash} + +
+                          {file.content_utf8}
+                        
+
+ ))} +
+ )} +
+ {installed ? ( + <> + + {copy.installed(installed.version, installed.update_mode)} + + {selectedUpdate && ( + + {copy.updateAvailable(selectedUpdate.plan?.version)} + + )} + + + + ) : ( + + )} +
+
+ )} +
+
+
+ + {publicationDraftId && ( +
+ { + setPublicationDraftId(null) + void refreshContributionData() + }} + onSubmitted={() => void refreshContributionData()} + profile={profile} + /> +
+ )} + + {actionPlan && ( +
+

{copy.confirmAction(actionPlan.action)}

+
+
{JSON.stringify(actionPlan, null, 2)}
+
+
+ {actionPlan.action === 'install' && ( +
+ + +

+ {copy.updateModeHelp} +

+
+ )} + {actionPlan.state === 'current' &&

{copy.alreadyCurrent}

} + {actionPlan.compatibility && actionPlan.compatibility.outcome !== 'compatible' && ( + + )} + {(actionPlan.sensitive_expansion?.length ?? 0) > 0 && ( + + )} + {actionPlan.modified && actionPlan.update_mode !== 'REQUIRED' && ( + + )} +
+ + {actionPlan.state !== 'current' && ( + + )} +
+
+
+ )} +
+ ) +} diff --git a/apps/desktop/src/app/skills/index.test.tsx b/apps/desktop/src/app/skills/index.test.tsx index b4647437dd..5f8174cf1d 100644 --- a/apps/desktop/src/app/skills/index.test.tsx +++ b/apps/desktop/src/app/skills/index.test.tsx @@ -19,6 +19,7 @@ const getUsageAnalytics = vi.fn() const getProfiles = vi.fn() const getSkillContent = vi.fn() const getOfficialSkills = vi.fn() +const getWisdomEntitlement = vi.fn() // Partial mock: keep the real module (SkillsView pulls in @/store/profile, // whose import-time subscription calls setApiRequestProfile) and stub only the @@ -36,7 +37,8 @@ vi.mock('@/hermes', async importOriginal => ({ getUsageAnalytics: (days: number, profile?: null | string) => getUsageAnalytics(days, profile), getProfiles: () => getProfiles(), getSkillContent: (name: string, profile?: null | string) => getSkillContent(name, profile), - getOfficialSkills: (profile?: null | string) => getOfficialSkills(profile) + getOfficialSkills: (profile?: null | string) => getOfficialSkills(profile), + getWisdomEntitlement: (profile?: null | string) => getWisdomEntitlement(profile) })) // Notifications hit nanostores/timers we don't care about here. @@ -45,6 +47,15 @@ vi.mock('@/store/notifications', () => ({ notifyError: vi.fn() })) +// Tab contents have their own suites; this suite owns route-to-panel selection. +vi.mock('@/components/chat/code-editor', () => ({ CodeEditor: () => null })) +vi.mock('./collective-tab', () => ({ + CollectiveTab: () =>
+})) +vi.mock('./plugins-tab', () => ({ + PluginsTab: () =>
+})) + // The catalog Install button routes through the hub action pipeline — stub the // action entrypoint (real module kept: SkillsView reads $hubActions and the // query keys from it). @@ -75,20 +86,26 @@ function toolset(overrides: Record = {}) { } } -async function renderSkills() { +async function renderSkills(tab = 'toolsets') { const { SkillsView } = await import('./index') let result: ReturnType await act(async () => { result = render( // SkillsView reads skills/toolsets via useQuery, so it needs a provider. - + ) }) + if (vi.isFakeTimers()) { + await act(async () => { + await vi.advanceTimersByTimeAsync(50) + }) + } + return result! } @@ -99,6 +116,12 @@ beforeEach(() => { getToolsetConfig.mockResolvedValue({ has_category: true, active_provider: null, providers: [] }) getUsageAnalytics.mockResolvedValue({ tools: [] }) getOfficialSkills.mockResolvedValue({ skills: [] }) + getWisdomEntitlement.mockResolvedValue({ + entitled: true, + org_id: 'org-1', + scopes: ['wisdom:read'], + expires_at: Date.now() / 1000 + 60 + }) getSkillContent.mockResolvedValue({ name: 'web-research', path: '/skills/web-research/SKILL.md', @@ -111,6 +134,7 @@ beforeEach(() => { afterEach(() => { cleanup() + vi.useRealTimers() vi.clearAllMocks() // Shared singleton client — drop cached skills/toolsets so each test refetches. queryClient.clear() @@ -122,6 +146,113 @@ afterEach(() => { // (2× in a row on PR #93612, plus a main run the same hour). Give this file // headroom; the tests are not slow individually. describe('SkillsView toolset management', { timeout: 60_000 }, () => { + it.each([ + ['collective', 'Collective workspace', 'Plugins workspace'], + ['plugins', 'Plugins workspace', 'Collective workspace'] + ])('opens the %s deep link without replacing the other workspace', async (tab, selected, other) => { + await renderSkills(tab) + + expect(await screen.findByRole('region', { name: selected })).toBeTruthy() + expect(screen.queryByRole('region', { name: other })).toBeNull() + expect(navigateSpy).not.toHaveBeenCalledWith({ pathname: '/skills', search: '', hash: '' }, { replace: true }) + const otherTab = other.replace(' workspace', '') + fireEvent.click(screen.getByRole('button', { name: otherTab })) + expect(navigateSpy).toHaveBeenCalledWith( + { pathname: '/skills', search: `?tab=${otherTab.toLowerCase()}`, hash: '' }, + { replace: true } + ) + }) + + it('hides Collective Wisdom and redirects its deep link when local entitlement is absent', async () => { + getWisdomEntitlement.mockResolvedValue({ entitled: false, org_id: null, scopes: [], expires_at: null }) + await renderSkills('collective') + + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + expect(screen.queryByRole('region', { name: 'Collective workspace' })).toBeNull() + await waitFor(() => + expect(navigateSpy).toHaveBeenCalledWith({ pathname: '/skills', search: '', hash: '' }, { replace: true }) + ) + }) + + it('fails closed when an entitlement response is already expired', async () => { + getWisdomEntitlement.mockResolvedValue({ + entitled: true, + org_id: 'org-1', + scopes: ['wisdom:read'], + expires_at: Date.now() / 1000 - 1 + }) + + await renderSkills('collective') + + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + expect(screen.queryByRole('region', { name: 'Collective workspace' })).toBeNull() + }) + + it('rechecks entitlement after switching away and back while probes are pending', async () => { + const { SkillsView } = await import('./index') + + const page = (profile: string) => ( + + + + + + ) + + const result = render(page('eligible')) + expect(await screen.findByRole('button', { name: 'Collective' })).toBeTruthy() + + getWisdomEntitlement.mockImplementation(() => new Promise(() => {})) + result.rerender(page('other')) + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + result.rerender(page('eligible')) + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + }) + + it('hides Collective when its positive JWT reaches expires_at', async () => { + vi.useFakeTimers() + const expiresAt = Date.now() / 1000 + 1 + getWisdomEntitlement.mockResolvedValue({ + entitled: true, + org_id: 'org-1', + scopes: ['wisdom:read'], + expires_at: expiresAt + }) + + await renderSkills('collective') + expect(screen.queryByRole('region', { name: 'Collective workspace' })).not.toBeNull() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_001) + }) + + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + expect(screen.queryByRole('region', { name: 'Collective workspace' })).toBeNull() + }) + + it('fails closed when a recheck errors after a positive result', async () => { + vi.useFakeTimers() + getWisdomEntitlement + .mockResolvedValueOnce({ + entitled: true, + org_id: 'org-1', + scopes: ['wisdom:read'], + expires_at: Date.now() / 1000 + 60 + }) + .mockRejectedValue(new Error('probe failed')) + + await renderSkills('collective') + expect(screen.queryByRole('region', { name: 'Collective workspace' })).not.toBeNull() + + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000) + }) + + expect(getWisdomEntitlement).toHaveBeenCalledTimes(2) + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + expect(screen.queryByRole('region', { name: 'Collective workspace' })).toBeNull() + }) + it('renders a switch for each toolset and toggles it off', async () => { await renderSkills() @@ -283,6 +414,37 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => { expect(await screen.findByText(/Deep research steps/)).toBeTruthy() }) + it('uses editorial skill copy while keeping canonical identifiers for actions', async () => { + getSkills.mockResolvedValue([ + { + name: 'web-research', + description: 'Use when researching the web.', + editorial_name: 'Research the Web', + editorial_description: 'Find and compare trustworthy sources online.', + category: 'research', + enabled: true, + usage: 3, + provenance: 'bundled' + } + ]) + + const { SkillsView } = await import('./index') + await act(async () => { + render( + + + + + + ) + }) + + expect((await screen.findAllByText('Research the Web')).length).toBeGreaterThan(0) + expect(screen.getByText('Find and compare trustworthy sources online.')).toBeTruthy() + expect(screen.queryByText('Use when researching the web.')).toBeNull() + expect(getSkillContent).toHaveBeenCalledWith('web-research', 'default') + }) + it('hub picker refuses to reinstall an already-installed skill', async () => { const { notify } = await import('@/store/notifications') const { EmbeddedHubPicker } = await import('./embedded-hub-picker') diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index c7e38e6ccc..94ea38c93c 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -21,6 +21,7 @@ import { getSkills, getToolsets, getUsageAnalytics, + getWisdomEntitlement, previewSkillHub, type ProfileScope, profileScopeKey, @@ -35,6 +36,7 @@ import { queryClient } from '@/lib/query-client' import { invalidateSlashCompletions } from '@/lib/slash-completion-cache' import { normalize } from '@/lib/text' import { useStoreSelector } from '@/lib/use-session-slice' +import { cn } from '@/lib/utils' import { $gateway, activeGatewayConnectionId } from '@/store/gateway' import { $hubActions, installHubSkill, OFFICIAL_SKILLS_KEY } from '@/store/hub-actions' import { notify, notifyError } from '@/store/notifications' @@ -66,6 +68,7 @@ import { TerminalBackendPanel } from '../settings/terminal-backend-panel' import { ToolsetConfigPanel } from '../settings/toolset-config-panel' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' +import { CollectiveTab } from './collective-tab' import { EmbeddedHubPicker } from './embedded-hub-picker' import { McpTab } from './mcp-tab' import { PluginsTab } from './plugins-tab' @@ -74,7 +77,7 @@ import { $skillsSortDesc, $toolsetsSortDesc } from './store' // 'hub' is gone as a top-level tab — the Skills Hub browser lives inside the // Skills tab now (EmbeddedHubPicker below the installed list). Legacy // `?tab=hub` links fall back to 'skills' via useRouteEnumParam. -const SKILLS_MODES = ['skills', 'toolsets', 'mcp', 'plugins'] as const +const SKILLS_MODES = ['skills', 'toolsets', 'mcp', 'plugins', 'collective'] as const // Skills + toolsets live in the RQ cache so switching tabs/pages paints the // cached lists instantly (no reload flash) and mount only fires a deduped @@ -118,6 +121,18 @@ const usageOf = (skill: SkillInfo): number => (typeof skill.usage === 'number' ? const categoryFor = (skill: SkillInfo): string => asText(skill.category) || 'general' +type SkillPresentation = { + description: string + editorial_description?: string + editorial_name?: string + name: string +} + +const skillDisplayName = (skill: SkillPresentation): string => asText(skill.editorial_name) || skill.name + +const skillDisplayDescription = (skill: SkillPresentation): string => + asText(skill.editorial_description) || skill.description + // Row subtitle: category, with non-default origins badged. function skillSubtitle(skill: SkillInfo): React.ReactNode { const category = prettyName(categoryFor(skill)) @@ -147,9 +162,14 @@ function filteredSkills(skills: SkillInfo[], query: string, desc: boolean): Skil return skills .filter( skill => - !q || includesQuery(skill.name, q) || includesQuery(skill.description, q) || includesQuery(skill.category, q) + !q || + includesQuery(skill.name, q) || + includesQuery(skill.description, q) || + includesQuery(skillDisplayName(skill), q) || + includesQuery(skillDisplayDescription(skill), q) || + includesQuery(skill.category, q) ) - .sort((a, b) => sign * (usageOf(b) - usageOf(a)) || asText(a.name).localeCompare(asText(b.name))) + .sort((a, b) => sign * (usageOf(b) - usageOf(a)) || skillDisplayName(a).localeCompare(skillDisplayName(b))) } // Catalog rows have no usage yet — plain A–Z, same query fields as installed @@ -163,10 +183,12 @@ function filteredOfficial(skills: OfficialSkillInfo[], query: string): OfficialS !q || includesQuery(skill.name, q) || includesQuery(skill.description, q) || + includesQuery(skillDisplayName(skill), q) || + includesQuery(skillDisplayDescription(skill), q) || includesQuery(skill.category, q) || skill.tags.some(tag => includesQuery(tag, q)) ) - .sort((a, b) => asText(a.name).localeCompare(asText(b.name))) + .sort((a, b) => skillDisplayName(a).localeCompare(skillDisplayName(b))) } const toolsetCalls = (toolset: ToolsetInfo, toolCalls: Record): number => @@ -287,6 +309,71 @@ export function SkillsView({ // the wrong machine — withhold it for cross-backend scopes. const crossBackendScope = scopeConnectionId !== null && scopeConnectionId !== (activeGatewayConnectionId() ?? 'local') + // Poll the refresh-free local claim probe so an open page cannot retain a + // positive gate indefinitely. A profile change is fail-closed even if React + // Query has cached that profile from an earlier visit. + const wisdomEntitlement = useQuery({ + queryKey: ['wisdom-entitlement', scopeKey], + queryFn: () => getWisdomEntitlement(scopeProfile), + staleTime: 0, + refetchInterval: 15_000, + retry: false + }) + + // Identity changes on every scope visit, including A -> B -> A. + const wisdomScope = useMemo(() => ({ key: scopeKey }), [scopeKey]) + + const [acceptedWisdomResult, setAcceptedWisdomResult] = useState(null) + + const [entitlementClock, setEntitlementClock] = useState(() => Date.now()) + + useEffect(() => { + if (wisdomEntitlement.isSuccess && wisdomEntitlement.fetchStatus === 'idle') { + setAcceptedWisdomResult({ scope: wisdomScope, updatedAt: wisdomEntitlement.dataUpdatedAt }) + } + }, [wisdomScope, wisdomEntitlement.dataUpdatedAt, wisdomEntitlement.fetchStatus, wisdomEntitlement.isSuccess]) + + const entitlementExpiresAt = wisdomEntitlement.data?.expires_at + useEffect(() => { + if (typeof entitlementExpiresAt !== 'number') { + return + } + + const remaining = entitlementExpiresAt * 1000 - Date.now() + + if (remaining <= 0) { + setEntitlementClock(Date.now()) + + return + } + + const timeout = window.setTimeout(() => setEntitlementClock(Date.now()), Math.min(remaining, 2_147_483_647)) + + return () => window.clearTimeout(timeout) + }, [entitlementExpiresAt]) + + const freshWisdomEntitlement = + wisdomEntitlement.data?.entitled === true && + typeof entitlementExpiresAt === 'number' && + entitlementExpiresAt * 1000 > entitlementClock + + const wisdomEntitled = + acceptedWisdomResult?.scope === wisdomScope && + acceptedWisdomResult.updatedAt === wisdomEntitlement.dataUpdatedAt && + !wisdomEntitlement.isError && + freshWisdomEntitlement + + const wisdomDenied = wisdomEntitlement.isError || (wisdomEntitlement.isSuccess && !freshWisdomEntitlement) + + useEffect(() => { + if (mode === 'collective' && wisdomDenied && !wisdomEntitlement.isFetching) { + setMode('skills') + } + }, [mode, setMode, wisdomDenied, wisdomEntitlement.isFetching]) + const { data: profilesData } = useQuery({ queryKey: ['capabilities-profiles'], queryFn: getProfiles, @@ -821,12 +908,25 @@ export function SkillsView({ // Browse Hub). Lets the user configure ANY profile's capabilities — on any // registered gateway — without switching the whole app. Only meaningful // with >1 option; hidden otherwise to avoid clutter. + // Plugins embeds the selector in its Agent-column header (compact, no label, + // no border): desktop halves on that page are app-level and must not read as + // governed by "Configuring: ". + const compactSelector = mode === 'plugins' + const scopeLabel = scopeOptions.find(option => option.value === scopeSelectValue)?.label + const profileScopeSelector = scopeOptions.length > 1 ? ( -
- {t.skills.configuringProfile} +
+ {!compactSelector && ( + {t.skills.configuringProfile} + )}