diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f245708486fb..0a798a4f5e80 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -76,7 +76,7 @@ jobs: matrix: include: - arch: amd64 - runner: ubuntu-latest-32-core + runner: ubuntu-latest platform: linux/amd64 cache-from: type=gha,scope=docker-amd64 cache-to: type=gha,mode=max,scope=docker-amd64 @@ -190,7 +190,7 @@ jobs: matrix: include: - arch: amd64 - runner: ubuntu-latest-32-core + runner: ubuntu-latest platform: linux/amd64 cache-from: type=gha,scope=docker-amd64 cache-to: type=gha,mode=max,scope=docker-amd64 diff --git a/.github/workflows/e2e-desktop.yml b/.github/workflows/e2e-desktop.yml index 8692f05d7a41..22f47adc1b40 100644 --- a/.github/workflows/e2e-desktop.yml +++ b/.github/workflows/e2e-desktop.yml @@ -20,7 +20,7 @@ jobs: # This job builds the renderer and the electron bundle, then drives a real # Electron app under xvfb. vite, tsc and the Playwright workers all scale # with the core count. - runs-on: ubuntu-latest-32-core + runs-on: ubuntu-latest timeout-minutes: 20 outputs: review_status: ${{ steps.review-status.outputs.review_status }} diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml index 956908293c24..8615575de333 100644 --- a/.github/workflows/js-tests.yml +++ b/.github/workflows/js-tests.yml @@ -14,7 +14,7 @@ jobs: # # One larger runner installs one time. vitest, tsc and eslint each size # their own worker pool from the core count. - runs-on: ubuntu-latest-32-core + runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index ccd47d1b09d2..5ab58d091012 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -54,7 +54,7 @@ jobs: # takes minutes and not seconds when the cache misses. `nix flake check` # builds 21 checks, and --max-jobs defaults to the core count. It uses the # wider runner with no more configuration. - runs-on: ubuntu-latest-32-core + runs-on: ubuntu-latest timeout-minutes: 60 steps: - name: Checkout code diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index 5c91e1a3528f..7100067ac3e8 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -30,7 +30,7 @@ jobs: # cargo builds codegen units and test binaries in parallel across the # cores. This lane also builds the crate from the start when Cargo.toml # changes. - runs-on: ubuntu-latest-32-core + runs-on: ubuntu-latest timeout-minutes: 30 defaults: run: diff --git a/.github/workflows/tests-os.yml b/.github/workflows/tests-os.yml index 12719a853c54..ba58626e6d21 100644 --- a/.github/workflows/tests-os.yml +++ b/.github/workflows/tests-os.yml @@ -48,7 +48,7 @@ jobs: runner: macos-latest marker: macos_only - name: Windows-only tests - runner: windows-latest-32-core + runner: windows-latest marker: windows_only steps: - name: Checkout code diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a0dca54ad003..073e0e48923a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,15 +14,17 @@ concurrency: jobs: test: name: Run tests - # One 96-core runner for the whole suite. There is no slicing. Slicing - # existed to spread the suite over 4-core runners. It cost a matrix job, a - # duration cache, a per-slice artifact and a merge job to do it. - # - # 96 cores clear the floor that the slowest single test file sets (about - # 82s). A second slice divides work that is already at that floor, and - # adds a second setup. - runs-on: ubuntu-latest-96-core - timeout-minutes: 30 + # Was tuned for one 96-core runner (see prior comment: no slicing, 96 + # cores clear the 82s single-file floor). ubuntu-latest-96-core isn't + # available on this personal GitHub account (confirmed 2026-08-26: gh + # api users/RFingAdam/actions/hosted-runners -> 404), so this now runs + # on standard ubuntu-latest (2-4 cores) instead. Far less parallelism -> + # the original 30min budget isn't enough (observed: still in_progress + # and cancelled at the 30min mark on a real run). Bumped generously + # rather than tuned tight, since a standard runner's real core count/ + # noisy-neighbor variance isn't something to chase precisely. + runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.gitignore b/.gitignore index 0d26c24a219c..bef8cb592ef4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /venv/ /venv.old/ /venv.stale.runtime-*/ +/bin/ /.hermes-runtime/ /_pycache/ *.pyc* diff --git a/Dockerfile b/Dockerfile index 2de6192715ed..37070bd991fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -298,7 +298,7 @@ RUN uv pip install --no-cache-dir --no-deps -e "." USER root RUN mkdir -p /opt/hermes/bin && \ cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \ - chmod 0755 /opt/hermes/bin/hermes && \ + chmod 0755 /opt/hermes /opt/hermes/bin/hermes && \ printf 'docker\n' > /opt/hermes/.install_method # The ``.install_method`` stamp is baked next to the running code (the install # tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data diff --git a/acp_adapter/permissions.py b/acp_adapter/permissions.py index b10b2a169ec5..72ff7dbfac1f 100644 --- a/acp_adapter/permissions.py +++ b/acp_adapter/permissions.py @@ -39,13 +39,19 @@ def _permission_option_supports_kind(kind: str) -> bool: def _build_permission_options( - *, allow_permanent: bool, smart_denied: bool = False, + *, allow_permanent: bool, allow_session: bool = True, + smart_denied: bool = False, ) -> list[PermissionOption]: """Return ACP options that match Hermes approval semantics.""" + # A gate that re-asks every time (allow_session=False, e.g. protected + # agent-instruction writes) collapses to the same two options as a + # Smart DENY override — the editor must not offer a scope Hermes + # discards, or every subsequent write re-prompts (#81887). + once_only = smart_denied or not allow_session options = [PermissionOption( option_id="allow_once", kind="allow_once", name="Allow once", )] - if not smart_denied: + if not once_only: options.append(PermissionOption( option_id="allow_session", # ACP has no session-scoped kind, so use the closest persistent @@ -53,7 +59,7 @@ def _build_permission_options( kind="allow_always", name="Allow for session", )) - if allow_permanent and not smart_denied: + if allow_permanent and not once_only: options.append( PermissionOption( option_id="allow_always", @@ -62,7 +68,7 @@ def _build_permission_options( ), ) options.append(PermissionOption(option_id="deny", kind="reject_once", name="Deny")) - if not smart_denied and _permission_option_supports_kind("reject_always"): + if not once_only and _permission_option_supports_kind("reject_always"): options.append( PermissionOption( option_id="deny_always", @@ -132,6 +138,7 @@ def _callback( description: str, *, allow_permanent: bool = True, + allow_session: bool = True, smart_denied: bool = False, **_: object, ) -> str: @@ -139,6 +146,7 @@ def _callback( options = _build_permission_options( allow_permanent=allow_permanent, + allow_session=allow_session, smart_denied=smart_denied, ) diff --git a/agent/agent_init.py b/agent/agent_init.py index 9b77d5227f51..8763af043bf6 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -509,6 +509,30 @@ def _normalize_run_budget_seconds(value) -> Optional[float]: return seconds +def _refuse_checkpoint_required_on_codex_app_server( + checkpoint_required: bool, api_mode: Optional[str] +) -> None: + """Fail closed at init when the checkpoint gate cannot be honored. + + The codex app-server owns its thread and compacts it without a truthful + pre-compaction transcript boundary (in "native" auto-compaction mode — + the default — Hermes never even initiates the compaction), so no + pre-compress checkpoint can be guaranteed on this API mode. Refusing here + keeps a turn from ever reaching a codex-owned compaction boundary; the + compress_context() guard alone cannot cover native turns that bypass + Hermes compression entirely. + """ + if checkpoint_required and api_mode == "codex_app_server": + raise RuntimeError( + "BLOCKED_MISSING_PREREQUISITE: compression.checkpoint_required " + "is incompatible with the codex_app_server API mode: the codex " + "agent compacts its own thread without a truthful pre-compaction " + "transcript boundary, so a required pre-compress checkpoint " + "cannot be guaranteed. Disable compression.checkpoint_required " + "or use a non-app-server API mode." + ) + + def init_agent( agent, base_url: str = None, @@ -1318,7 +1342,9 @@ def init_agent( client_kwargs["default_headers"] = _ra()._qwen_portal_headers() elif base_url_host_matches(effective_base, "chatgpt.com"): from agent.auxiliary_client import _codex_cloudflare_headers - client_kwargs["default_headers"] = _codex_cloudflare_headers(api_key) + client_kwargs["default_headers"] = _codex_cloudflare_headers( + api_key, base_url=effective_base, + ) elif base_url_host_matches(effective_base, "x.ai"): from tools.xai_http import hermes_xai_default_headers @@ -2243,6 +2269,12 @@ def _parse_prune_int(raw, default): compression_threshold_tokens = None except (TypeError, ValueError): compression_threshold_tokens = None + compression_checkpoint_required = is_truthy_value( + _compression_cfg.get("checkpoint_required"), default=False + ) + _refuse_checkpoint_required_on_codex_app_server( + compression_checkpoint_required, getattr(agent, "api_mode", None) + ) # In-place compaction: when True, compress_context() rewrites the message # list + rebuilds the system prompt WITHOUT rotating the session id (no # parent_session_id chain, no `name #N` renumber). See #38763 and @@ -2767,6 +2799,19 @@ def _parse_prune_int(raw, default): agent.compression_in_place = compression_in_place # Apply micro-compaction settings to the compressor (feature is opt-in) _cc = getattr(agent, "context_compressor", None) + # compression.checkpoint_required: micro-compaction is a lossy rewrite + # authority too — it absorbs the oldest uncompacted exchanges into a + # rolling summary post-turn, with no pre-compress checkpoint hook in its + # path. Suppress it while the gate is armed so the checkpoint-aware + # batch compressor stays the only lossy authority (mirrors the + # server-side native-compaction suppression in native_compaction.py). + if compression_checkpoint_required and compression_micro_compact: + logger.warning( + "compression.checkpoint_required is enabled: post-turn " + "micro-compaction is disabled for this agent so every lossy " + "rewrite passes through the checkpoint-gated compressor." + ) + compression_micro_compact = False if _cc is not None and hasattr(_cc, "_micro_compact_enabled"): _cc._micro_compact_enabled = compression_micro_compact if _cc is not None and hasattr(_cc, "_micro_compact_every_n_turns"): @@ -2775,6 +2820,7 @@ def _parse_prune_int(raw, default): _cc._micro_compact_defrag_threshold_tokens = ( compression_micro_compact_defrag_tokens ) + agent.compression_checkpoint_required = compression_checkpoint_required agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction agent.codex_responses_native_compaction = codex_responses_native_compaction agent.codex_responses_compact_threshold = codex_responses_compact_threshold diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 1c89288330f7..c7f0f5dacb81 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -33,11 +33,20 @@ from typing import Any, Dict, List, Optional, Tuple from hermes_cli.timeouts import get_provider_request_timeout -from agent.message_sanitization import _FULL_ARGS_LOG_BOUND +from agent.message_sanitization import ( + _FULL_ARGS_LOG_BOUND, + coalesce_tool_call_id, + tool_call_id_variants, + tool_result_id_variants, +) from agent.prompt_builder import format_steer_marker from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message from agent.trajectory import convert_scratchpad_to_think -from agent.credential_pool import STATUS_EXHAUSTED, credential_pool_matches_provider +from agent.credential_pool import ( + STATUS_EXHAUSTED, + credential_pool_matches_provider, + resolve_runtime_pool_key, +) from agent.error_classifier import FailoverReason from agent.turn_context import drop_stale_api_content from utils import base_url_host_matches, base_url_hostname, env_var_enabled, atomic_json_write @@ -413,7 +422,10 @@ def _prepend_marker(tool_msg: dict) -> None: candidate = messages[scan_index] if not isinstance(candidate, dict) or candidate.get("role") != "tool": break - if candidate.get("tool_call_id") == tool_call_id: + if ( + tool_result_id_variants(candidate.get("tool_call_id")) + & tool_call_id_variants(tool_call) + ): existing_tool_msg = candidate break scan_index += 1 @@ -688,25 +700,18 @@ def _is_verification_candidate(m: Dict) -> bool: continue collapsed.append(msg) - # Pass 1: drop stray tool messages that don't follow a known - # assistant tool_call_id. Uses a rolling set of known ids refreshed - # on each assistant message. - # - # Both ``id`` AND ``call_id`` are registered for every assistant - # tool_call. In the Codex Responses API format the two differ - # (``id`` = ``fc_...`` response-item id, ``call_id`` = ``call_...`` - # the function-call id), and a tool result's ``tool_call_id`` may be - # matched against *either* depending on which code path built it - # (the OpenAI-compatible path stores ``tc.id``; codex paths store - # ``call_id``). Registering only ``id`` — as this pass did before — - # made a valid tool result look orphaned whenever the assistant - # tool_call carried a distinct ``call_id`` (or only ``call_id``); the - # pass then dropped it, leaving the assistant tool_call unanswered and - # producing an HTTP 400 on strict providers (DeepSeek, Kimi). Matching - # on the *superset* of both keys achieves the same tolerance as - # ``_get_tool_call_id_static``'s ``call_id || id`` — a match set must - # accept every legitimate reference, not just the canonical one (#58168). - known_tool_ids: set = set() + # Pass 1: drop stray tool messages that don't follow a known assistant + # tool call. A Responses call can have several equivalent spellings + # (call_id, id, response_item_id, or a composite ``call|item`` id), so + # consume the whole alias group when one spelling is matched. Otherwise a + # duplicate result keyed on the sibling alias would survive and be replayed + # to strict providers (#66974). Alias expansion lives in + # ``agent.message_sanitization.tool_call_id_variants`` / + # ``tool_result_id_variants`` (single policy owner) — which also handles + # SDK tool_call objects, preserving the #91768 dict-or-object tolerance. + known_tool_ids: Dict[str, int] = {} + matched_tool_groups: set = set() + next_tool_group = 0 filtered: List[Dict] = [] for msg in collapsed: if not isinstance(msg, dict): @@ -714,25 +719,35 @@ def _is_verification_candidate(m: Dict) -> bool: continue role = msg.get("role") if role == "assistant": - known_tool_ids = set() + known_tool_ids = {} + matched_tool_groups = set() for tc in (msg.get("tool_calls") or []): - if not isinstance(tc, dict): + variants = tool_call_id_variants(tc) + if not variants: continue - for key in ("id", "call_id"): - tc_id = tc.get(key) - if tc_id: - known_tool_ids.add(tc_id) + group_id = next_tool_group + next_tool_group += 1 + for tc_id in variants: + known_tool_ids.setdefault(tc_id, group_id) filtered.append(msg) elif role == "tool": - tc_id = msg.get("tool_call_id") - if tc_id and tc_id in known_tool_ids: + result_variants = tool_result_id_variants(msg.get("tool_call_id")) + candidate_groups = { + known_tool_ids[tc_id] + for tc_id in result_variants + if tc_id in known_tool_ids + and known_tool_ids[tc_id] not in matched_tool_groups + } + if not result_variants: + filtered.append(msg) + elif candidate_groups: + # Consume the whole alias group so a SECOND result replaying + # any sibling spelling falls into the drop branch below — + # strict providers reject duplicate tool_call_ids with HTTP + # 400 (#58327, #66974). Credit: #55436. + group_id = min(candidate_groups) filtered.append(msg) - # Consume the id so a SECOND tool result carrying the same - # tool_call_id (duplicate from a retry/crash/session-resume - # glitch) falls into the drop branch below instead of being - # replayed — strict providers (DeepSeek) reject a duplicate - # tool_call_id with HTTP 400 (#58327). Credit: #55436. - known_tool_ids.discard(tc_id) + matched_tool_groups.add(group_id) else: repairs += 1 else: @@ -740,7 +755,8 @@ def _is_verification_candidate(m: Dict) -> bool: # A user turn closes the tool-result run; subsequent # tool messages without a fresh assistant tool_call # are orphans. - known_tool_ids = set() + known_tool_ids = {} + matched_tool_groups = set() filtered.append(msg) # Pass 2: merge consecutive user messages. Preserves all user input @@ -755,6 +771,19 @@ def _is_verification_candidate(m: Dict) -> bool: and merged[-1].get("role") == "user" ): prev = merged[-1] + # A summary carrier followed by a new user row is a deliberate + # durable shape after retry/rewind. Do not absorb the fresh ask + # into the already-persisted carrier: mutating that dict can make + # the only in-memory copy diverge from its durable row. Provider + # sanitizers merge copies later when strict alternation requires + # it, without rewriting either durable message. + from agent.context_compressor import split_user_originated_turn + + handoff, _ = split_user_originated_turn(prev) + if handoff is not None: + merged.append(msg) + continue + prev_content = prev.get("content", "") new_content = msg.get("content", "") # Only merge plain-text content; leave multimodal (list) @@ -989,28 +1018,15 @@ def recover_with_credential_pool( # because swapping the pool's credentials would set base_url/api_key # without fixing the empty provider field, leaving the agent in a # corrupted state (provider="" model=""). - if pool_provider and current_provider != pool_provider: - # Custom endpoints use two naming conventions for the SAME provider: - # the agent carries the generic ``custom`` label while the pool is - # keyed ``custom:`` (see CUSTOM_POOL_PREFIX). A literal string - # compare treats them as a mismatch and skips recovery for every - # custom-provider user — 401s/429s then burn the full retry cycle - # with no rotation or refresh. Accept the pair as matching only when - # the agent's CURRENT base_url actually resolves to this pool key, - # so a fallback provider (or a different custom endpoint) still - # triggers the guard. - _custom_match = False - if current_provider == "custom" and pool_provider.startswith("custom:"): - try: - from agent.credential_pool import get_custom_provider_pool_key - _agent_base = (getattr(agent, "base_url", "") or "").strip() - _custom_match = bool(_agent_base) and ( - (get_custom_provider_pool_key(_agent_base) or "").strip().lower() - == pool_provider - ) - except Exception: - _custom_match = False - if not _custom_match: + if pool_provider: + # Use the same fail-closed boundary predicate as runtime binding. This + # recognizes configured named-custom aliases, validates endpoints even + # for exact custom:* identities, and preserves fallback isolation. + if not credential_pool_matches_provider( + pool, + current_provider, + base_url=getattr(agent, "base_url", None), + ): _ra().logger.warning( "Credential pool provider mismatch: pool=%s, agent=%s — " "skipping pool mutation to avoid cross-provider contamination", @@ -1425,8 +1441,6 @@ def drop_thinking_only_and_merge_users( ) ] dropped = len(messages) - len(kept) - if dropped == 0: - return messages # Pass 2: merge any newly-adjacent user messages. merged: List[Dict[str, Any]] = [] @@ -1476,6 +1490,9 @@ def drop_thinking_only_and_merge_users( else: merged.append(m) + if dropped == 0 and merges == 0: + return messages + _ra().logger.debug( "Pre-call sanitizer: dropped %d thinking-only assistant turn(s), " "merged %d adjacent user message(s)", @@ -1533,22 +1550,39 @@ def restore_primary_runtime(agent) -> bool: # pool-rebind block below via ``prefetched_primary_pool`` so the load # happens at most once per restore. prefetched_primary_pool = None + primary_pool_prefetched = False try: primary_provider = str( (agent._primary_runtime or {}).get("provider") or "" ).strip().lower() + primary_runtime_base_url = str( + (agent._primary_runtime or {}).get("base_url") or "" + ) + primary_pool_key = resolve_runtime_pool_key( + primary_provider, + primary_runtime_base_url, + ) pool = getattr(agent, "_credential_pool", None) if not credential_pool_matches_provider( pool, primary_provider, - base_url=str((agent._primary_runtime or {}).get("base_url") or ""), + base_url=primary_runtime_base_url, ): from agent.credential_pool import load_pool prefetched_primary_pool = ( - load_pool(primary_provider) if primary_provider else None + load_pool(primary_pool_key) if primary_pool_key else None ) - pool = prefetched_primary_pool + primary_pool_prefetched = True + if prefetched_primary_pool is not None and credential_pool_matches_provider( + prefetched_primary_pool, + primary_provider, + base_url=primary_runtime_base_url, + ): + pool = prefetched_primary_pool + else: + prefetched_primary_pool = None + pool = None next_at = getattr(pool, "next_available_at", lambda: None)() if next_at is not None and next_at > time.time(): if not getattr(agent, "_restore_wait_logged", False): @@ -1570,6 +1604,19 @@ def restore_primary_runtime(agent) -> bool: agent._restore_wait_logged = False rt = agent._primary_runtime + fallback_route = getattr(agent, "_provider_fallback_route", None) + if ( + isinstance(fallback_route, (list, tuple)) + and len(fallback_route) == 2 + ): + previous_model = str(fallback_route[0] or "unknown") + previous_provider = str(fallback_route[1] or "unknown") + else: + previous_model = str(getattr(agent, "model", "") or "unknown") + previous_provider = str(getattr(agent, "provider", "") or "unknown") + provider_fallback_active = bool( + getattr(agent, "_provider_fallback_active", False) + ) try: # ── Core runtime state ── agent.model = rt["model"] @@ -1643,34 +1690,44 @@ def restore_primary_runtime(agent) -> bool: # and disables credential rotation. Reload the primary pool first; if # auth storage is temporarily unreadable, clear the mismatched pool. primary_provider = str(rt.get("provider") or "").strip().lower() + primary_runtime_base_url = str(rt.get("base_url") or "") + primary_pool_key = resolve_runtime_pool_key( + primary_provider, + primary_runtime_base_url, + ) pool = getattr(agent, "_credential_pool", None) pool_provider = str(getattr(pool, "provider", "") or "").strip().lower() - pool_matches_primary = pool_provider == primary_provider - if ( - primary_provider == "custom" - and pool_provider.startswith("custom:") - ): - try: - from agent.credential_pool import get_custom_provider_pool_key - - primary_key = ( - get_custom_provider_pool_key(str(rt.get("base_url") or "")) or "" - ).strip().lower() - pool_matches_primary = bool(primary_key) and primary_key == pool_provider - except Exception: - pool_matches_primary = False + pool_matches_primary = credential_pool_matches_provider( + pool, + primary_provider, + base_url=primary_runtime_base_url, + ) if pool is not None and pool_provider and not pool_matches_primary: agent._credential_pool = None agent._credential_pool_entry_id = None try: - if prefetched_primary_pool is not None: + if primary_pool_prefetched: # Reuse the pool the reset-aware gate already loaded for # this restore — avoids a second disk read of auth.json. - agent._credential_pool = prefetched_primary_pool + if ( + prefetched_primary_pool is not None + and credential_pool_matches_provider( + prefetched_primary_pool, + primary_provider, + base_url=primary_runtime_base_url, + ) + ): + agent._credential_pool = prefetched_primary_pool else: from agent.credential_pool import load_pool - agent._credential_pool = load_pool(primary_provider) + loaded_pool = load_pool(primary_pool_key) + if loaded_pool is not None and credential_pool_matches_provider( + loaded_pool, + primary_provider, + base_url=primary_runtime_base_url, + ): + agent._credential_pool = loaded_pool except Exception as exc: logger.warning( "Restore could not reload primary credential pool for %s: %s", @@ -1692,30 +1749,11 @@ def restore_primary_runtime(agent) -> bool: entry = pool.select() if entry is not None: entry_provider = str(getattr(entry, "provider", "") or "").strip().lower() - entry_matches_primary = entry_provider == primary_provider - # Custom endpoints all carry the generic ``custom`` provider on - # the agent while the pool entry is keyed ``custom:`` (see - # CUSTOM_POOL_PREFIX). Resolve the primary's base_url to its - # ``custom:`` key via the canonical helper and compare - # against the entry's key — this mirrors the sibling guard in - # ``recover_with_credential_pool`` (see above) and correctly - # disambiguates multiple custom providers that share one gateway - # base_url. Fixes #56885. - from agent.credential_pool import CUSTOM_POOL_PREFIX - if ( - primary_provider == "custom" - and entry_provider.startswith(CUSTOM_POOL_PREFIX) - ): - entry_matches_primary = False - try: - from agent.credential_pool import get_custom_provider_pool_key - primary_base_url = str(rt.get("base_url") or "").strip() - primary_key = ( - get_custom_provider_pool_key(primary_base_url) or "" - ).strip().lower() - entry_matches_primary = bool(primary_key) and primary_key == entry_provider - except Exception: - entry_matches_primary = False + entry_matches_primary = credential_pool_matches_provider( + entry, + primary_provider, + base_url=primary_runtime_base_url, + ) entry_key = ( getattr(entry, "runtime_api_key", None) @@ -1767,6 +1805,18 @@ def restore_primary_runtime(agent) -> bool: "Primary runtime restored for new turn: %s (%s)", agent.model, agent.provider, ) + agent._provider_fallback_active = False + agent._provider_fallback_route = None + if provider_fallback_active: + try: + agent._emit_status( + f"✅ Primary model restored: {agent.model} via {agent.provider}; " + f"fallback {previous_model} via {previous_provider} is no longer active." + ) + except Exception: + # Notification surfaces are best-effort and must never undo a + # successful runtime restoration. + pass return True except Exception as e: logger.warning("Failed to restore primary runtime: %s", e) @@ -2291,29 +2341,93 @@ def anthropic_prompt_cache_policy( and (eff_provider == "anthropic" or base_url_hostname(eff_base_url) == "api.anthropic.com") ) - # A custom Anthropic-compatible route may use a bare model alias that is - # canonicalized only after Hermes sends the request. In that case model - # spelling cannot prove cache support. Honor an exact route+model - # capability declaration instead; explicit false is authoritative too. - # This preserves the runtime model id (and therefore request/cache keys) - # while avoiding unsafe alias-name guesses. + # A configured route may use an arbitrary provider name and model alias + # that are canonicalized only after Hermes sends the request. Honor its + # existing per-model ``prompt_caching`` capability instead of guessing + # support from either spelling. Explicit false is authoritative too. # - # Also consulted for a LiteLLM route on the OpenAI wire: that grant is - # inferred from the provider/host name, so an operator who explicitly - # declares prompt_caching for the route+model must still win over the - # inference — in either direction. Narrowed to the routes the LiteLLM - # branch below can actually grant (chat_completions + Claude): the lookup - # calls get_compatible_custom_providers, which rebuilds its normalized - # view on every call (~1.5ms uncached), and this function runs per - # request destination. Widening it unconditionally regressed the - # non-declaring common case ~200x (7.5us -> 1528us). + # The declaration only controls the two transports handled by this marker + # planner. Responses and Bedrock use separate caching protocols and must + # not receive Anthropic-style cache_control fields. custom_prompt_caching = None + _supports_anthropic_cache_markers = eff_api_mode in { + "anthropic_messages", + "chat_completions", + } _litellm_openai_wire = ( eff_api_mode == "chat_completions" and is_claude and _is_litellm_route(provider_lower, eff_base_url) ) - if is_anthropic_wire or _litellm_openai_wire: + _custom_providers = getattr(agent, "_custom_providers", None) + _route_may_be_custom = False + if not _supports_anthropic_cache_markers: + # Responses/Bedrock never consume the declaration — skip the + # identity probe entirely for those transports. + pass + elif _custom_providers: + # The normalized list is already attached after agent initialization. + # Use cheap runtime identity signals before calling the capability + # helper so an unrelated configured provider does not put every + # built-in chat-completions request on the route-normalization path. + # + # Identity must match the authoritative helper's semantics: + # get_custom_provider_model_capability compares base URLs via + # normalize_route_base_url, and runtime provider ids go through + # custom_provider_aliases (space→hyphen, custom: prefix variants). + # A raw-string gate here would silently drop declarations whose + # config spelling differs only in host case / trailing slash. + from hermes_cli.providers import custom_provider_aliases + from hermes_cli.route_identity import normalize_route_base_url + + _provider_ids = {provider_lower} + if provider_lower.startswith("custom:"): + _provider_ids.add(provider_lower.removeprefix("custom:")) + _eff_url_normalized = normalize_route_base_url(eff_base_url) + for _entry in _custom_providers: + if not isinstance(_entry, dict): + continue + _entry_ids = custom_provider_aliases( + str(_entry.get("name") or ""), + str(_entry.get("provider_key") or ""), + ) + if _provider_ids & _entry_ids or ( + _eff_url_normalized + and normalize_route_base_url(_entry.get("base_url")) + == _eff_url_normalized + ): + _route_may_be_custom = True + break + elif _custom_providers is None: + # None = the list is not attached yet (early agent initialization or + # a blank_cache_policy_stub destination); an attached empty list means + # the agent initialized with no custom providers and correctly never + # matches. Avoid rebuilding the list for ordinary built-in routes, + # while still recognizing arbitrary config keys and built-in-name + # overrides that point at a different endpoint. + try: + from hermes_cli.providers import get_provider + + # allow_network=False: this runs per request destination; a cold + # models.dev cache must not trigger a foreground registry fetch + # from the send path. A catalog miss (None) degrades to the + # conservative side (route may be custom → capability lookup). + _provider_def = get_provider(eff_provider, allow_network=False) + _route_may_be_custom = _provider_def is None or ( + bool(_provider_def.base_url) + and base_url_hostname(_provider_def.base_url) + != base_url_hostname(eff_base_url) + ) + except Exception as _pd_exc: + logger.debug( + "provider lookup failed during cache-policy pre-gate: %s", + _pd_exc, + ) + _route_may_be_custom = provider_lower.startswith("custom:") + + if _supports_anthropic_cache_markers and ( + is_anthropic_wire or _litellm_openai_wire or _route_may_be_custom + ): try: from hermes_cli.config import get_custom_provider_model_capability @@ -2321,7 +2435,7 @@ def anthropic_prompt_cache_policy( model=eff_model, base_url=eff_base_url, capability="prompt_caching", - custom_providers=getattr(agent, "_custom_providers", None), + custom_providers=_custom_providers, ) except Exception as _cap_exc: logger.debug( @@ -2329,10 +2443,9 @@ def anthropic_prompt_cache_policy( _cap_exc, ) if custom_prompt_caching is not None: - # Layout follows the transport, not the declaration: the native - # inner-block form is only honored on the Anthropic Messages wire - # (see the LiteLLM OpenAI-wire branch below for why a top-level - # marker is dropped or 400s on chat_completions). + # Layout follows the transport, not the declaration: native Messages + # uses inner-block markers; OpenAI-compatible chat uses the envelope + # layout already emitted for OpenRouter and LiteLLM. return custom_prompt_caching, custom_prompt_caching and is_anthropic_wire # MiniMax-M3 rides MiniMax's server-side automatic prefix cache on the @@ -2576,7 +2689,6 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo client_kwargs["default_headers"] = existing except Exception: _ra().logger.debug("Copilot default-header guard skipped", exc_info=True) - # OpenCode Free: the tier is served ANONYMOUSLY — any bearer the relay # doesn't recognize (including placeholders) is a 401. Route every # opencode-free client through the shared keyless header policy: an @@ -2588,6 +2700,16 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo _existing = dict(client_kwargs.get("default_headers") or {}) _existing.update(opencode_zen_free_headers()) client_kwargs["default_headers"] = _existing + + # All primary construction and recovery paths must identify Hermes to the + # official Codex endpoint, including snapshots with custom header overrides. + from agent.auxiliary_client import _apply_required_codex_headers + + _apply_required_codex_headers( + client_kwargs, + access_token=client_kwargs.get("api_key", ""), + base_url=str(client_kwargs.get("base_url", "")), + ) # Uses the module-level `OpenAI` name, resolved lazily on first # access via __getattr__ below. Tests patch via `run_agent.OpenAI`. client = _ra().OpenAI(**client_kwargs) @@ -3008,6 +3130,8 @@ def _restore_snapshot() -> None: # ── Reset fallback state ── agent._fallback_activated = False + agent._provider_fallback_active = False + agent._provider_fallback_route = None agent._fallback_index = 0 # When the user deliberately swaps primary providers (e.g. openrouter @@ -3456,6 +3580,16 @@ def _strip_tool_suffix(s: str) -> str | None: return None +def _tool_call_id_variants(tc: Any) -> set: + """Return every id a tool result might legitimately match this tool_call on. + + Thin forwarder — the policy owner is + ``agent.message_sanitization.tool_call_id_variants`` (handles ``id``, + ``call_id``, ``response_item_id``, and composite ``call|item`` spellings). + Kept for backward compatibility with existing importers. + """ + return set(tool_call_id_variants(tc)) + # Placeholder substituted for an empty non-final message that would otherwise # make the provider reject the whole request. Kept identical to the stub- @@ -3692,53 +3826,83 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] elif isinstance(tc, dict): tc["function"] = {"name": _EMPTY_NAME_SENTINEL, "arguments": "{}"} - surviving_call_ids: set = set() + assistant_call_variants: List[tuple[Any, frozenset[str]]] = [] + surviving_call_ids: set[str] = set() for msg in messages: - if msg.get("role") == "assistant": - for tc in msg.get("tool_calls") or []: - cid = _ra().AIAgent._get_tool_call_id_static(tc) - if cid: - surviving_call_ids.add(cid) + if msg.get("role") != "assistant": + continue + for tc in msg.get("tool_calls") or []: + # A tool_call may carry SEVERAL equivalent id spellings (``id`` + # fc_..., ``call_id`` call_..., ``response_item_id``, or a + # composite ``call|item`` bridge id). ``_get_tool_call_id_static`` + # returns only the preferred one, but a tool result keyed on any + # OTHER spelling would then look orphaned and get dropped + + # replaced with a bogus "[Result unavailable]" stub — silently + # eating a perfectly valid tool result (#55626, #63000). Register + # EVERY variant so a result matching any of them survives. + variants = tool_call_id_variants(tc) + if variants: + assistant_call_variants.append((tc, variants)) + surviving_call_ids.update(variants) + + result_entries = [ + (msg, tool_result_id_variants(msg.get("tool_call_id"))) + for msg in messages + if msg.get("role") == "tool" + ] - result_call_ids: set = set() - for msg in messages: - if msg.get("role") == "tool": - cid = (msg.get("tool_call_id") or "").strip() - if cid: - result_call_ids.add(cid) - - # 1. Drop tool results with no matching assistant call - orphaned_results = result_call_ids - surviving_call_ids - if orphaned_results: - messages = [ - m for m in messages - if not (m.get("role") == "tool" and (m.get("tool_call_id") or "").strip() in orphaned_results) + # 1. Drop tool results whose complete alias set matches no assistant call. + orphaned_result_objects = { + id(msg) + for msg, variants in result_entries + if variants and not (variants & surviving_call_ids) + } + if orphaned_result_objects: + messages = [m for m in messages if id(m) not in orphaned_result_objects] + result_entries = [ + (msg, variants) + for msg, variants in result_entries + if id(msg) not in orphaned_result_objects ] _ra().logger.debug( "Pre-call sanitizer: removed %d orphaned tool result(s)", - len(orphaned_results), + len(orphaned_result_objects), ) - # 2. Inject stub results for calls whose result was dropped - missing_results = surviving_call_ids - result_call_ids - if missing_results: + # 2. Inject one stub per assistant call with no result on ANY alias. + surviving_result_variants = [ + variants for _, variants in result_entries if variants + ] + missing_tool_calls = [ + tc + for tc, variants in assistant_call_variants + if not any(variants & result_variants for result_variants in surviving_result_variants) + ] + if missing_tool_calls: + missing_tool_call_objects = {id(tc) for tc in missing_tool_calls} patched: List[Dict[str, Any]] = [] for msg in messages: patched.append(msg) if msg.get("role") == "assistant": for tc in msg.get("tool_calls") or []: - cid = _ra().AIAgent._get_tool_call_id_static(tc) - if cid in missing_results: - patched.append({ - "role": "tool", - "name": _ra().AIAgent._get_tool_call_name_static(tc), - "content": "[Result unavailable — see context summary above]", - "tool_call_id": cid, - }) + if id(tc) not in missing_tool_call_objects: + continue + cid = coalesce_tool_call_id(tc) + if not cid: + variants = tool_call_id_variants(tc) + cid = sorted(variants)[0] if variants else "" + if not cid: + continue + patched.append({ + "role": "tool", + "name": _ra().AIAgent._get_tool_call_name_static(tc), + "content": "[Result unavailable — see context summary above]", + "tool_call_id": cid, + }) messages = patched _ra().logger.debug( "Pre-call sanitizer: added %d stub tool result(s)", - len(missing_results), + len(missing_tool_calls), ) # 3. Deduplicate tool_call_ids. Strict providers (DeepSeek) reject a @@ -3761,8 +3925,19 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] # semantics keep both protections intact: a re-emitted result still # answers no pending call and is still dropped, while a genuine new call # that reuses the id re-arms that id first. + # Variant-group tracking: answering or deduping one spelling consumes + # its siblings too. A Codex/Responses tool_call registers ``id`` + # (fc_...), ``call_id`` (call_...), ``response_item_id``, and composite + # spellings (#55626/#58168/#63000); tracking only the coalesced id here + # made a result keyed on any OTHER variant look like it answered no + # outstanding call, so this pass deleted the very result step 2's + # variant-aware matching had just preserved (issue #93251 — whole + # parallel batches vanished). seen_assistant_call_ids: set = set() outstanding_call_ids: set = set() + outstanding_groups: Dict[int, frozenset] = {} + variant_to_group: Dict[str, int] = {} + next_group_id = 0 deduped: List[Dict[str, Any]] = [] removed_dupes = 0 for msg in messages: @@ -3770,13 +3945,18 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] if role == "assistant" and msg.get("tool_calls"): kept_tcs = [] for tc in msg.get("tool_calls") or []: - cid = _ra().AIAgent._get_tool_call_id_static(tc) - if cid and cid in seen_assistant_call_ids: + variants = tool_call_id_variants(tc) + if variants and variants & seen_assistant_call_ids: removed_dupes += 1 continue - if cid: - seen_assistant_call_ids.add(cid) - outstanding_call_ids.add(cid) + if variants: + group_id = next_group_id + next_group_id += 1 + outstanding_groups[group_id] = variants + for variant in variants: + seen_assistant_call_ids.add(variant) + outstanding_call_ids.add(variant) + variant_to_group.setdefault(variant, group_id) kept_tcs.append(tc) if kept_tcs: msg = {**msg, "tool_calls": kept_tcs} @@ -3784,16 +3964,28 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] msg = {k: v for k, v in msg.items() if k != "tool_calls"} deduped.append(msg) elif role == "tool": - cid = (msg.get("tool_call_id") or "").strip() - if cid and cid not in outstanding_call_ids: + result_variants = tool_result_id_variants(msg.get("tool_call_id")) + candidate_groups = { + variant_to_group[variant] + for variant in result_variants + if variant in variant_to_group + and variant in outstanding_call_ids + } + if result_variants and not candidate_groups: removed_dupes += 1 continue - if cid: - # Answered: this id is no longer outstanding, so a second - # result replaying it is still caught above. - outstanding_call_ids.discard(cid) - # A reused id must be re-armable by the next assistant call. - seen_assistant_call_ids.discard(cid) + if candidate_groups: + # Answered: consume EVERY variant of the matched call so a + # second result replaying any sibling spelling is still + # caught above, and the ids are re-armable by the next + # assistant call that reuses them. + group_id = min(candidate_groups) + group_variants = outstanding_groups.pop(group_id, frozenset()) + for variant in group_variants: + outstanding_call_ids.discard(variant) + seen_assistant_call_ids.discard(variant) + if variant_to_group.get(variant) == group_id: + variant_to_group.pop(variant, None) deduped.append(msg) else: deduped.append(msg) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index ce311fa1e358..07e801a90247 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -15,6 +15,7 @@ import logging import os import platform +import re import secrets import stat import subprocess @@ -439,6 +440,137 @@ def _detect_claude_code_version() -> str: _CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude." _MCP_TOOL_PREFIX = "mcp__" +# Bare multi-segment Hermes tool names frequently mentioned in the system +# prompt. Even when tool *schemas* are rewritten to ``mcp__*`` on the OAuth +# wire, Anthropic's subscription billing classifier still scans system-prompt +# prose for these identifiers and routes the request to the unpaid "extra +# usage" pool (HTTP 400 "You're out of extra usage"). Keep this list focused +# on snake_case fingerprints — single-word English tools (memory/process/ +# patch/todo/clarify/terminal) are only rewritten when backtick-wrapped so +# ordinary prose is preserved. +_OAUTH_SYSTEM_TOOL_FINGERPRINTS = ( + "skill_manage", + "session_search", + "skill_view", + "skills_list", + "delegate_task", + "execute_code", + "search_files", + "read_file", + "write_file", + "web_search", + "web_extract", + "text_to_speech", + "vision_analyze", + "video_analyze", + "video_generate", + "tool_search", + "tool_describe", + "tool_call", + "ha_call_service", + "ha_get_state", + "ha_list_entities", + "ha_list_services", + "browser_navigate", + "browser_snapshot", + "browser_click", + "browser_type", + "browser_scroll", + "browser_back", + "browser_press", + "browser_console", + "browser_vision", + "browser_get_images", +) + +# Single-token tool names that are also common English. Only rewrite when the +# system prompt references them as a tool identifier inside backticks. +_OAUTH_SYSTEM_BACKTICK_ONLY_TOOLS = ( + "memory", + "process", + "patch", + "todo", + "clarify", + "terminal", +) + + +def _oauth_bare_tool_name(name: str) -> str: + """Strip any ``mcp__`` / ``mcp_`` wire prefix down to the bare tool id.""" + if not name: + return "" + if name.startswith("mcp__"): + return name[len("mcp__"):] + if name.startswith("mcp_"): + return name[len("mcp_"):] + return name + + +def _oauth_sanitize_system_text(text: str, tool_names: Optional[List[str]] = None) -> str: + """Rewrite third-party fingerprints in system text for Anthropic OAuth. + + Product-name swaps keep the Claude Code identity consistent. Tool-name + rewrites align prose mentions with the ``mcp__`` wire names used for tool + schemas so the subscription billing classifier does not treat the prompt + as a third-party agent (empirically: bare ``skill_manage`` / + ``session_search`` in system text alone flips a request onto the empty + extra-usage pool while the same request with ``mcp__skill_manage`` / + ``mcp__session_search`` bills against the plan). + """ + if not text: + return text + + text = text.replace("Hermes Agent", "Claude Code") + text = text.replace("Hermes agent", "Claude Code") + text = text.replace("hermes-agent", "claude-code") + text = text.replace("Nous Research", "Anthropic") + + bare_from_tools = { + _oauth_bare_tool_name(n) + for n in (tool_names or []) + if n + } + # Multi-segment (snake_case) names: rewrite on identifier boundaries. + multi = { + n for n in (bare_from_tools | set(_OAUTH_SYSTEM_TOOL_FINGERPRINTS)) + if n and "_" in n and not n.startswith("mcp") + } + for name in sorted(multi, key=len, reverse=True): + text = re.sub( + rf"(? Any: + """Recursively sanitize string leaves in tool JSON (descriptions/schemas).""" + if isinstance(value, str): + return _oauth_sanitize_system_text(value, tool_names=tool_names) + if isinstance(value, list): + return [_oauth_sanitize_json_values(v, tool_names=tool_names) for v in value] + if isinstance(value, dict): + return { + k: _oauth_sanitize_json_values(v, tool_names=tool_names) + for k, v in value.items() + } + return value + def _get_claude_code_version() -> str: """Lazily detect the installed Claude Code version when OAuth headers need it.""" @@ -2985,6 +3117,12 @@ def build_anthropic_kwargs( # ── OAuth: Claude Code identity ────────────────────────────────── if is_oauth: + # Collect bare tool ids BEFORE wire-prefixing so system-prompt prose + # can be aligned with the mcp__ names that go on the wire. + _oauth_tool_names = [ + t.get("name", "") for t in (anthropic_tools or []) if isinstance(t, dict) + ] + # 1. Prepend Claude Code system prompt identity cc_block = {"type": "text", "text": _CLAUDE_CODE_SYSTEM_PREFIX} if isinstance(system, list): @@ -2994,16 +3132,18 @@ def build_anthropic_kwargs( else: system = [cc_block] - # 2. Sanitize system prompt — replace product name references - # to avoid Anthropic's server-side content filters. + # 2. Sanitize system prompt — product names AND bare tool identifiers. + # Tool schemas alone are not enough: Anthropic's OAuth billing + # classifier also fingerprints system-prompt prose. Bare mentions + # of Hermes tool ids (skill_manage, session_search, …) route the + # request onto the empty "extra usage" pool even when schemas use + # mcp__* wire names. Rewrite those mentions to match the wire. for block in system: if isinstance(block, dict) and block.get("type") == "text": - text = block.get("text", "") - text = text.replace("Hermes Agent", "Claude Code") - text = text.replace("Hermes agent", "Claude Code") - text = text.replace("hermes-agent", "claude-code") - text = text.replace("Nous Research", "Anthropic") - block["text"] = text + block["text"] = _oauth_sanitize_system_text( + block.get("text", ""), + tool_names=_oauth_tool_names, + ) # 3. Normalize tool names so NOTHING goes on the OAuth wire with a # single-underscore ``mcp_`` prefix. Anthropic's subscription/OAuth @@ -3035,6 +3175,23 @@ def _to_oauth_wire_name(name: str) -> str: for tool in anthropic_tools: if "name" in tool: tool["name"] = _to_oauth_wire_name(tool["name"]) + # Tool descriptions / schemas also carry bare Hermes tool ids + # (cross-references like "prefer session_search"). Anthropic's + # OAuth billing classifier scans the whole request, not just + # system prose + tool *names* — leave those bare and a request + # that otherwise looks like Claude Code still lands on the + # empty extra-usage pool. + if isinstance(tool.get("description"), str) and tool["description"]: + tool["description"] = _oauth_sanitize_system_text( + tool["description"], + tool_names=_oauth_tool_names, + ) + schema = tool.get("input_schema") + if isinstance(schema, dict): + tool["input_schema"] = _oauth_sanitize_json_values( + schema, + tool_names=_oauth_tool_names, + ) # 4. Apply the same normalization to tool names in message history # (tool_use blocks) so replayed turns match the wire names above. diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index b5aeae274f09..4cc501670258 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -161,7 +161,11 @@ def aux_probe_mode(): _aux_probe_state.active = prev from agent.credential_pool import load_pool -from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length +from agent.model_metadata import ( + MINIMUM_CONTEXT_LENGTH, + get_model_context_length, + strip_codex_context_variant_suffix as _strip_codex_ctx_variant, +) from hermes_cli.config import get_hermes_home 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 @@ -191,7 +195,7 @@ def _resolve_aux_verify(base_url: Optional[str]) -> Any: """Resolve httpx ``verify`` for an auxiliary-client base_url. Mirrors the main client's TLS resolution so auxiliary calls (compression, - vision, web_extract, title generation, etc.) honor per-provider + vision, title generation, etc.) honor per-provider ``ssl_ca_cert`` / ``ssl_verify`` config and the ``HERMES_CA_BUNDLE`` / ``SSL_CERT_FILE`` env conventions. Best-effort: any failure falls back to the httpx/certifi default (``True``). @@ -277,6 +281,7 @@ def _create_openai_client(*, api_key: str, base_url: str, **kwargs: Any) -> Any: kwargs["default_headers"] = merged except Exception: pass + _apply_required_codex_headers(kwargs, access_token=api_key, base_url=base_url) # Hermes owns auxiliary retry + provider/model fallback policy (the # same-provider transient retry in call_llm plus the except-chain # fallback). The OpenAI SDK's own default (max_retries=2 → up to 3 @@ -674,11 +679,19 @@ def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = Non ``compression.codex_gpt55_autoraise`` config key.) The exact ``gpt-daybreak-blue-latest`` Codex slug is also a verified Sol-family alias and receives the same autoraise. + + ``-900k`` large-context picker variants are explicitly EXCLUDED: the + 85% autoraise exists to stop wasting a small 272K window, and a 900K + window doesn't have that problem — those sessions keep the user's + global ``compression.threshold`` (default 50%, ~450K). """ prov = (provider or "").strip().lower() if prov != "openai-codex": return False bare = (model or "").strip().lower().rsplit("/", 1)[-1] + from agent.model_metadata import is_codex_context_variant + if is_codex_context_variant(bare): + return False return ( bare == "gpt-5.4" or bare.startswith("gpt-5.4-") @@ -1210,19 +1223,31 @@ def _nous_extra_body() -> dict: _CODEX_AUX_BASE_URL = "https://chatgpt.com/backend-api/codex" -def _codex_cloudflare_headers(access_token: str) -> Dict[str, str]: - """Headers required to avoid Cloudflare 403s on chatgpt.com/backend-api/codex. +def _is_official_codex_base_url(base_url: str) -> bool: + """Identify OpenAI's Codex endpoint without matching custom proxies.""" + try: + parsed = urlparse(base_url) + path = parsed.path.rstrip("/") + return ( + parsed.scheme == "https" + and parsed.hostname == "chatgpt.com" + and parsed.port in (None, 443) + and (path == "/backend-api/codex" or path.startswith("/backend-api/codex/")) + ) + except (TypeError, ValueError): + return False + - The Cloudflare layer in front of the Codex endpoint whitelists a small set of - first-party originators (``codex_cli_rs``, ``codex_vscode``, ``codex_sdk_ts``, - anything starting with ``Codex``). Requests from non-residential IPs (VPS, - server-hosted agents) that don't advertise an allowed originator are served - a 403 with ``cf-mitigated: challenge`` regardless of auth correctness. +def _codex_cloudflare_headers( + access_token: str, *, base_url: str = _CODEX_AUX_BASE_URL, +) -> Dict[str, str]: + """Identity and account headers for chatgpt.com/backend-api/codex. - We pin ``originator: codex_cli_rs`` to match the upstream codex-rs CLI, set - ``User-Agent`` to a codex_cli_rs-shaped string (beats SDK fingerprinting), - and extract ``ChatGPT-Account-ID`` (canonical casing, from codex-rs - ``auth.rs``) out of the OAuth JWT's ``chatgpt_account_id`` claim. + OpenAI requires third-party harnesses to identify themselves. Requests to + the official endpoint always send Hermes' originator and version. Custom + endpoints retain the existing compatibility identity. In either case, + preserve ``ChatGPT-Account-ID`` from the OAuth JWT's + ``chatgpt_account_id`` claim. Malformed tokens are tolerated — we drop the account-ID header rather than raise, so a bad token still surfaces as an auth error (401) instead of a @@ -1232,6 +1257,13 @@ def _codex_cloudflare_headers(access_token: str) -> Dict[str, str]: "User-Agent": "codex_cli_rs/0.0.0 (Hermes Agent)", "originator": "codex_cli_rs", } + if _is_official_codex_base_url(base_url): + from hermes_cli import __version__ + + headers.update({ + "User-Agent": f"HermesAgent/{__version__}", + "originator": "hermes-agent", + }) if not isinstance(access_token, str) or not access_token.strip(): return headers try: @@ -1249,6 +1281,22 @@ def _codex_cloudflare_headers(access_token: str) -> Dict[str, str]: return headers +def _apply_required_codex_headers( + client_kwargs: Dict[str, Any], *, access_token: str, base_url: str, +) -> None: + """Keep required Codex identity after user/provider header overrides.""" + if not _is_official_codex_base_url(base_url): + return + required = _codex_cloudflare_headers(access_token, base_url=base_url) + required_names = {name.lower() for name in required} + existing = client_kwargs.get("default_headers") or {} + client_kwargs["default_headers"] = { + **{name: value for name, value in existing.items() + if str(name).lower() not in required_names}, + **required, + } + + # Hosts that expose BOTH an Anthropic-style ``…/anthropic`` path and a sibling # OpenAI-compatible ``…/v1`` (or vendor-specific OpenAI path). Unconditional # ``/anthropic`` → ``/v1`` rewrites break Anthropic-only gateways such as @@ -1531,7 +1579,10 @@ def create(self, **kwargs) -> Any: ) resp_kwargs: Dict[str, Any] = { - "model": model, + # Strip the Hermes-side ``-900k`` large-context picker suffix — + # the Codex backend only knows the base slug (mirrors the main + # transport in agent/transports/codex.py::build_kwargs). + "model": _strip_codex_ctx_variant(model), "instructions": instructions, "input": input_items or [{"role": "user", "content": ""}], "store": False, @@ -1791,10 +1842,16 @@ def _check_cancelled() -> None: # Consuming raw events and assembling the final response # ourselves from ``response.output_item.done`` makes us # structurally immune to that drift. - from agent.codex_runtime import _consume_codex_event_stream + from agent.codex_runtime import ( + _bypass_sdk_request_transform, + _consume_codex_event_stream, + ) stream_kwargs = dict(resp_kwargs) stream_kwargs["stream"] = True + # #93650: keep bulk wire-format payload out of the SDK's + # GIL-holding request transform on auxiliary calls too. + stream_kwargs = _bypass_sdk_request_transform(stream_kwargs) def _on_each_event(_event: Any) -> None: # Re-check timeout/cancellation per event, matching the @@ -3783,7 +3840,7 @@ def _build_codex_client(model: str) -> Tuple[Optional[Any], Optional[str]]: real_client = _create_openai_client( api_key=codex_token, base_url=base_url, - default_headers=_codex_cloudflare_headers(codex_token), + default_headers=_codex_cloudflare_headers(codex_token, base_url=base_url), ) return CodexAuxiliaryClient(real_client, model), model @@ -5557,7 +5614,7 @@ def _task_minimum_context_length(task: Optional[str]) -> Optional[int]: Only ``compression`` carries an explicit minimum today (the same ``MINIMUM_CONTEXT_LENGTH`` (64K) floor that ``check_compression_model_feasibility`` already enforces at startup). - Other tasks (``vision``, ``title_generation``, ``web_extract``, + Other tasks (``vision``, ``title_generation``, ``skills_hub``, ``mcp``, ``session_search``) return ``None`` — they have no per-task context floor and the runtime chain must remain permissive for them. @@ -6180,6 +6237,10 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): async_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"} elif base_url_host_matches(sync_base_url, "integrate.api.nvidia.com"): async_kwargs["default_headers"] = build_nvidia_nim_headers(sync_base_url) + elif _is_official_codex_base_url(sync_base_url): + async_kwargs["default_headers"] = _codex_cloudflare_headers( + sync_client.api_key, base_url=sync_base_url, + ) elif base_url_host_matches(sync_base_url, "x.ai"): from tools.xai_http import hermes_xai_default_headers @@ -6201,6 +6262,9 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): _merged_async = _apply_user_default_headers(async_kwargs.get("default_headers")) if _merged_async: async_kwargs["default_headers"] = _merged_async + _apply_required_codex_headers( + async_kwargs, access_token=sync_client.api_key, base_url=sync_base_url, + ) async_kwargs = { **_openai_http_client_kwargs(sync_base_url, async_mode=True), **async_kwargs, @@ -7118,11 +7182,11 @@ def get_text_auxiliary_client( """Return (client, default_model_slug) for text-only auxiliary tasks. Args: - task: Optional task name ("compression", "web_extract") to check + task: Optional task name ("compression", "skills_hub") to check for a task-specific provider override. Callers may override the returned model via config.yaml - (e.g. auxiliary.compression.model, auxiliary.web_extract.model). + (e.g. auxiliary.compression.model, auxiliary.skills_hub.model). """ provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(task or None) return resolve_provider_client( @@ -9375,7 +9439,7 @@ def _call_llm_impl( handles auth, request formatting, and model-specific arg adjustments. Args: - task: Auxiliary task name ("compression", "vision", "web_extract", + task: Auxiliary task name ("compression", "vision", "session_search", "skills_hub", "mcp", "title_generation"). Reads provider:model from config/env. Ignored if provider is set. provider: Explicit provider override. diff --git a/agent/background_review.py b/agent/background_review.py index f48a3d5200d0..1c041ab3450a 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -125,7 +125,11 @@ def _interrupt() -> None: try: from agent.interrupt_compat import request_hard_interrupt - request_hard_interrupt(review_agent, "superseded by a new live turn") + request_hard_interrupt( + review_agent, + "superseded by a new live turn", + tool_reason="background review superseded", + ) except Exception: logger.debug( "Failed to cancel in-flight background review for a new turn", @@ -200,6 +204,23 @@ def cancel_background_review_for_live_turn(agent: Any) -> None: # Historical hardcoded iteration budget for the review fork. _REVIEW_MAX_ITERATIONS = 16 +# Default aggregate INPUT-token budget for one review fork (#93057). The +# fork's first request replays the full snapshot — a warm prompt-cache read +# that is cheap and intended (cache parity), which is why both compression +# gates are deferred until the first provider response arrives +# (_review_fork_first_request_pending in agent/turn_context.py). After that, +# detached in-memory compaction bounds each request to roughly the +# compression threshold, but nothing capped the SUM across the review's tool +# loop: one production review made 8 requests replaying 1,487,951 input +# tokens total (four of them at 350k-384k). This budget caps the aggregate; +# the review tool loop stops before the provider call that would cross it +# (see ``_review_input_budget_exhausted`` in agent/conversation_loop.py). +# 2x the historical 300k foreground trigger keeps legitimate reviews +# comfortable while capping the pathological case. Override with +# ``auxiliary.background_review.max_input_tokens``; 0 or a negative value +# disables the cap (unbounded = pre-fix behavior). +_REVIEW_MAX_INPUT_TOKENS_DEFAULT = 600_000 + def _background_review_task_config( task_cfg: Optional[Dict[str, Any]] = None, @@ -222,6 +243,26 @@ def _background_review_task_config( return task if isinstance(task, dict) else {} +def _review_input_token_budget( + task_cfg: Optional[Dict[str, Any]] = None, +) -> Optional[int]: + """Aggregate input-token budget for one review fork (None = unlimited). + + Reads ``auxiliary.background_review.max_input_tokens``; falls back to + :data:`_REVIEW_MAX_INPUT_TOKENS_DEFAULT`. ``0`` or a negative value + disables the cap explicitly. + """ + task = _background_review_task_config(task_cfg) + raw = task.get("max_input_tokens", _REVIEW_MAX_INPUT_TOKENS_DEFAULT) + try: + budget = int(raw) + except (TypeError, ValueError): + budget = _REVIEW_MAX_INPUT_TOKENS_DEFAULT + if budget <= 0: + return None + return budget + + def load_background_review_settings() -> tuple[bool, Dict[str, Any]]: """Single config read for the automatic-review gate + task block. @@ -439,8 +480,10 @@ def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict] " 1. UPDATE A CURRENTLY-LOADED SKILL. Look back through the " "conversation for skills the user loaded via /skill-name or you " "read via skill_view. If any of them covers the territory of the " - "new learning, PATCH that one first. It is the skill that was in " - "play, so it's the right one to extend — but only if it is " + "new learning, PATCH that one first (re-load it with skill_view " + "during this review — see Read-before-write below). It is the " + "skill that was in play, so it's the right one to extend — but " + "only if it is " "curator-managed. Bundled, hub, pinned, and user-owned skills are " "off-limits to you no matter how relevant (see Protected skills " "below); for those, fall through to the next option.\n" @@ -473,6 +516,18 @@ def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict] "codename, library-alone name, or 'fix-X / debug-Y / audit-Z-today' " "session artifact. If the proposed name only makes sense for " "today's task, it's wrong — fall back to (1), (2), or (3).\n\n" + "Read-before-write (ENFORCED — skill_manage refuses otherwise): " + "before you patch or edit an existing skill's SKILL.md, call " + "skill_view(name) for that skill during this review. Before you " + "overwrite or remove an EXISTING supporting file, call " + "skill_view(name, file_path=...) for that exact file. Content " + "quoted earlier in the conversation transcript does NOT count — " + "the guard requires a fresh load within this review, and your " + "write must be based on what skill_view just returned. Creating " + "a brand-new skill or adding a NEW supporting file needs no " + "prior read. If a write is refused with a read-before-write " + "error, call skill_view for the named target once and retry the " + "write once; do not loop.\n\n" "User-preference embedding (important): when the user expressed a " "style/format/workflow preference, the update belongs in the " "SKILL.md body, not just in memory. Memory captures 'who the user " @@ -559,7 +614,9 @@ def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict] "Preference order for skills — pick the earliest that fits:\n" " 1. UPDATE A CURRENTLY-LOADED SKILL. Check what skills were " "loaded via /skill-name or skill_view in the conversation. If one " - "of them covers the learning, PATCH it first. It was in play; " + "of them covers the learning, PATCH it first (re-load it with " + "skill_view during this review — see Read-before-write below). " + "It was in play; " "it's the right place — provided it is curator-managed. Protected " "and user-owned skills are off-limits however relevant; fall " "through when one of those is the best fit.\n" @@ -579,6 +636,15 @@ def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict] "codename, library-alone name, or 'fix-X / debug-Y' session " "artifact. If the name only fits today's task, fall back to (1), " "(2), or (3).\n\n" + "Read-before-write (ENFORCED — skill_manage refuses otherwise): " + "before patching or editing an existing skill's SKILL.md, call " + "skill_view(name) during this review; before overwriting or " + "removing an EXISTING supporting file, call skill_view(name, " + "file_path=...) for that exact file. Content quoted earlier in " + "the transcript does NOT count — base the write on what " + "skill_view just returned. New skills and NEW supporting files " + "need no prior read. On a read-before-write refusal: view the " + "named target once, retry the write once, do not loop.\n\n" "User-preference embedding: when the user complains about how " "you handled a task, update the skill that governs that task — " "memory alone isn't enough. Memory says 'who the user is and " @@ -1269,17 +1335,93 @@ def _finish_request_phase(agent_ref) -> None: # conversation (the review fires every ~10 turns). Leave session # finalization to the real owner (CLI close / gateway reset / cron). review_agent._end_session_on_close = False - # Never let the review fork compress. It shares the parent's - # session_id, so if it won a compression race it would rotate the - # parent into a NEW child that the gateway never adopts (the fork - # is single-lifecycle and dies right after this run_conversation). - # The foreground turn would then start from the stale parent and - # compress it again, leaving the same parent with two sibling - # children (issue #38727). Review also needs full context to - # produce a good memory/skill summary — compressing would strip - # detail. Both compression triggers in conversation_loop.py gate on - # agent.compression_enabled, so this short-circuits both paths. - review_agent.compression_enabled = False + # DETACHED IN-MEMORY COMPACTION (issue #93057). The fork shares + # the parent's session_id (pinned above for prefix-cache parity), + # so the historical guard here was ``compression_enabled = False``: + # if the fork ran the ordinary compression path it could rotate / + # archive the parent's live session — the sibling-session race + # behind #38727. But disabling compaction was a proxy for + # detachment, and it removed the ONLY bound on the review's + # private snapshot: as the review performs tool calls, every + # follow-up provider request replayed the snapshot plus the + # growing review tool loop (350k-384k input tokens per request in + # production, 1.49M total across one 8-request review). + # + # The fix is detachment, not disablement: + # • Persistence is already off above (_persist_disabled / + # _session_db=None), so the commit site in compress_context + # (``if agent._session_db:``) skips every durable write and + # compaction can only ever rewrite the fork's private + # in-memory transcript. + # • The compressor's OWN session binding still needs severing: + # AIAgent.__init__ bound it to the parent's SessionDB and + # session_id before this function nulled the agent-level + # binding, so durable cooldown/streak/ineffective-count + # writes would otherwise land on the parent's row. Rebinding + # with session_db=None / session_id="" makes every + # compressor persist guard a no-op. + # • Force in-place mode (never rotation) even if the parent's + # config selected rotation, and re-enable compression ONLY + # after the rebind succeeds (fail-closed — see below). While + # enabled, both compression gates stay deferred until the + # fork's first provider response so request #1 replays the + # full snapshot as a warm cache read. + _review_compressor = getattr(review_agent, "context_compressor", None) + _bind_review_compressor = getattr( + _review_compressor, "bind_session_state", None + ) + _review_compression_detached = False + if callable(_bind_review_compressor): + try: + # Plugin/third-party context engines may not accept these + # kwargs; they own their own persistence policy, so a + # failed rebind leaves the pre-existing flags in place + # and must never abort the review (same tolerance as the + # init-time binding in agent_init.py). + _bind_review_compressor(session_db=None, session_id="") + _review_compression_detached = True + except Exception: + # FAIL-CLOSED (adversarial review, #93057): if the rebind + # could not sever the engine's session binding, the + # compressor may still point at the parent's + # SessionDB/session_id. Enabling compression in that + # state would let durable cooldown/streak/ineffective- + # count writes land on the parent's row and re-open the + # #38727 sibling race. Keep the historical + # compression_enabled=False behavior instead and warn; + # the review still runs, bounded by the iteration cap + # and the aggregate input budget below. + logger.warning( + "background-review compressor detachment failed; " + "keeping compression DISABLED on this review fork " + "(fail-closed, issue #93057 / #38727)", + exc_info=True, + ) + # Force in-place mode (never rotation) even if the parent's + # config selected rotation. Re-enable compression ONLY after the + # compressor's session binding was successfully severed; an + # engine without a bind hook keeps the historical disabled + # behavior as well. + review_agent.compression_in_place = True + review_agent.compression_enabled = _review_compression_detached + if _review_compression_detached: + # Warm-cache parity: the fork's FIRST provider request + # replays the parent's full snapshot as a warm prompt-cache + # read, so compaction must not rewrite the snapshot before + # that first request goes out. Defer both compression gates + # until the first provider response arrives (see + # _review_fork_first_request_pending in agent/turn_context.py + # and the pre-API gate in agent/conversation_loop.py); from + # the second request on, the fork's transcript is its own and + # compaction bounds it. + review_agent._review_defer_compaction_before_first_response = True + # Aggregate input budget: compaction bounds any single request; + # this bounds the WHOLE review. Iterations are already capped by + # _REVIEW_MAX_ITERATIONS. Checked in agent/conversation_loop.py + # via _review_input_budget_exhausted (issue #93057). + review_agent._review_input_token_budget = _review_input_token_budget( + task_cfg + ) # Register this fork on the PARENT's _active_children (the same # list interrupt() fans out to for subagent delegation) and diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 7af486dc5bb8..e63e28f3bd34 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2423,6 +2423,41 @@ def _fallback_entry_unavailable_without_network(agent, fb: dict) -> Optional[str return None +def _fallback_reason_text(reason: "FailoverReason | None") -> str: + """Return a concise operator-facing explanation for a fallback switch.""" + if reason is None: + return "provider failure" + labels = { + FailoverReason.auth: "authentication failed", + FailoverReason.auth_permanent: "authentication permanently failed", + FailoverReason.billing: "billing or quota exhausted", + FailoverReason.rate_limit: "rate limit", + FailoverReason.upstream_rate_limit: "upstream model rate limit", + FailoverReason.overloaded: "provider overloaded", + FailoverReason.server_error: "provider server error", + FailoverReason.timeout: "request timeout", + FailoverReason.ssl_cert_verification: "TLS certificate verification failed", + FailoverReason.context_overflow: "context window exceeded", + FailoverReason.payload_too_large: "request payload too large", + FailoverReason.image_too_large: "image payload too large", + FailoverReason.model_not_found: "model not found", + FailoverReason.provider_policy_blocked: "provider policy blocked the request", + FailoverReason.content_policy_blocked: "content policy blocked the request", + FailoverReason.format_error: "request format rejected", + FailoverReason.invalid_encrypted_content: "encrypted reasoning state rejected", + FailoverReason.multimodal_tool_content_unsupported: "multimodal tool content unsupported", + FailoverReason.thinking_signature: "thinking signature rejected", + FailoverReason.long_context_tier: "long-context tier unavailable", + FailoverReason.oauth_long_context_beta_forbidden: "OAuth long-context beta unavailable", + FailoverReason.llama_cpp_grammar_pattern: "grammar pattern rejected", + FailoverReason.unknown: "provider failure", + } + label = labels.get(reason) + if label: + return label + value = getattr(reason, "value", None) + return str(value or reason or "provider failure").replace("_", " ") + def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool: """Switch to the next fallback model/provider in the chain. @@ -2814,20 +2849,26 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool # answering, so "what model are you?" doesn't report the primary. rewrite_prompt_model_identity(agent, fb_model, fb_provider) - agent._buffer_status( - f"🔄 Primary model failed — switching to fallback: " - f"{fb_model} via {fb_provider}" - ) - # The buffered line above is dropped on successful recovery, but a - # provider/model switch is a durable state change operators must see - # even when the fallback succeeds. Record a one-shot notice that the - # success path surfaces exactly once via _emit_pending_fallback_notice - # (see run_agent.py); it is discarded on terminal failure since the - # buffered line is flushed instead. See fallback-observability fix. - agent._pending_fallback_notice = ( - f"🔄 Switched to fallback model: {old_model} via {old_provider} " - f"→ {fb_model} via {fb_provider}" + notice = ( + f"⚠️ Model fallback: {old_model} via {old_provider} unavailable " + f"({_fallback_reason_text(reason)}); using {fb_model} via {fb_provider}." ) + # The buffered switch is surfaced on terminal failure. A successful + # fallback clears retry chatter, so retain every switch as a durable + # one-shot notice for _emit_pending_fallback_notice (run_agent.py). + agent._buffer_status(notice) + pending = getattr(agent, "_pending_fallback_notice", None) + if isinstance(pending, list): + pending.append(notice) + elif pending: + agent._pending_fallback_notice = [str(pending), notice] + else: + agent._pending_fallback_notice = [notice] + # ``_fallback_activated`` is also reused by temporary `/model --once` + # restoration. Keep separate provenance so the restore path only emits + # a fallback-recovery notice after an actual provider fallback. + agent._provider_fallback_active = True + agent._provider_fallback_route = (str(fb_model), str(fb_provider)) logger.info( "Fallback activated: %s → %s (%s)", old_model, fb_model, fb_provider, @@ -2848,8 +2889,21 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: """Request a summary when max iterations are reached. Returns the final response text.""" - agent._safe_print( - f"⚠️ Reached maximum iterations ({agent.max_iterations}). Requesting summary..." + warning = f"⚠️ Reached maximum iterations ({agent.max_iterations}). Requesting summary..." + if getattr(agent, "suppress_status_output", False): + # Strict machine-readable mode (hermes chat -Q, oneshot, background + # review): keep diagnostics out of stdout so wrappers receive only + # the final assistant content (#93220 class). Note: plain quiet_mode + # is NOT the right gate — the interactive CLI runs quiet_mode=True by + # default and should still see this warning. + logger.warning(warning) + else: + agent._safe_print(warning) + + summary_request = ( + "You've reached the maximum number of tool-calling iterations allowed. " + "Please provide a final response summarizing what you've found and accomplished so far, " + "without calling any more tools." ) summary_api_request_id = f"iteration-summary:{uuid.uuid4()}" diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 6c8a811dd466..a1118759da3a 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -18,6 +18,7 @@ import json import logging +import os import time from types import SimpleNamespace from typing import Any, Callable, Dict, List @@ -690,6 +691,20 @@ def run_codex_app_server_turn( Called from run_conversation() when agent.api_mode == "codex_app_server". Returns the same dict shape as the chat_completions path. """ + # Defense in depth for compression.checkpoint_required: agent init + # already refuses this combination, but api_mode is a plain attribute a + # future code path could mutate on a live agent. Fail closed before the + # codex agent can compact its thread — once run_turn() executes, a + # codex-owned compaction may already have happened with no pre-compress + # checkpoint. Explicit-True check matches the compress_context() gate. + if getattr(agent, "compression_checkpoint_required", False) is True: + from agent.conversation_compression import _checkpoint_blocked + + raise _checkpoint_blocked( + "codex_app_server owns the authoritative thread and compacts it " + "without a truthful pre-compaction transcript boundary" + ) + from agent.transports.codex_app_server_session import ( CodexAppServerSession, _ServerRequestRouting, @@ -1084,8 +1099,23 @@ def _consume_codex_event_stream( * ``interrupt_check()`` — returns True to break the loop early. """ collected_output_items: List[Any] = [] + # output_index of each collected_output_items entry, appended in lockstep + # so settled pending calls can be merged back in stream order. + collected_output_indexes: List[Any] = [] + collected_output_sequences: List[int] = [] collected_text_deltas: List[str] = [] has_tool_calls = False + # Function calls announced via output_item.added but not yet confirmed by + # output_item.done, keyed by item id. Some OpenAI-compatible backends omit + # per-item done events on a successful completion (upstream evidence: + # anomalyco/opencode#37159); these are settled from accumulated stream + # state at the terminal event so the tool call executes instead of being + # silently dropped. + pending_function_calls: Dict[str, Dict[str, Any]] = {} + # First-observed (sequence, output_index) per announced item id, so items + # confirmed later via output_item.done keep their announced stream + # position when merged with settled pending calls. + announced_output_order: Dict[str, tuple] = {} first_delta_fired = False active_message_phase: str | None = None commentary_text_deltas: List[str] = [] @@ -1099,6 +1129,11 @@ def _consume_codex_event_stream( terminal_incomplete_details: Any = None terminal_error: Any = None saw_terminal = False + # Settlement of pending calls requires an actually observed successful + # terminal frame. ``terminal_status`` defaults to "completed", so it + # cannot distinguish a real response.completed from EOF/interruption. + saw_response_completed = False + next_output_sequence = 0 for event in event_iter: if on_event is not None: @@ -1141,8 +1176,32 @@ def _consume_codex_event_stream( commentary_text_deltas = [] else: active_message_phase = None + # First-observed ordering metadata for EVERY announced item (not + # just function calls): when this item later lands via + # output_item.done, the done path must reuse the announced + # sequence/index instead of allocating a fresh tail position, or + # a mixed announced/pending stream without output_index values + # reorders the calls (review P1 on PR #92767). + item_id = str(_item_field(item, "id", "")) + if item_id and item_id not in announced_output_order: + announced_output_order[item_id] = ( + next_output_sequence, + _event_field(event, "output_index", None), + ) + next_output_sequence += 1 if "function_call" in str(item_type): has_tool_calls = True + if item_id: + announced_sequence, announced_index = announced_output_order[item_id] + # Seed from the announced item's own arguments when the + # backend attaches them up front, and remember the stream + # position so a settled call keeps its place in the output. + pending_function_calls[item_id] = { + "item": item, + "arguments": str(_item_field(item, "arguments", "") or ""), + "output_index": announced_index, + "sequence": announced_sequence, + } continue if "output_text.delta" in event_type or event_type == "response.output_text.delta": @@ -1181,7 +1240,27 @@ def _consume_codex_event_stream( if "function_call" in event_type: has_tool_calls = True - # fall through — function_call items still get added on output_item.done + # Accumulate streamed argument deltas for calls announced via + # output_item.added, so a stream that completes without per-item + # done events can still be settled from accumulated state. + if "delta" in event_type: + delta_args = _event_field(event, "delta", "") + pending = pending_function_calls.get(str(_event_field(event, "item_id", ""))) + if pending is not None and delta_args: + pending["arguments"] += delta_args + continue + if event_type.endswith("function_call_arguments.done"): + done_args = _event_field(event, "arguments", None) + pending = pending_function_calls.get(str(_event_field(event, "item_id", ""))) + if pending is not None and done_args is not None: + # Per-item arguments.done is authoritative for the + # accumulated string when the item itself never lands. + # An explicit empty string (zero-argument call) counts as + # authoritative; only a missing field leaves the streamed + # deltas in place. + pending["arguments"] = str(done_args) + continue + # other function_call frames fall through — function_call items still get added on output_item.done if "reasoning" in event_type and "delta" in event_type: reasoning_text = _event_field(event, "delta", "") @@ -1207,6 +1286,26 @@ def _consume_codex_event_stream( done_item = _event_field(event, "item") if done_item is not None: collected_output_items.append(done_item) + # Reuse the first-observed position when this item was + # announced earlier via output_item.added; a fresh tail + # sequence is allocated only for genuinely unannounced items. + # The .done event's own output_index wins when present, with + # the announced index as its fallback. + done_id = str(_item_field(done_item, "id", "")) + announced_sequence, announced_index = announced_output_order.get( + done_id, (None, None) + ) + done_index = _event_field(event, "output_index", None) + if done_index is None: + done_index = announced_index + if announced_sequence is None: + announced_sequence = next_output_sequence + next_output_sequence += 1 + collected_output_indexes.append(done_index) + collected_output_sequences.append(announced_sequence) + # Confirmed by the authoritative per-item done event; remove + # from pending so it is not settled twice. + pending_function_calls.pop(done_id, None) done_phase = _item_field(done_item, "phase", None) done_phase = done_phase.strip().lower() if isinstance(done_phase, str) else None if done_phase == "commentary" and on_commentary_message is not None: @@ -1255,6 +1354,7 @@ def _consume_codex_event_stream( if terminal_error is None and isinstance(resp_obj, dict): terminal_error = resp_obj.get("error") if event_type == "response.completed": + saw_response_completed = True terminal_status = terminal_status or "completed" elif event_type == "response.incomplete": terminal_status = terminal_status or "incomplete" @@ -1279,6 +1379,56 @@ def _consume_codex_event_stream( else: output = [] + # Settle function calls that were announced via output_item.added and + # streamed argument deltas but never confirmed by output_item.done: some + # OpenAI-compatible backends omit per-item done events on a successful + # completion (anomalyco/opencode#37159). Done items stay authoritative; + # this only fills the gap so the call executes instead of vanishing. + if pending_function_calls and saw_response_completed: + # Assemble settled calls and .done items in output_index order instead + # of appending at the tail: a pending call that streamed before a later + # .done item must keep its position, or dependent side effects invert. + indexed = [ + (index, sequence, position, item) + for position, (index, sequence, item) in enumerate( + zip( + collected_output_indexes, + collected_output_sequences, + collected_output_items, + ) + ) + ] + for position, pending in enumerate(pending_function_calls.values(), start=len(indexed)): + item = pending["item"] + # Canonicalize empty/whitespace arguments so zero-delta calls stay + # executable; malformed non-empty JSON passes through untouched and + # stays rejected by downstream argument parsing. + arguments = (pending["arguments"] or "").strip() or "{}" + indexed.append((pending.get("output_index"), pending["sequence"], position, SimpleNamespace( + type="function_call", + id=_item_field(item, "id", None), + call_id=_item_field(item, "call_id", None), + name=_item_field(item, "name", None), + arguments=arguments, + status="completed", + ))) + + # output_index is optional in compatible Responses streams. A partial + # ordering (sorting indexed entries while interleaving unindexed ones) + # is not well-defined and can produce contradictory comparisons. Keep + # the observed wire order whenever any index is missing; use the + # protocol ordering only when every entry provides an index. + if all(entry[0] is not None for entry in indexed): + try: + indexed.sort(key=lambda entry: entry[0]) + except TypeError: + # Preserve wire order if a backend sends non-comparable index + # values instead of integers. + pass + else: + indexed.sort(key=lambda entry: entry[1]) + output = [entry[3] for entry in indexed] + # If the stream ended without any terminal event AND produced no usable # content (no items, no text deltas), surface that as a RuntimeError so # callers can distinguish "stream truncated mid-flight / provider rejected @@ -1361,6 +1511,78 @@ def _sanitize_consumer_codex_request( return sanitized +# Bulk request fields that carry the conversation payload. Everything else in +# the request is scalar configuration the SDK transform handles in microseconds. +_SDK_TRANSFORM_BYPASS_FIELDS = ("input", "tools") + + +def _is_plain_json_data(value: Any) -> bool: + """True when ``value`` is composed purely of JSON wire types. + + The SDK's request transform exists to convert typed params (TypedDict + key aliases, pydantic models, ``PropertyInfo`` formats) into wire + format. Hermes assembles Codex payloads from JSON round-trips, so they + are already wire format — but that is only provable when every node is + a plain JSON type. Anything else must keep the typed SDK path. + """ + if value is None or isinstance(value, (str, int, float, bool)): + return True + if isinstance(value, dict): + return all( + isinstance(key, str) and _is_plain_json_data(item) + for key, item in value.items() + ) + if isinstance(value, list): + return all(_is_plain_json_data(item) for item in value) + return False + + +def _bypass_sdk_request_transform(stream_kwargs: dict) -> dict: + """Route bulk payload fields around the SDK's ``maybe_transform`` (#93650). + + ``responses.create`` re-walks the entire request body against the + ``ResponseCreateParams`` union graph before any byte leaves the process. + That walk runs with the GIL held, and #93650 documents it wedging for + 12+ hours on a ~1.4 MB conversation — starving every other thread, + including the TTFB/stale watchdogs whose job is to rescue this exact + call. Because the hang is client-side and pre-network, no socket kill + can unblock it. + + The SDK merges ``extra_body`` into the JSON body *after* the transform + (``_base_client._build_request``), so moving the already-wire-format + bulk fields there skips the walk entirely and produces a byte-identical + request. Fields containing anything that is not plain JSON data (e.g. + pydantic models, generators) stay on the typed path, which still needs + the transform. Set HERMES_CODEX_SDK_TRANSFORM=1 to restore the pre-fix + behavior. + """ + if os.environ.get("HERMES_CODEX_SDK_TRANSFORM", "").strip().lower() in { + "1", "true", "yes", "on" + }: + return stream_kwargs + + moved = { + field: stream_kwargs[field] + for field in _SDK_TRANSFORM_BYPASS_FIELDS + if isinstance(stream_kwargs.get(field), (dict, list)) + and _is_plain_json_data(stream_kwargs[field]) + } + if not moved: + return stream_kwargs + + bypassed = { + key: value for key, value in stream_kwargs.items() if key not in moved + } + extra_body = bypassed.get("extra_body") + merged = dict(extra_body) if isinstance(extra_body, dict) else {} + for field, value in moved.items(): + # An explicit caller-provided extra_body entry keeps precedence, + # matching what the SDK's post-transform merge would have done. + merged.setdefault(field, value) + bypassed["extra_body"] = merged + return bypassed + + def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta=None): """Execute one streaming Responses API request and return the final response. @@ -1408,6 +1630,7 @@ def _open_codex_stream(next_api_kwargs: dict[str, Any]): next_api_kwargs, ) stream_kwargs["stream"] = True + stream_kwargs = _bypass_sdk_request_transform(stream_kwargs) return active_client.responses.create(**stream_kwargs) def _codex_stream_created(_raw_stream: Any) -> None: diff --git a/agent/coding_context.py b/agent/coding_context.py index aa38305e016f..333de26b0541 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -520,13 +520,21 @@ def toolset_selection(self, config: Optional[dict[str, Any]] = None) -> Optional return None return [self.profile.toolset, *_enabled_mcp_servers(config)] - def system_prompt_parts(self) -> tuple[list[str], list[str], list[str]]: + def system_prompt_parts( + self, valid_tool_names=None + ) -> tuple[list[str], list[str], list[str]]: """Return prefix, workspace, and trailing posture blocks separately. The operating brief carries a model-family edit-format nudge appended to it (one cached string, not a separate block) so the model is steered toward the `patch` mode it handles best — see ``_edit_format_line``. + ``valid_tool_names`` (when provided) tailors the brief to the session's + toolset: the ``todo`` tracking sentence is dropped when the todo tool + isn't loaded (e.g. Blank Slate), so the brief never references a tool + the model can't call. The toolset is fixed at session construction, + so the rendered brief is deterministic per session — cache-safe. + The three lists preserve the historical flat prompt order: the brief, the live workspace snapshot, then configured operator instructions. Prompt assembly can therefore put a cache boundary before the snapshot @@ -539,6 +547,13 @@ def system_prompt_parts(self) -> tuple[list[str], list[str], list[str]]: trailing: list[str] = [] if self.profile.guidance: brief = self.profile.guidance + if valid_tool_names is not None and "todo" not in valid_tool_names: + brief = brief.replace( + "- Track multi-step work with `todo`. Reference code as " + "`path:line` instead of pasting whole files.", + "- Reference code as `path:line` instead of pasting " + "whole files.", + ) edit_line = _edit_format_line(self.model) if edit_line: brief = f"{brief}\n{edit_line}" @@ -666,11 +681,12 @@ def coding_system_prompt_parts( cwd: Optional[str | Path] = None, config: Optional[dict[str, Any]] = None, model: Optional[str] = None, + valid_tool_names=None, ) -> tuple[list[str], list[str], list[str]]: """Return coding prefix, workspace snapshot, and trailing guidance.""" return resolve_runtime_mode( platform=platform, cwd=cwd, config=config, model=model - ).system_prompt_parts() + ).system_prompt_parts(valid_tool_names=valid_tool_names) def coding_compact_skill_categories( diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 2a0b5dc4a449..0450b136c3aa 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -16,6 +16,7 @@ - Richer tool call/result detail in summarizer input """ +import copy import hashlib import json import logging @@ -33,6 +34,7 @@ ) from agent.context_engine import ContextEngine, sanitize_memory_context from agent.error_classifier import FailoverReason, classify_api_error +from agent.message_sanitization import tool_result_id_variants from agent.model_metadata import ( MINIMUM_CONTEXT_LENGTH, get_model_context_length, @@ -1220,6 +1222,13 @@ def _collect_protected_skill_names( # protected region — but always keep this many trailing messages verbatim so # the active user ask / latest tool pair remain readable. Issue #61932. _PRESSURE_KEEP_RECENT_MESSAGES = 3 +# Native vision_analyze / computer_use screenshots that sit inside the +# protected tail cannot be demoted by pass 2, so they ride every later +# request until anti-thrash disables compression (#92699). Keep this many +# newest image-bearing tool results verbatim; retire older image payloads +# even when they fall inside ``protect_last_n``. Matches the Anthropic +# adapter's outbound keep-window. +_MAX_KEEP_TOOL_IMAGES = 3 # Models with context windows below this get their compression threshold # floored at ``_SMALL_CTX_THRESHOLD_PERCENT`` (raise-only — an explicitly @@ -1560,6 +1569,79 @@ def _strip_image_parts_from_parts(parts: Any) -> Any: return out if had_image else None +def _tool_content_has_images(content: Any) -> bool: + """True when a tool-result body carries embedded image bytes. + + Handles both unwrapped OpenAI-style part lists and the native + ``{_multimodal: True, content: [...]}`` envelope vision_analyze returns. + """ + if isinstance(content, dict) and content.get("_multimodal"): + return _content_has_images(content.get("content")) + return _content_has_images(content) + + +def _strip_images_from_tool_msg(msg: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Return a copy of a tool message with its image payloads replaced. + + Handles the two image-bearing tool-result shapes: + + * ``{_multimodal: True, ...}`` envelopes collapse to a short + ``"[screenshot removed] "`` string; + * OpenAI-style part lists have image parts swapped for text + placeholders via :func:`_strip_image_parts_from_parts`. + + Returns ``None`` when the message carries no strippable image (the + caller should leave it untouched). The returned copy has its stale + ``api_content`` sidecar dropped so replay cannot resend the + pre-rewrite bytes. The input message is never mutated. + """ + content = msg.get("content") + if isinstance(content, dict) and content.get("_multimodal"): + summary = content.get("text_summary") or "[screenshot removed to save context]" + new_msg = {**msg, "content": f"[screenshot removed] {str(summary)[:200]}"} + drop_stale_api_content(new_msg) + return new_msg + stripped = _strip_image_parts_from_parts(content) + if stripped is None: + return None + new_msg = {**msg, "content": stripped} + drop_stale_api_content(new_msg) + return new_msg + + +def _retire_stale_tool_result_images( + result: List[Dict[str, Any]], + keep_newest: int = _MAX_KEEP_TOOL_IMAGES, +) -> int: + """Replace image payloads on older tool results with text placeholders. + + Walks newest-first, keeps the most recent ``keep_newest`` image-bearing + tool messages intact (follow-up screenshot QA still sees the latest + frames), and retires the rest. User-role uploads are not touched. + + Mutates ``result`` in place. Returns the number of messages rewritten. + """ + if keep_newest < 0: + keep_newest = 0 + seen = 0 + pruned = 0 + for i in range(len(result) - 1, -1, -1): + msg = result[i] + if not isinstance(msg, dict) or msg.get("role") != "tool": + continue + if not _tool_content_has_images(msg.get("content")): + continue + seen += 1 + if seen <= keep_newest: + continue + new_msg = _strip_images_from_tool_msg(msg) + if new_msg is None: + continue + result[i] = new_msg + pruned += 1 + return pruned + + def _truncate_tool_call_args_json(args: str, head_chars: int = 200) -> str: """Shrink long string values inside a tool-call arguments JSON blob while preserving JSON validity. @@ -2018,6 +2100,7 @@ def on_session_reset(self) -> None: self._last_compression_savings_pct = 100.0 self._ineffective_compression_count = 0 self._anti_thrash_recovery_deadline = 0.0 + self._structural_no_op_backoff_until = 0.0 self._prellm_skip_count = 0 self._fallback_compression_streak = 0 self._verify_compaction_cleared_threshold = False @@ -2302,6 +2385,7 @@ def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> Non self._last_compression_savings_pct = 100.0 self._ineffective_compression_count = 0 self._anti_thrash_recovery_deadline = 0.0 + self._structural_no_op_backoff_until = 0.0 self._prellm_skip_count = 0 self._fallback_compression_streak = 0 self._verify_compaction_cleared_threshold = False @@ -2334,6 +2418,7 @@ def bind_session_state(self, session_db: Any = None, session_id: str = "") -> No self._ineffective_compression_count = 0 self._prellm_skip_count = 0 self._anti_thrash_recovery_deadline = 0.0 + self._structural_no_op_backoff_until = 0.0 self._proactive_prune_rearm_tokens = 0 self.get_active_compression_failure_cooldown() self._load_fallback_compression_streak() @@ -2514,6 +2599,31 @@ def _record_ineffective_compression_verdict(self, count: int) -> None: self._ineffective_compression_count = count self._persist_ineffective_compression_count() + def _record_structural_no_op(self, reason: str) -> None: + """Defer retries after a structural no-op WITHOUT striking the breaker. + + A structural no-op (too few messages / no compressible window / + empty post-handoff window) means the protection window left nothing + eligible to compress *right now* — compression was never really + attempted, so there is nothing "ineffective" to score (#93022). + Counting these as strikes permanently disarms auto-compaction on + short sessions even after they later grow real compressible + material. The transient backoff preserves #40803's guarantee (a + transcript that can never shrink does not re-fire the scan every + turn) while auto-compaction resumes on its own once the backoff + lapses or the transcript outgrows the window. + """ + self._structural_no_op_backoff_until = ( + time.monotonic() + self._STRUCTURAL_NO_OP_BACKOFF_SECONDS + ) + if not self.quiet_mode: + logger.warning( + "Compression skipped (%s): retrying in %.0fs " + "(structural no-op backoff)", + reason, + self._STRUCTURAL_NO_OP_BACKOFF_SECONDS, + ) + def record_rejected_compaction(self) -> None: """Record one compaction whose result was REJECTED before committing. @@ -2552,6 +2662,10 @@ def record_completed_compaction( exactly the incompressible-transcript case the ineffective-strike breaker exists for, and its recovery probe bounds the block. """ + # A completed boundary is proof the transcript was compressible, so + # lift any pending structural no-op backoff (#93022) alongside the + # usual bookkeeping. + self._structural_no_op_backoff_until = 0.0 self._verify_compaction_cleared_threshold = True if feasibility_skip: # A deliberate pre-LLM feasibility skip (#60451) is not a @@ -2868,6 +2982,17 @@ def update_model( # before it rides into the provider's hard context limit. _ANTI_THRASH_RECOVERY_SECONDS = 300.0 + # Structural no-op backoff (#93022): when a compression attempt finds + # nothing eligible inside the protection window (too few messages, empty + # window, post-handoff residue), that is "nothing to compress right now" + # — not an ineffective attempt — so it must not strike the anti-thrash + # breaker (a short session would otherwise permanently disarm + # auto-compaction). Instead, defer retries for this long so a transcript + # that can never shrink doesn't re-fire the scan every turn (#40803's + # frozen-CLI loop). Compaction resumes automatically once the backoff + # lapses or the transcript outgrows the window. + _STRUCTURAL_NO_OP_BACKOFF_SECONDS = 300.0 + @staticmethod def _coerce_max_tokens(value: Any) -> int | None: """Normalize a max_tokens value to a positive int or None. @@ -3163,6 +3288,9 @@ def __init__( # no-op/abort without inferring progress from message-list length. self._last_compression_made_progress: bool = False self._summary_failure_cooldown_until: float = 0.0 + # Transient deferral after a structural no-op (#93022) — see the + # _STRUCTURAL_NO_OP_BACKOFF_SECONDS class constant. + self._structural_no_op_backoff_until: float = 0.0 # True while the live local cooldown failed to persist to the DB; # a refresh must then treat an empty durable row as unknown, not # cleared (see get_active_compression_failure_cooldown). @@ -3427,6 +3555,10 @@ def _compression_block_reason(self) -> "str | None": * ``"cooldown:"`` — the summary LLM is recovering from a recent 429/transient failure; compression is deferred to avoid the freeze loop described in #11529. + * ``"structural_backoff:"`` — a recent attempt found nothing + eligible inside the protection window (#93022); retries are + deferred transiently and compaction resumes when the backoff + lapses or the transcript outgrows the window. * ``"ineffective"`` — anti-thrashing has backed off (the last two compressions each saved <10%, or the fallback streak tripped). * ``None`` — no block active. @@ -3434,6 +3566,11 @@ def _compression_block_reason(self) -> "str | None": _cooldown_remaining = self._summary_failure_cooldown_until - time.monotonic() if _cooldown_remaining > 0: return f"cooldown:{_cooldown_remaining:.0f}" + _structural_remaining = ( + self._structural_no_op_backoff_until - time.monotonic() + ) + if _structural_remaining > 0: + return f"structural_backoff:{_structural_remaining:.0f}" if ( self._ineffective_compression_count >= 2 or self._fallback_compression_streak >= 2 @@ -3498,6 +3635,23 @@ def _automatic_compression_blocked_locally(self) -> bool: _cooldown_remaining, ) return True + # Structural no-op backoff (#93022): a recent attempt found nothing + # eligible inside the protection window. Unlike the ineffective + # breaker below this is inherently transient (in-memory only, no + # strikes accumulate), so a short session that tripped it can still + # auto-compact normally once the backoff lapses or the transcript + # outgrows the protection window. + _structural_remaining = ( + self._structural_no_op_backoff_until - time.monotonic() + ) + if _structural_remaining > 0: + if not self.quiet_mode: + logger.debug( + "Compression deferred — structural no-op backoff for " + "%.0fs more", + _structural_remaining, + ) + return True # Anti-thrashing: back off if recent compressions were ineffective. # The back-off must not be permanent (#14694): the tripped state was # judged against the transcript as it existed THEN (e.g. a middle @@ -3700,16 +3854,15 @@ def _demote_tool_result_at(idx: int, *, spare_protected_skills: bool = True) -> if msg.get("role") != "tool": return False content = msg.get("content", "") - if isinstance(content, list): - stripped = _strip_image_parts_from_parts(content) - if stripped is not None: - result[idx] = {**msg, "content": stripped} - pruned += 1 - return True - return False - if isinstance(content, dict) and content.get("_multimodal"): - summary = content.get("text_summary") or "[screenshot removed to save context]" - result[idx] = {**msg, "content": f"[screenshot removed] {summary[:200]}"} + if isinstance(content, list) or ( + isinstance(content, dict) and content.get("_multimodal") + ): + # Image-bearing shapes share one strip policy with pass 3.5 + # (also drops the stale api_content sidecar on rewrite). + new_msg = _strip_images_from_tool_msg(msg) + if new_msg is None: + return False + result[idx] = new_msg pruned += 1 return True if not isinstance(content, str): @@ -3779,6 +3932,13 @@ def _truncate_tool_call_args_at(idx: int) -> bool: for i in range(max(0, prune_boundary)): _truncate_tool_call_args_at(i) + # Pass 3.5 (#92699): retire image payloads that pass 2 cannot reach + # because they sit inside the protected tail. Native vision_analyze + # embeds re-sent on every turn otherwise make compression look + # ineffective (savings < 10%) and anti-thrash disables it. Newest + # frames stay live for follow-up QA; older ones become placeholders. + pruned += _retire_stale_tool_result_images(result) + # Pass 4 (issue #61932): protected-tail pressure demotion. # After multiple in-place compactions the transcript can be short # enough that nearly every remaining message sits inside the @@ -3874,16 +4034,19 @@ def prune_tool_results_only( threshold (≈100K tokens on a 1M window) and would protect the entire session, pruning nothing. - ``_prune_old_tool_results`` runs all three deterministic passes: + ``_prune_old_tool_results`` runs all deterministic passes: (1) dedup byte-identical tool results — keeps the newest full copy and back-references older exact duplicates ANYWHERE in the list (including the protected tail), so no unique content is ever lost; (2) summarize non-tail tool results larger than ``min_prune_chars``; (3) truncate - oversized tool_call arguments on non-tail assistant messages. Only - pass (2)'s floor is raised by ``proactive_prune_min_result_chars``; - passes (1) and (3) keep their own fixed floors. The recent-tail - protection applies to passes (2) and (3); pass (1) is tail-agnostic by - design because dedup is lossless. + oversized tool_call arguments on non-tail assistant messages; + (3.5) retire image payloads on all but the newest + ``_MAX_KEEP_TOOL_IMAGES`` image-bearing tool results — tail-agnostic + and lossy by design (#92699). Only pass (2)'s floor is raised by + ``proactive_prune_min_result_chars``; passes (1) and (3) keep their + own fixed floors. The recent-tail protection applies to passes (2) + and (3); pass (1) is tail-agnostic by design because dedup is + lossless. PROMPT-CACHE CONTRACT: a committed prune rewrites message bodies the provider has already seen, invalidating the cached prefix from the @@ -3907,7 +4070,7 @@ def prune_tool_results_only( before = sum(_estimate_msg_budget_tokens(m) for m in messages) if before < self._proactive_prune_rearm_tokens: return messages, 0 - # Capability gate BEFORE the expensive 3-pass scan: a bound store that + # Capability gate BEFORE the expensive multi-pass scan: a bound store that # can't persist the prune atomically (duck-typed/plugin session store # without archive_and_compact) makes every prune a permanent no-op, so # don't pay the scan for it on every eligible iteration. @@ -5599,11 +5762,26 @@ def _strip_context_summary_handoff_message( @staticmethod def _get_tool_call_id(tc) -> str: - """Extract the call ID from a tool_call entry (dict or SimpleNamespace).""" + """Extract the canonical call ID from a tool_call entry (dict or + SimpleNamespace), for logging/display only. Matching logic must use + :meth:`_tool_call_id_variants` instead — see its docstring.""" if isinstance(tc, dict): return tc.get("call_id", "") or tc.get("id", "") or "" return getattr(tc, "call_id", "") or getattr(tc, "id", "") or "" + @staticmethod + def _tool_call_id_variants(tc) -> set: + """Return every id variant a tool result might reference *tc* by. + + Thin forwarder — the policy owner is + ``agent.message_sanitization.tool_call_id_variants``, which also + expands ``response_item_id`` and composite ``call|item`` bridge + spellings (#63000), so the compressor's pairing tolerance matches + the pre-call sanitizer's exactly and the two can never drift. + """ + from agent.message_sanitization import tool_call_id_variants + return set(tool_call_id_variants(tc)) + def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Fix orphaned tool_call / tool_result pairs after compression. @@ -5630,23 +5808,28 @@ def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str, for msg in messages: if msg.get("role") == "assistant": for tc in msg.get("tool_calls") or []: - cid = self._get_tool_call_id(tc) - if cid: - surviving_call_ids.add(cid) + surviving_call_ids |= self._tool_call_id_variants(tc) result_call_ids: set = set() for msg in messages: if msg.get("role") == "tool": cid = msg.get("tool_call_id") if cid: - result_call_ids.add(cid) + # Expand alias spellings on the RESULT side too — a + # composite ``call|item`` tool_call_id must match a + # tool_call registered under either half (#63000). + result_call_ids |= tool_result_id_variants(cid) # 1. Remove tool results whose call_id has no matching assistant tool_call orphaned_results = result_call_ids - surviving_call_ids if orphaned_results: messages = [ m for m in messages - if not (m.get("role") == "tool" and m.get("tool_call_id") in orphaned_results) + if not ( + m.get("role") == "tool" + and (rv := tool_result_id_variants(m.get("tool_call_id"))) + and not (rv & surviving_call_ids) + ) ] if not self.quiet_mode: logger.info("Compression sanitizer: removed %d orphaned tool result(s)", len(orphaned_results)) @@ -5655,8 +5838,11 @@ def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str, # were dropped. Stripping is preferred over inserting stub results # because stubs can be dropped by downstream repair_message_sequence # when call_id != id (Codex Responses API format), re-exposing orphans. - missing_results = surviving_call_ids - result_call_ids - if missing_results: + # A tool_call survives if ANY of its id variants still has a + # matching result — checking only one variant per side is exactly + # the mismatch this method exists to avoid. + stripped_count = 0 + if surviving_call_ids - result_call_ids: # --- In-flight tool chain protection (issue #79278) ------------- # A strip here must distinguish a *pending* tool_call (the model's # live request whose result the executor has not yet appended) from @@ -5701,8 +5887,9 @@ def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str, tcs = msg.get("tool_calls") if not tcs: continue - kept = [tc for tc in tcs if self._get_tool_call_id(tc) not in missing_results] + kept = [tc for tc in tcs if self._tool_call_id_variants(tc) & result_call_ids] if len(kept) != len(tcs): + stripped_count += len(tcs) - len(kept) if kept: msg["tool_calls"] = kept else: @@ -5712,10 +5899,10 @@ def _sanitize_tool_pairs(self, messages: List[Dict[str, Any]]) -> List[Dict[str, content = msg.get("content") if not content or (isinstance(content, str) and not content.strip()): msg["content"] = "(tool call removed)" - if not self.quiet_mode: + if stripped_count and not self.quiet_mode: logger.info( "Compression sanitizer: stripped %d orphaned tool_call(s) from assistant messages", - len(missing_results), + stripped_count, ) return messages @@ -7115,29 +7302,25 @@ def compress( # this, /compress would silently no-op for 30-60s after a failure. if force: self._clear_compression_failure_cooldown() + # Manual /compress also overrides a structural no-op backoff + # (#93022): an explicit user request must always get a real try. + self._structural_no_op_backoff_until = 0.0 n_messages = len(messages) # Only need head + 3 tail messages minimum (token budget decides the real tail size) _min_for_compress = self._protect_head_size(messages) + 3 + 1 if n_messages <= _min_for_compress: - # Record the no-op, exactly as the sibling "no compressable window" - # branch below does (#40803). Returning without touching the - # anti-thrashing counter leaves should_compress() saying True on a - # transcript that can never shrink: when the prompt sits above the - # threshold because of the incompressible floor (system prompt + - # tool schemas), every subsequent turn re-fires a compaction that - # returns here unchanged, and the CLI appears frozen. - self._record_ineffective_compression_verdict( - self._ineffective_compression_count + 1, - ) + # Structural no-op, not an ineffective attempt (#93022): with this + # few messages there is nothing eligible to compress yet. Defer + # retries via the transient backoff instead of striking the + # anti-thrashing breaker — striking it here permanently disarmed + # auto-compaction on short sessions even after they grew real + # compressible material. The backoff still prevents #40803's + # per-turn re-fire loop on a transcript that cannot shrink. self._last_compression_savings_pct = 0.0 telemetry["failure_class"] = "insufficient_messages" - if not self.quiet_mode: - logger.warning( - "Cannot compress: only %d messages (need > %d). " - "ineffective_compression_count=%d", - n_messages, _min_for_compress, - self._ineffective_compression_count, - ) + self._record_structural_no_op( + f"only {n_messages} messages (need > {_min_for_compress})" + ) return messages display_tokens = current_tokens if current_tokens else self.last_prompt_tokens or estimate_messages_tokens_rough(messages) @@ -7205,22 +7388,17 @@ def compress( ) telemetry["failure_class"] = "no_compressible_window" # No compressable window — the entire transcript fits within - # the tail budget (soft_ceiling). Without recording this as - # an ineffective compression the anti-thrashing guard in - # should_compress() never fires and every subsequent turn - # re-triggers a no-op compression loop. (#40803) - self._record_ineffective_compression_verdict( - self._ineffective_compression_count + 1, - ) + # the tail budget (soft_ceiling): nothing was eligible to + # compress. This is a structural no-op, not an ineffective + # attempt (#93022), so defer retries via the transient backoff + # instead of striking the anti-thrashing breaker; the backoff + # still prevents #40803's per-turn no-op compression loop on a + # transcript that cannot shrink. self._last_compression_savings_pct = 0.0 - if not self.quiet_mode: - logger.warning( - "Compression skipped: compress_start (%d) >= compress_end (%d) " - "— transcript fits within tail budget, nothing to compress. " - "ineffective_compression_count=%d", - compress_start, compress_end, - self._ineffective_compression_count, - ) + self._record_structural_no_op( + f"compress_start ({compress_start}) >= compress_end " + f"({compress_end}) - transcript fits within tail budget" + ) return messages turns_to_summarize = messages[compress_start:compress_end] @@ -7344,30 +7522,21 @@ def _window_row(idx: int, msg: Dict[str, Any]): # is nothing new to summarize. Skip the summary call entirely: # without this guard the empty window still reached # _generate_summary, wasting an aux LLM call that aborts - # noisily on empty input (#59496). Mirrors the sibling - # "no compressable window" guard above (#40803): record an - # ineffective strike through the durable write-through helper - # so the anti-thrash breaker in should_compress() can stop the - # loop — this shape cannot shrink, so every subsequent turn - # would otherwise re-fire the same no-op. The rehydrated - # _previous_summary is deliberately KEPT (not rolled back as - # the summary-abort path does for #57835): it came from a - # handoff genuinely present in this transcript, which is - # returned unchanged. + # noisily on empty input (#59496). Like the sibling "no + # compressable window" guard above, this is a structural no-op, + # not an ineffective attempt (#93022): defer retries via the + # transient backoff instead of striking the anti-thrash breaker, + # while still stopping #40803's per-turn re-fire of the same + # no-op. The rehydrated _previous_summary is deliberately KEPT + # (not rolled back as the summary-abort path does for #57835): + # it came from a handoff genuinely present in this transcript, + # which is returned unchanged. telemetry["failure_class"] = "empty_post_handoff_window" - self._record_ineffective_compression_verdict( - self._ineffective_compression_count + 1, - ) self._last_compression_savings_pct = 0.0 - if not self.quiet_mode: - logger.warning( - "Compression skipped: latest context summary leaves no " - "new turns to summarize in window %d-%d. " - "ineffective_compression_count=%d", - compress_start, - compress_end, - self._ineffective_compression_count, - ) + self._record_structural_no_op( + f"window {compress_start}-{compress_end} holds only " + "already-summarized handoffs" + ) return messages if not self.quiet_mode: @@ -7935,6 +8104,218 @@ def is_compaction_summary_message(message: Any) -> bool: return ContextCompressor._is_context_summary_content(content) +# Display metadata that describes the durable message independently of the +# compaction wrapper. Other metadata may describe a synthetic timeline event +# and must not make that event look human after projection. +SUMMARY_CARRIER_DURABLE_DISPLAY_METADATA_KEYS = ("reactions",) + + +def _handoff_only_content(content: Any) -> Any: + """Project summary-bearing content to the synthetic handoff alone. + + The compressor has two composite layouts. Ordinary merge-into-tail keeps + the live content before ``_MERGED_SUMMARY_DELIMITER``; the force-user- + leading layout keeps it after ``_SUMMARY_END_MARKER``. This is the inverse + of ``_strip_context_summary_handoff_message`` and deliberately never keeps + live media blocks. + """ + if isinstance(content, str): + if _MERGED_SUMMARY_DELIMITER in content: + suffix = content.split(_MERGED_SUMMARY_DELIMITER, 1)[1].lstrip() + marker_idx = suffix.find(_SUMMARY_END_MARKER) + if marker_idx >= 0: + return suffix[: marker_idx + len(_SUMMARY_END_MARKER)] + return suffix + marker_idx = content.find(_SUMMARY_END_MARKER) + if marker_idx >= 0: + return content[: marker_idx + len(_SUMMARY_END_MARKER)] + return content + + if not isinstance(content, list): + return content + + # Ordinary merge: the summary suffix begins in the delimiter-bearing text + # part. Do not retain later parts: malformed/legacy rows may carry live + # media there rather than synthetic scaffold content. + for item in content: + text = ( + item + if isinstance(item, str) + else item.get("text") + if isinstance(item, dict) + else None + ) + if not isinstance(text, str) or _MERGED_SUMMARY_DELIMITER not in text: + continue + suffix = text.split(_MERGED_SUMMARY_DELIMITER, 1)[1].lstrip() + marker_idx = suffix.find(_SUMMARY_END_MARKER) + if marker_idx >= 0: + suffix = suffix[: marker_idx + len(_SUMMARY_END_MARKER)] + if not suffix: + return [] + if isinstance(item, dict): + copied = item.copy() + copied["text"] = suffix + return [copied] + return [suffix] + + # Force-user-leading merge: keep textual parts through the end marker and + # truncate the marker-bearing part before the live ask. + projected: list[Any] = [] + for item in content: + text = ( + item + if isinstance(item, str) + else item.get("text") + if isinstance(item, dict) + else None + ) + if isinstance(text, str) and _SUMMARY_END_MARKER in text: + prefix = text.split(_SUMMARY_END_MARKER, 1)[0] + _SUMMARY_END_MARKER + if isinstance(item, dict): + copied = item.copy() + copied["text"] = prefix + projected.append(copied) + else: + projected.append(prefix) + return projected + if isinstance(text, str): + projected.append(item.copy() if isinstance(item, dict) else item) + return projected + + +def split_user_originated_turn( + message: Any, +) -> tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: + """Split a user row into hidden handoff scaffold and canonical live view. + + Returns ``(handoff_only, live_view)``. A normal human row has no + handoff; a pure compaction handoff has no live view; a composite carrier + has both. Rewritten projections are fresh dictionaries and never retain + stale API-content or physical persistence identity. + """ + if not isinstance(message, dict) or message.get("role") != "user": + return None, None + + is_summary = is_compaction_summary_message(message) + handoff: Optional[Dict[str, Any]] = None + if is_summary: + handoff = { + "role": "user", + "content": _handoff_only_content(message.get("content")), + COMPRESSED_SUMMARY_METADATA_KEY: True, + "display_kind": "hidden", + } + if COMPRESSED_SUMMARY_HAS_USER_TURN_KEY in message: + handoff[COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] = bool( + message.get(COMPRESSED_SUMMARY_HAS_USER_TURN_KEY) + ) + if message.get(MICRO_COMPACT_MARKER_KEY): + handoff[MICRO_COMPACT_MARKER_KEY] = True + if message.get("timestamp") is not None: + handoff["timestamp"] = message["timestamp"] + drop_stale_api_content(handoff) + + # Hidden is the legacy physical wrapper used for compaction rows and + # does not hide a successfully unwrapped human payload. Other typed + # display rows are synthetic timeline events, never user input. + display_kind = message.get("display_kind") + if display_kind and display_kind != "hidden": + return handoff, None + candidate = ContextCompressor._strip_context_summary_handoff_message(message) + if candidate is None: + return handoff, None + else: + if message.get("display_kind"): + return None, None + candidate = message.copy() + + candidate.pop(COMPRESSED_SUMMARY_METADATA_KEY, None) + candidate.pop(COMPRESSED_SUMMARY_HAS_USER_TURN_KEY, None) + candidate.pop(MICRO_COMPACT_MARKER_KEY, None) + candidate.pop(_DB_PERSISTED_MARKER, None) + if is_summary: + candidate.pop("_row_id", None) + candidate.pop("display_kind", None) + candidate.pop("display_metadata", None) + carrier_metadata = message.get("display_metadata") + if isinstance(carrier_metadata, dict): + durable_metadata = { + key: copy.deepcopy(carrier_metadata[key]) + for key in SUMMARY_CARRIER_DURABLE_DISPLAY_METADATA_KEYS + if key in carrier_metadata + } + if durable_metadata: + candidate["display_metadata"] = durable_metadata + drop_stale_api_content(candidate) + if ContextCompressor._is_synthetic_compression_user_turn(candidate): + return handoff, None + if not ContextCompressor._is_actionable_user_turn(candidate): + return handoff, None + return handoff, candidate + + +def user_originated_turn_view(message: Any) -> Optional[Dict[str, Any]]: + """Return the live human-authored projection of a user row, if any.""" + return split_user_originated_turn(message)[1] + + +def history_before_user_originated_turn( + messages: List[Dict[str, Any]], + index: int, +) -> tuple[List[Dict[str, Any]], Dict[str, Any]]: + """Return a rewind prefix and canonical live view for ``index``. + + When the selected row is a composite carrier, the hidden handoff scaffold + remains at the new history head while the live ask and later rows are + removed. This retains the only representation of already-compacted turns. + """ + if index < 0 or index >= len(messages): + raise IndexError("user turn index is outside the transcript") + handoff, live_view = split_user_originated_turn(messages[index]) + if live_view is None: + raise ValueError("selected row is not a user-originated turn") + prefix = [message.copy() for message in messages[:index]] + if handoff is not None: + prefix.append(handoff) + return prefix, live_view + + +def retryable_user_text(content: Any) -> str: + """Return lossless retry text or raise before destructive mutation. + + Retry has no attachment replay protocol. Media and unknown structured + parts therefore fail closed; already-persisted strings are replayed as + text, including any textual degradation labels. Structured content is + flattened only when every part is plain text. + """ + if isinstance(content, str): + text = content + elif isinstance(content, list): + chunks: list[str] = [] + for part in content: + if isinstance(part, str): + chunks.append(part) + continue + if not isinstance(part, dict): + raise ValueError("retry does not support non-text content") + if part.get("type") not in {"text", "input_text", "output_text"}: + raise ValueError("retry does not support media or unknown content parts") + if set(part) - {"type", "text"}: + raise ValueError("retry cannot losslessly flatten annotated text parts") + part_text = part.get("text") + if not isinstance(part_text, str): + raise ValueError("retry text parts must contain text") + chunks.append(part_text) + text = "".join(chunks) + else: + raise ValueError("retry does not support non-text content") + + if not text.strip(): + raise ValueError("retry found no text to send") + return text + + def _handoff_carries_live_user_content(message: Any) -> bool: """Return True when a summary-bearing row still carries a live user ask. @@ -7944,13 +8325,11 @@ def _handoff_carries_live_user_content(message: Any) -> bool: shape must remain actionable (#80622 must not treat them as sole-handoff). Delegates to ``_strip_context_summary_handoff_message`` — the canonical - "does anything survive once the handoff is removed" logic (it also - handles multimodal list content and returns ``None`` for a merged-shaped - row whose preserved prior tail is EMPTY, which a bare - ``classify_summary_content(...) == "merged"`` check would wrongly treat - as live). Callers must pre-filter with ``is_compaction_summary_message``: - for non-summary rows the strip helper returns the message unchanged, - which would read as "carries live content" here. + "does anything survive once the handoff is removed" logic. This helper + also applies to merged assistant carriers whose pending tool calls keep an + exchange in flight, so it must not use the user-row-only display projection. + Callers must pre-filter with ``is_compaction_summary_message`` because a + non-summary row is returned unchanged by the strip helper. """ if not isinstance(message, dict): return False @@ -8029,15 +8408,8 @@ def is_user_originated_turn(message: Any) -> bool: this instead of ``role == "user" and not display_kind`` — standalone handoffs with ``_compressed_summary_has_user_turn`` were previously left without ``display_kind=hidden`` and could be mistaken for real asks (#80622). - Summary-bearing rows are never user-originated, even when they embed a - live ask after the end marker (callers that need that text should unwrap). + Summary-bearing rows count only when their canonical live-user projection + recovers an actionable ask. Pure handoffs and typed synthetic rows never + count. """ - if not isinstance(message, dict) or message.get("role") != "user": - return False - if message.get("display_kind"): - return False - if is_compaction_summary_message(message): - return False - if ContextCompressor._is_synthetic_compression_user_turn(message): - return False - return ContextCompressor._is_actionable_user_turn(message) + return user_originated_turn_view(message) is not None diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 90f3b5e879d5..012600bca82d 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -71,6 +71,7 @@ automatic_compaction_status_message, sanitize_memory_context, ) +from agent.memory_provider import PRE_COMPRESS_CHECKPOINT_API_VERSION from agent.model_metadata import ( estimate_messages_tokens_rough, estimate_request_tokens_rough, @@ -104,6 +105,27 @@ COMPACTION_DONE_STATUS = "✓ Context compaction complete — continuing turn..." +def _strip_marker_for_comparison(msgs: Any) -> Any: + """Copy ``msgs`` with the ``_db_persisted`` persistence marker removed. + + Used by the no-op progress check: live and loaded dicts carry the marker + (loaded rows are stamped at materialization time, #92231) while + ``compress()`` output is marker-swept, so a raw ``==`` would misclassify a + semantically-identical no-op copy as progress. Non-list inputs and + non-dict entries pass through unchanged. + """ + from agent.context_compressor import _DB_PERSISTED_MARKER + + if not isinstance(msgs, list): + return msgs + return [ + {k: v for k, v in m.items() if k != _DB_PERSISTED_MARKER} + if isinstance(m, dict) + else m + for m in msgs + ] + + def _emit_compaction_done(agent: Any) -> None: """Emit the structured terminal edge for a started compaction.""" status_callback = getattr(agent, "status_callback", None) @@ -1090,6 +1112,16 @@ def _fence_gated_worker(worker_fence: CompressionCommitFence): # the host unwinds, so the detached worker can never publish. fence.revoke_commit_admission() +class CompressionCheckpointUnavailable(RuntimeError): + """Raised when required durable pre-compress checkpointing is unavailable.""" + + +def _checkpoint_blocked(reason: str) -> CompressionCheckpointUnavailable: + return CompressionCheckpointUnavailable( + "BLOCKED_MISSING_PREREQUISITE: required pre-compress checkpoint " + f"unavailable: {reason}" + ) + def _lock_api_is_absent_on_session_db(lock_db: Any) -> bool: """Whether the live in-memory SessionDB class structurally predates locks. @@ -1519,6 +1551,41 @@ def _run(self) -> None: return self._touch("context compression in progress") +def _direct_messages_for_pre_compress_memory(messages: Any) -> list[dict[str, Any]]: + """Return direct user/assistant evidence safe for memory checkpointing. + + Compression summaries are derivative context, not new source evidence. + Tool rows and system messages are likewise omitted so memory providers + receive one normalized host contract instead of having to infer Hermes + transcript internals independently. Assistant messages that carry both + prose and ``tool_calls`` keep their prose (the ``tool_calls`` payload is + stripped); pure tool-call wrappers without prose are dropped. + """ + # Deferred import: context_compressor imports turn_context, which imports + # this module — a module-level import here would close that cycle + # (upstream introduced the turn_context edge with the api_content work). + from agent.context_compressor import COMPRESSED_SUMMARY_METADATA_KEY + + direct_messages: list[dict[str, Any]] = [] + for message in messages or []: + if not isinstance(message, dict): + continue + role = message.get("role") + if role not in {"user", "assistant"}: + continue + if message.get(COMPRESSED_SUMMARY_METADATA_KEY): + continue + if role == "assistant" and message.get("tool_calls"): + content = message.get("content") + has_prose = bool( + content.strip() if isinstance(content, str) else content + ) + if not has_prose: + continue + message = {k: v for k, v in message.items() if k != "tool_calls"} + direct_messages.append(message) + return direct_messages + class _CompressionLockLeaseRefresher: def __init__( @@ -2319,7 +2386,18 @@ def compress_context( # The memory-provider context handoff below is intentionally Hermes-only: # the app server does not expose its native summary prompt, so there is no # truthful injection point for ``on_pre_compress()`` return text here. + # `is True` (not bool()): unit tests drive this path with bare MagicMock + # agents whose auto-created attributes are truthy; the gate must only arm + # on the explicit boolean set by agent_init from config. + checkpoint_required = ( + getattr(agent, "compression_checkpoint_required", False) is True + ) if getattr(agent, "api_mode", None) == "codex_app_server": + if checkpoint_required: + raise _checkpoint_blocked( + "codex_app_server owns the authoritative thread and does not " + "expose a truthful pre-compaction transcript boundary" + ) _codex_fence_entered = False if commit_fence is not None: _codex_fence_entered = commit_fence.begin_commit( @@ -2935,9 +3013,53 @@ def _release_lock() -> None: # wants surfaced inside the compression summary; capture and forward it # instead of silently discarding the provider's return value. memory_context = "" - if agent._memory_manager: + memory_manager = getattr(agent, "_memory_manager", None) + # Raw messages remain the historical (API v1) provider contract; the + # normalized evidence list is handed only to API v2+ checkpoint + # providers inside MemoryManager.on_pre_compress(). + evidence_messages = _direct_messages_for_pre_compress_memory(messages) + if checkpoint_required: + supports_checkpoint = getattr( + memory_manager, "supports_pre_compress_checkpoint", None + ) + if memory_manager is None or not callable(supports_checkpoint): + raise _checkpoint_blocked( + f"no active provider implements checkpoint API " + f"v{PRE_COMPRESS_CHECKPOINT_API_VERSION}" + ) try: - _maybe_ctx = agent._memory_manager.on_pre_compress(messages) + compatible = bool( + supports_checkpoint(PRE_COMPRESS_CHECKPOINT_API_VERSION) + ) + except Exception as exc: + raise _checkpoint_blocked("provider capability probe failed") from exc + if not compatible: + raise _checkpoint_blocked( + f"active provider does not implement checkpoint API " + f"v{PRE_COMPRESS_CHECKPOINT_API_VERSION}" + ) + try: + _maybe_ctx = memory_manager.on_pre_compress( + messages, + evidence_messages=evidence_messages, + require_checkpoint=True, + checkpoint_api_version=PRE_COMPRESS_CHECKPOINT_API_VERSION, + ) + except Exception as exc: + logger.warning( + "Required pre-compress checkpoint failed (%s)", + type(exc).__name__, + ) + raise _checkpoint_blocked( + f"provider checkpoint API v{PRE_COMPRESS_CHECKPOINT_API_VERSION} failed" + ) from exc + if isinstance(_maybe_ctx, str): + memory_context = sanitize_memory_context(_maybe_ctx) + elif memory_manager: + try: + _maybe_ctx = memory_manager.on_pre_compress( + messages, evidence_messages=evidence_messages + ) if isinstance(_maybe_ctx, str): memory_context = sanitize_memory_context(_maybe_ctx) except Exception: @@ -3163,8 +3285,15 @@ def _release_lock() -> None: # Compare against the pre-dispatch semantic state, not object identity: # legacy/plugin engines may return an equal copy for a no-op, or mutate # the live list while returning an unchanged snapshot. Neither case may - # rotate or rewrite the session. - if compressed == messages_before_compression: + # rotate or rewrite the session. The raw ``==`` leg runs FIRST so a + # list subclass returned by an engine keeps its ``__eq__`` semantics + # (tests seam on this); the marker-insensitive leg (#92231) then covers + # the cold-resume shape where the stamped snapshot differs from the + # marker-swept compress() output only by ``_db_persisted``. + if compressed == messages_before_compression or ( + _strip_marker_for_comparison(compressed) + == _strip_marker_for_comparison(messages_before_compression) + ): if messages != messages_before_compression: messages[:] = copy.deepcopy(messages_before_compression) logger.info( diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 2d49501518e9..23be8bedb1f3 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -22,6 +22,7 @@ import random import re import ssl +import sys import time from typing import Any, Dict, List, Optional @@ -41,6 +42,7 @@ from agent.message_metadata import append_message from agent.turn_context import ( _compression_warrants_another_preflight_pass, + _review_fork_first_request_pending, build_turn_context, compose_user_api_content, reanchor_current_turn_user_idx, @@ -50,6 +52,7 @@ from agent.message_sanitization import ( close_interrupted_tool_sequence, _repair_tool_call_arguments, + coalesce_tool_call_id, _sanitize_messages_non_ascii, _sanitize_messages_surrogates, _sanitize_structure_non_ascii, @@ -118,6 +121,26 @@ ) +def _review_input_budget_exhausted(agent: Any) -> bool: + """True when a detached review fork has replayed its aggregate input budget. + + Only forks carrying an explicit ``_review_input_token_budget`` (the + background-review path, #93057) are gated; every other agent returns + False and is unaffected. ``session_input_tokens`` accumulates from + provider usage after each response, so the check fires at the top of the + NEXT tool-loop iteration — the budget-crossing request is admitted and + completes (its tool writes land), then the loop stops before any further + provider call. This caps the review's total replayed input, complementing + the per-request bound provided by detached in-memory compaction and the + ``_REVIEW_MAX_ITERATIONS`` iteration cap. + """ + budget = getattr(agent, "_review_input_token_budget", None) + if not isinstance(budget, int) or isinstance(budget, bool) or budget <= 0: + return False + used = getattr(agent, "session_input_tokens", 0) + return isinstance(used, int) and not isinstance(used, bool) and used >= budget + + def _maybe_inject_run_budget_wrapup(agent: Any, messages: List[Dict[str, Any]]) -> bool: """Inject the one-time wall-clock wrap-up notice when past 80% of budget. @@ -271,6 +294,39 @@ def _should_rearm_compression_budget( "chat_completion_helpers", }) +# Maximum total outer-loop exceptions tolerated within one user turn before +# the loop gives up (#92450). The turn budget is unlimited by default +# (``max_iterations = sys.maxsize``), so the historical "near the limit" +# guard no longer stops a turn whose outer loop keeps raising: permanent +# failures spun at ~64 retries/s, pegged a core, and overwrote the rotated +# agent.log history (days of diagnostic context) within minutes. The inner +# retry/fallback machinery owns transient API recovery and terminates on its +# own; only exceptions that ESCAPE it reach this bound, so the cap can be +# small. Still scaled down by a tiny explicit ``max_iterations`` so a +# manually bounded budget keeps governing. +_MAX_OUTER_LOOP_ERRORS = 8 + + +def _is_interpreter_shutdown_error(exc: Exception) -> bool: + """Check if *exc* is a fatal interpreter-shutdown failure. + + During teardown, ``concurrent.futures`` refuses new work with + ``RuntimeError: cannot schedule new futures after interpreter shutdown`` + (or the shorter ``... after shutdown`` variant from a plain + ThreadPoolExecutor). Both are documented in #58720. + + Delegates to the shared predicate in ``tools.interpreter_shutdown`` + (same home as cron delivery and concurrent tool submission) so the + shutdown-race bug class has one text-matching site. Keeps the + RuntimeError type gate from the original (#93269): unlike the raw + predicate, a ValueError carrying similar text must not match here. + """ + if isinstance(exc, RuntimeError): + from tools.interpreter_shutdown import interpreter_shutting_down + + return interpreter_shutting_down(exc) + return False + def _moa_client_consumes_prepared_request(client: Any) -> bool: """True when ``client`` is the in-process MoA facade. @@ -1896,6 +1952,8 @@ def run_conversation( failed = False codex_ack_continuations = 0 length_continue_retries = 0 + # Total outer-loop exceptions this turn (#92450) — see _MAX_OUTER_LOOP_ERRORS. + _outer_error_count = 0 truncated_tool_call_retries = 0 truncated_response_parts: List[str] = [] compression_attempts = 0 @@ -1977,6 +2035,21 @@ def run_conversation( if not agent.quiet_mode: agent._safe_print("\n⚡ Breaking out of tool loop due to interrupt...") break + + # Aggregate input budget for detached auxiliary forks (background + # review, #93057): compaction bounds each request; this bounds the + # review as a whole. Fires between iterations — the budget-crossing + # request completed (its tool writes landed), and the tool loop stops + # before the next provider call, mirroring the iteration-budget exit. + if _review_input_budget_exhausted(agent): + _turn_exit_reason = "review_input_budget_exhausted" + if not agent.quiet_mode: + agent._safe_print( + f"\n⏹️ Review input budget exhausted " + f"({int(agent.session_input_tokens):,} tokens) — stopping " + f"the review tool loop before the next provider call." + ) + break api_call_count += 1 agent._api_call_count = api_call_count @@ -2602,6 +2675,7 @@ def run_conversation( )() if ( agent.compression_enabled + and not _review_fork_first_request_pending(agent) and len(messages) > 1 and compression_attempts < max_compression_attempts and not _preflight_compression_blocked @@ -2912,6 +2986,14 @@ def run_conversation( is_github_responses=agent._is_copilot_url(), sanitize_harmony_tokens=agent._is_codex_backend(), ) + # OpenRouter response caching replays identical successful + # responses verbatim, including empty completions. An empty- + # response retry must reach the provider instead of replaying + # the response that triggered the retry. + if agent._empty_content_retries > 0 and agent._is_openrouter_url(): + _xh = dict(api_kwargs.get("extra_headers") or {}) + _xh["X-OpenRouter-Cache"] = "false" + api_kwargs["extra_headers"] = _xh # Copilot x-initiator: the first API call of a user turn is # marked "user" so Copilot bills a premium request; tool-loop # follow-ups keep the default "agent" header (#3040). @@ -4656,6 +4738,39 @@ def _perform_api_call(next_api_kwargs): status_code = getattr(api_error, "status_code", None) error_context = agent._extract_api_error_context(api_error) + # ── Interpreter finalization: abandon immediately ── + # The process is exiting (TUI quit, SIGTERM, one-shot done) + # while this turn — typically the post-turn review fork's + # daemon thread — is mid-flight. Retries, credential + # rotation, and fallbacks are all futile ("cannot schedule + # new futures..."), and the buffered ⚠️/❌ retry trace spams + # the shell after the TUI already exited. End the turn with + # a single log line: no print, no traceback, no debug dump, + # no retry. Same class as cron delivery (#55924/#58720) and + # concurrent tool submission — shared predicate. + from tools.interpreter_shutdown import interpreter_shutting_down + + if interpreter_shutting_down(api_error): + logger.warning( + "%sInterpreter is shutting down — abandoning turn " + "during API call #%d (%s)", + agent.log_prefix, api_call_count, api_error, + ) + _shutdown_summary = ( + "Turn abandoned: the process was shutting down " + "before the model call could complete." + ) + return { + "final_response": _shutdown_summary, + "messages": messages, + "api_calls": api_call_count, + "completed": False, + "failed": True, + "error": _shutdown_summary, + "failure_reason": "interpreter_shutdown", + "failure_retryable": False, + } + # ── Classify the error for structured recovery decisions ── _compressor = getattr(agent, "context_compressor", None) _ctx_len = getattr(_compressor, "context_length", 200000) if _compressor else 200000 @@ -7014,7 +7129,7 @@ def _perform_api_call(next_api_kwargs): append_message(messages, { "role": "tool", "name": tc.function.name, - "tool_call_id": tc.id, + "tool_call_id": coalesce_tool_call_id(tc), "content": content, }) continue @@ -7118,7 +7233,7 @@ def _perform_api_call(next_api_kwargs): append_message(messages, { "role": "tool", "name": tc.function.name, - "tool_call_id": tc.id, + "tool_call_id": coalesce_tool_call_id(tc), "content": tool_result, }) continue @@ -7266,7 +7381,7 @@ def _perform_api_call(next_api_kwargs): append_message(messages, { "role": "tool", "name": tc.function.name, - "tool_call_id": tc.id, + "tool_call_id": coalesce_tool_call_id(tc), "content": _invalid_tool_name_error_content( tc.function.name, agent.valid_tool_names ), @@ -8298,6 +8413,11 @@ def _perform_api_call(next_api_kwargs): break except Exception as e: + # Count every escaped exception against the per-turn bound before + # classification — permanent failures must terminate even when the + # turn budget is unlimited (#92450). + _outer_error_count += 1 + # Phase-aware error classification. The huge outer try/except spans # both the actual API request and all local post-processing of the # returned assistant message. Deterministic local bugs (e.g. @@ -8309,6 +8429,43 @@ def _perform_api_call(next_api_kwargs): # local post-processing helpers and never entered the interruptible # API-call helpers, it is almost certainly a local processing bug. # (#66267) + # + # Interpreter shutdown: if the process is tearing down, every + # executor-backed operation (API call, tool dispatch, memory sync) + # raises ``RuntimeError: cannot schedule new futures after + # interpreter shutdown``. Retrying is pointless — the executor is + # gone for good — and each retry just spams another traceback. + # Break immediately so the turn exits cleanly. (#93217) + if sys.is_finalizing() or _is_interpreter_shutdown_error(e): + error_msg = ( + f"Interpreter is shutting down — cannot continue " + f"(API call #{api_call_count}): {e}" + ) + try: + agent._safe_print(f"❌ {error_msg}") + except (OSError, ValueError): + pass + logger.warning(error_msg) + # Best-effort persist — the executor is dying, so this may + # raise the same RuntimeError. Don't let that mask the + # shutdown exit. finalize_turn will retry the persist. + try: + agent._persist_session(messages, conversation_history) + except Exception: + pass + _turn_exit_reason = "interpreter_shutdown" + final_response = ( + "Session is shutting down. Your conversation can be " + "resumed with: hermes --resume " + ) + # Don't append the assistant message here — a thinking-prefill + # or interim assistant may already be the tail, and appending + # would create assistant→assistant. finalize_turn handles + # this case safely (lines 341-353: appends only when + # _tail_role != "assistant"), matching the pattern at other + # break sites that set final_response without appending. + break + tb_module_names: set[str] = set() _tb = e.__traceback__ while _tb is not None: @@ -8328,10 +8485,20 @@ def _perform_api_call(next_api_kwargs): ) else: error_msg = f"Error during OpenAI-compatible API call #{api_call_count}: {str(e)}" - try: - print(f"❌ {error_msg}") - except (OSError, ValueError): + # The background-review fork sets suppress_status_output=True so + # lifecycle noise never reaches the user's terminal — but this + # bare print() bypassed it, leaking ❌ lines onto the shell after + # the TUI exited. Honor the same contract _vprint enforces + # (quiet_mode -q still shows hard failures, matching force=True + # semantics; suppress_status_output silences them). The + # logger.exception below still captures the full traceback. + if getattr(agent, "suppress_status_output", False): logger.error(error_msg) + else: + try: + print(f"❌ {error_msg}") + except (OSError, ValueError): + logger.error(error_msg) # Emit the full traceback at ERROR level so it lands in both # agent.log AND errors.log. Previously this was logged at DEBUG, @@ -8376,20 +8543,33 @@ def _perform_api_call(next_api_kwargs): # If we're near the limit, break to avoid infinite loops. # Local processing errors are deterministic — stop immediately - # rather than retrying until the budget is exhausted. + # rather than retrying until the budget is exhausted. Repeated + # outer-loop errors stop after a small per-turn cap: with + # max_iterations now unlimited by default, a permanent failure + # would otherwise spin forever and overwrite the rotated log + # history within minutes (#92450). + _outer_error_cap = min(_MAX_OUTER_LOOP_ERRORS, max(1, agent.max_iterations)) if ( _is_local_processing_error or api_call_count >= agent.max_iterations - 1 + or _outer_error_count >= _outer_error_cap ): if _is_local_processing_error: _turn_exit_reason = f"local_processing_error({error_msg[:80]})" final_response = f"I apologize, but I encountered an error while processing the model response: {error_msg}" + elif _outer_error_count >= _outer_error_cap: + failed = True + _turn_exit_reason = f"repeated_outer_errors({error_msg[:80]})" + final_response = f"I apologize, but I encountered repeated errors: {error_msg}" else: _turn_exit_reason = f"error_near_max_iterations({error_msg[:80]})" final_response = f"I apologize, but I encountered repeated errors: {error_msg}" - # Append as assistant so the history stays valid for - # session resume (avoids consecutive user messages). - append_message(messages, {"role": "assistant", "content": final_response}) + # Don't append the assistant message here — a thinking-prefill + # or interim assistant may already be the tail, and appending + # would create assistant→assistant. finalize_turn handles + # this case safely (lines 341-353: appends only when + # _tail_role != "assistant"), matching the pattern at other + # break sites that set final_response without appending. break # Post-loop turn finalization extracted to agent/turn_finalizer.finalize_turn diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 84c5b6834b96..982440a59c80 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -458,20 +458,17 @@ def _normalize_custom_pool_name(name: str) -> str: def _iter_custom_providers(config: Optional[dict] = None): - """Yield (normalized_name, entry_dict) for each valid custom_providers entry.""" + """Yield normalized entries from the merged custom-provider config view.""" if config is None: config = _load_config_safe() if config is None: return - custom_providers = config.get("custom_providers") - if not isinstance(custom_providers, list): - # Fall back to the v12+ providers dict via the compatibility layer - try: - from hermes_cli.config import get_compatible_custom_providers + try: + from hermes_cli.config import get_compatible_custom_providers - custom_providers = get_compatible_custom_providers(config) - except Exception: - return + custom_providers = get_compatible_custom_providers(config) + except Exception: + return if not custom_providers: return for entry in custom_providers: @@ -559,10 +556,11 @@ def credential_pool_matches_provider( ) -> bool: """Return whether a pool belongs to the requested runtime provider. - Named custom endpoints intentionally use two identities: the live agent is - ``custom`` while its pool is keyed ``custom:``. Accept that pair only - when the runtime base URL resolves to the exact same custom pool key. - Empty string identities fail closed. Legacy pool adapters without a + Named custom endpoints may use three identities: the live agent can retain + the configured name/provider key, newer runtime paths normalize it to + ``custom``, and the pool is keyed ``custom:``. Accept those aliases + only when the runtime endpoint belongs to the same configured custom + provider. Empty identities fail closed. Legacy pool adapters without a ``provider`` attribute remain compatible; production pools are scoped. """ raw_pool_provider = getattr(pool_or_provider, "provider", None) @@ -578,15 +576,80 @@ def credential_pool_matches_provider( provider_norm = str(provider or "").strip().lower() if not pool_provider or not provider_norm: return False - if pool_provider == provider_norm: - return True - if provider_norm != "custom" or not pool_provider.startswith(CUSTOM_POOL_PREFIX): + if not pool_provider.startswith(CUSTOM_POOL_PREFIX): + return pool_provider == provider_norm + if provider_norm == "custom": + try: + matched_pool = get_custom_provider_pool_key(base_url or "") + except Exception: + return False + return str(matched_pool or "").strip().lower() == pool_provider + + runtime_url = str(base_url or "").strip().rstrip("/") + if not runtime_url: return False try: - matched_pool = get_custom_provider_pool_key(base_url or "") + for normalized_name, entry in _iter_custom_providers(): + if f"{CUSTOM_POOL_PREFIX}{normalized_name}" != pool_provider: + continue + aliases = {normalized_name} + for value in (entry.get("name"), entry.get("provider_key")): + alias = _normalize_custom_pool_name(str(value or "")) + if alias: + aliases.add(alias) + if alias.startswith(CUSTOM_POOL_PREFIX): + aliases.add(alias[len(CUSTOM_POOL_PREFIX):]) + configured_url = str(entry.get("base_url") or "").strip().rstrip("/") + runtime_aliases = {_normalize_custom_pool_name(provider_norm)} + if provider_norm.startswith(CUSTOM_POOL_PREFIX): + runtime_aliases.add( + _normalize_custom_pool_name( + provider_norm[len(CUSTOM_POOL_PREFIX):] + ) + ) + return bool(runtime_aliases & aliases) and runtime_url == configured_url except Exception: return False - return str(matched_pool or "").strip().lower() == pool_provider + return False + + +def resolve_runtime_pool_key(provider: Optional[str], base_url: Optional[str]) -> str: + """Resolve the credential-pool key for a runtime provider identity. + + Named custom runtimes retain their configured alias while their pool is + stored under ``custom:``. Return that scoped key only when the + canonical provider/endpoint boundary accepts it; otherwise preserve the + normalized runtime identity so callers fail closed. + """ + provider_norm = str(provider or "").strip().lower() + if not provider_norm: + return "" + + try: + if provider_norm == "custom": + candidate = get_custom_provider_pool_key(base_url) + if candidate and credential_pool_matches_provider( + candidate, + provider_norm, + base_url=base_url, + ): + return str(candidate).strip().lower() + else: + # Named and exact custom runtimes are keyed by provider identity, + # while auth storage remains keyed by display name. Search the + # configured candidates by identity before considering endpoint; + # this prevents a sibling sharing the URL from lending its pool. + for normalized_name, _entry in _iter_custom_providers(): + candidate = f"{CUSTOM_POOL_PREFIX}{normalized_name}" + if credential_pool_matches_provider( + candidate, + provider_norm, + base_url=base_url, + ): + return candidate + except Exception: + pass + return provider_norm DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1 @@ -1061,7 +1124,7 @@ def _sync_xai_oauth_entry_from_pool_store( This helper is called while the shared auth-store lock is held and re-reads the exact persisted row before a refresh POST is attempted. """ - if self.provider != "xai-oauth": + if self.provider not in ("xai-oauth", "anthropic"): return entry try: persisted = next( @@ -1319,7 +1382,7 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po # resolve_codex_runtime_credentials()). When a waiter finally acquires # the lock, the in-lock re-sync below picks up the rotated token the # winner persisted and skips the POST. - if self.provider in ("openai-codex", "xai-oauth"): + if self.provider in ("openai-codex", "xai-oauth", "anthropic"): sync_entry = ( self._sync_codex_entry_from_auth_store if self.provider == "openai-codex" @@ -1351,11 +1414,10 @@ def _single_use_refresh_lock_timeout(self) -> float: resolves. Reads the provider's ``HERMES_*_REFRESH_TIMEOUT_SECONDS`` override. """ - env_var = ( - "HERMES_CODEX_REFRESH_TIMEOUT_SECONDS" - if self.provider == "openai-codex" - else "HERMES_XAI_REFRESH_TIMEOUT_SECONDS" - ) + env_var = { + "openai-codex": "HERMES_CODEX_REFRESH_TIMEOUT_SECONDS", + "anthropic": "HERMES_ANTHROPIC_REFRESH_TIMEOUT_SECONDS", + }.get(self.provider, "HERMES_XAI_REFRESH_TIMEOUT_SECONDS") refresh_timeout_seconds = auth_mod.env_float(env_var, 20) return max( float(auth_mod.AUTH_LOCK_TIMEOUT_SECONDS), @@ -1956,7 +2018,7 @@ def _available_entries( entry = cleared cleared_any = True if refresh and self._entry_needs_refresh(entry): - if self.provider in ("openai-codex", "xai-oauth"): + if self.provider in ("openai-codex", "xai-oauth", "anthropic"): # Defer single-use-token refresh to avoid holding the # threading lock during cross-process flock + network I/O. sync_fn = ( diff --git a/agent/curator.py b/agent/curator.py index 9668e76a466b..c08d2496123d 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -539,6 +539,13 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int "merges.\n\n" "Your toolset:\n" " - skills_list, skill_view — read the current landscape\n" + " READ BEFORE WRITE — enforced, not advisory. Before skill_manage " + "action=patch, action=edit, action=write_file on a file that already " + "exists, or action=remove_file, call skill_view on that SAME target in " + "this review turn — skill_view(name) for SKILL.md, " + "skill_view(name, file_path=...) for a supporting file — and build the " + "write from the content it just returned. A write without that read is " + "REFUSED and nothing is saved.\n" " - skill_manage action=patch — add sections to the umbrella\n" " - skill_manage action=create — create a new umbrella SKILL.md\n" " - skill_manage action=write_file — add a references/, templates/, " diff --git a/agent/deadline.py b/agent/deadline.py index 5aa58e6c06e9..fa6bf2a7dac8 100644 --- a/agent/deadline.py +++ b/agent/deadline.py @@ -542,3 +542,32 @@ def kill_process_tree(pid: int, *, sig: Optional[int] = None) -> bool: except Exception: continue return signalled + + +class SuspectableBackend: + """Protocol for backends whose connection state can be *poisoned* by a + race (teardown-vs-keepalive, auth-lock corruption) without the backend + itself being dead. + + The contract is **cheap-mark, lazy-verify**: noticing a poisoned state + must never do I/O — ``mark_suspect`` just latches a reason string. The + NEXT caller pays for verification once, via ``ensure_healthy``: a cheap + health probe that either clears the suspicion (backend was fine) or + forces a reconnect/recycle before the call proceeds. This is what keeps + a single race from permanently parking a connection (#81051/#77765/ + #84132): instead of parking on the ambiguous event, the backend is + marked suspect and recycled exactly once on next use. + """ + + def mark_suspect(self, reason: str) -> None: + """Latch a suspicion about this backend. Must be cheap (no I/O).""" + raise NotImplementedError + + async def ensure_healthy(self, timeout: float = 5.0) -> bool: + """Verify a suspect backend before reuse. + + Returns True when the backend is healthy (clearing the suspicion); + returns False after forcing a reconnect/recycle so the caller's + normal no-session path handles the rebuild. Must not raise. + """ + raise NotImplementedError diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 514eda3834e9..39e8ed5c5278 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -245,10 +245,15 @@ def _billing_ambiguity_context(error_msg: str) -> Dict[str, Any]: "retry", "resets at", "reset in", + "resets in", + "reset after", + "available in", "wait", "requests remaining", "periodic", "window", + "per minute", + "per second", ] # Payload-too-large patterns detected from message text (no status_code attr). @@ -803,6 +808,7 @@ def classify_api_error( status_code = 429 body = _extract_error_body(error) error_code = _extract_error_code(body) + response_headers = _extract_response_headers(error) # Build a comprehensive error message string for pattern matching. # str(error) alone may not include the body message (e.g. OpenAI SDK's @@ -1047,6 +1053,7 @@ def _result(reason: FailoverReason, **overrides) -> ClassifiedError: provider=provider_lower, model=model_lower, approx_tokens=approx_tokens, context_length=context_length, num_messages=num_messages, + response_headers=response_headers, result_fn=_result, ) if classified is not None: @@ -1204,6 +1211,7 @@ def _classify_by_status( approx_tokens: int, context_length: int, num_messages: int = 0, + response_headers=None, result_fn, ) -> Optional[ClassifiedError]: """Classify based on HTTP status code with message-aware refinement.""" @@ -1338,6 +1346,48 @@ def _classify_by_status( should_fallback=True, error_context=ctx, ) + # Account/subscription usage exhaustion is a quota wall, not a + # request-rate throttle. Anthropic returns this as 429, so the generic + # branch below used to retry it and Desktop rendered a provider error + # instead of the billing/quota recovery. Preserve periodic quotas when + # the response supplies an explicit reset/retry signal. + # + # The check covers the narrow #93419 core (Anthropic's + # ``usage_limit_reached``) plus the broader ``_USAGE_LIMIT_PATTERNS`` + # ("quota", "limit exceeded", "key limit exceeded") so other providers' + # hard quota walls also route to billing — but ONLY when the message is + # not itself an explicit rate-limit phrase. Without that guard, + # "Rate limit exceeded" ("limit exceeded" substring) would wrongly + # promote to non-retryable billing. (broadening + guard credit #39441) + has_usage_limit = ( + error_code.lower() == "usage_limit_reached" + or "usage_limit_reached" in error_msg + or any(p in error_msg for p in _USAGE_LIMIT_PATTERNS) + ) + # Explicit billing phrases in a 429 body are a hard wall regardless of + # usage-limit wording — a provider that wraps "insufficient credits" in + # a 429 (rather than 402) was previously retried as a rate limit and + # burned the pool. (credit #39441) + has_billing = any(p in error_msg for p in _BILLING_PATTERNS) + has_explicit_rate_limit = any( + p in error_msg for p in _RATE_LIMIT_PATTERNS + ) + has_transient_signal = _has_usage_limit_transient_signal( + error_msg, + body, + response_headers, + ) + if ( + (has_billing or has_usage_limit) + and not has_explicit_rate_limit + and not has_transient_signal + ): + return result_fn( + FailoverReason.billing, + retryable=False, + should_rotate_credential=True, + should_fallback=True, + ) return result_fn( FailoverReason.rate_limit, retryable=True, @@ -1445,6 +1495,41 @@ def _classify_by_status( return None +def _has_usage_limit_transient_signal( + error_msg: str, + body: dict, + response_headers, +) -> bool: + """Return whether a usage-limit response identifies a reset window.""" + if any(pattern in error_msg for pattern in _USAGE_LIMIT_TRANSIENT_SIGNALS): + return True + + payloads = [body] + if isinstance(body, dict) and isinstance(body.get("error"), dict): + payloads.append(body["error"]) + reset_fields = ("resets_in_seconds", "resets_at", "reset_at", "retry_after") + for payload in payloads: + if not isinstance(payload, dict): + continue + if any( + payload.get(field) is not None and payload.get(field) != "" + for field in reset_fields + ): + return True + + if response_headers and hasattr(response_headers, "get"): + for header in ( + "retry-after", + "Retry-After", + "x-ratelimit-reset", + "X-RateLimit-Reset", + ): + value = response_headers.get(header) + if value is not None and value != "": + return True + return False + + def _classify_402(error_msg: str, result_fn) -> ClassifiedError: """Disambiguate 402: billing exhaustion vs transient usage limit. @@ -1965,6 +2050,21 @@ def _extract_error_body(error: Exception) -> dict: return {} +def _extract_response_headers(error: Exception): + """Walk the error and its cause chain to find response headers.""" + current = error + for _ in range(5): + response = getattr(current, "response", None) + headers = getattr(response, "headers", None) + if headers and hasattr(headers, "get"): + return headers + cause = getattr(current, "__cause__", None) or getattr(current, "__context__", None) + if cause is None or cause is current: + break + current = cause + return {} + + def _extract_error_code(body: dict) -> str: """Extract an error code string from the response body.""" if not body: diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 01e14c2c58ef..43d56464d0dd 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -99,7 +99,7 @@ def probe_gemini_tier( api_key: str, base_url: str = DEFAULT_GEMINI_BASE_URL, *, - model: str = "gemini-3.6-flash", + model: str = "gemini-3.7-flash", timeout: float = 10.0, ) -> str: """Probe a Google AI Studio API key and return its tier. @@ -360,6 +360,33 @@ def _translate_tool_call_to_gemini( return part +def _looks_like_json_schema(node: Any) -> bool: + """True if a parsed value contains a JSON-Schema-style ``$ref`` pointer. + + Gemini 3 resolves ``$ref``/``$defs`` references inside a + functionResponse.response payload and rejects unknown pointers with + HTTP 400 INVALID_ARGUMENT. A tool result that is itself a JSON Schema + (e.g. the output of ``tool_describe`` for an MCP tool) must therefore be + forwarded as opaque text rather than as a structured response. + + Detection is deliberately structural, not semantic: any ``$ref`` value + shaped like a JSON pointer (``#/...``) demotes the whole result. Non-schema + data that happens to carry such a pointer is a false positive, but the raw + content is preserved verbatim either way, so the cost is fidelity-free. + The recursive walk is O(n) over the parsed value; tool-result payloads are + small, so this is negligible per turn. + """ + if isinstance(node, dict): + for key, value in node.items(): + if key == "$ref" and isinstance(value, str) and value.startswith("#/"): + return True + if _looks_like_json_schema(value): + return True + elif isinstance(node, list): + return any(_looks_like_json_schema(item) for item in node) + return False + + def _translate_tool_result_to_gemini( message: Dict[str, Any], tool_name_by_call_id: Optional[Dict[str, str]] = None, @@ -382,6 +409,14 @@ def _translate_tool_result_to_gemini( parsed = json.loads(content) if content.strip().startswith(("{", "[")) else None except json.JSONDecodeError: parsed = None + # Gemini 3 resolves JSON-Schema ``$ref`` pointers inside a + # functionResponse.response payload and rejects unknown references with + # HTTP 400 INVALID_ARGUMENT ("referenced name '#/$defs/...' does not match + # a display_name"; see vercel/ai#14369). A tool result that is itself a + # JSON Schema (e.g. tool_describe output for an MCP tool) must therefore + # be forwarded as opaque text, not as a structured response. + if isinstance(parsed, dict) and _looks_like_json_schema(parsed): + parsed = None response = parsed if isinstance(parsed, dict) else {"output": content} function_response: Dict[str, Any] = { "name": name, @@ -1111,7 +1146,7 @@ def _advance_stream_iterator(iterator: Iterator[_GeminiStreamChunk]) -> tuple[bo def _create_chat_completion( self, *, - model: str = "gemini-3.6-flash", + model: str = "gemini-3.7-flash", messages: Optional[List[Dict[str, Any]]] = None, stream: bool = False, tools: Any = None, diff --git a/agent/image_routing.py b/agent/image_routing.py index a3762fccd4aa..a7f5c5c9f55b 100644 --- a/agent/image_routing.py +++ b/agent/image_routing.py @@ -332,17 +332,105 @@ def _resolve_inference_base_url( return "" -def _should_probe_ollama_vision(provider: str, base_url: str) -> bool: - """True when the active provider likely fronts a local Ollama server.""" +def _resolve_inference_api_key( + cfg: Optional[Dict[str, Any]], + provider: str, +) -> str: + """Best-effort API key for the active inference provider. + + Mirrors :func:`_resolve_inference_base_url`'s resolution order (runtime + value, then ``model.api_key``, then the providers blocks) so the key + matches the base URL actually being probed. Without this, the local + server-type probe fires at a remote API-keyed endpoint without an + Authorization header — 5×401 per image-bearing turn on a keyed + sglang/vLLM deployment (#89863). + """ + try: + from agent.auxiliary_client import _runtime_main_value + + runtime_key = str(_runtime_main_value("api_key") or "").strip() + if runtime_key: + return runtime_key + except Exception: + pass + + if not isinstance(cfg, dict): + return "" + + model_cfg_raw = cfg.get("model") + model_cfg: Dict[str, Any] = model_cfg_raw if isinstance(model_cfg_raw, dict) else {} + key = str(model_cfg.get("api_key") or "").strip() + if key: + return key + + config_provider = str(model_cfg.get("provider") or "").strip() + candidate_names: set[str] = set() + for p in filter(None, (provider, config_provider)): + candidate_names.add(p) + if p.lower().startswith("custom:"): + candidate_names.add(p.split(":", 1)[1]) + else: + candidate_names.add(f"custom:{p}") + + providers_cfg = cfg.get("providers") + if isinstance(providers_cfg, dict): + for name in candidate_names: + entry = providers_cfg.get(name) + if isinstance(entry, dict): + k = str(entry.get("api_key") or "").strip() + if k: + return k + + custom_providers = cfg.get("custom_providers") + if isinstance(custom_providers, list): + lowered = {n.lower() for n in candidate_names} + for entry_raw in custom_providers: + if not isinstance(entry_raw, dict): + continue + entry_name = str(entry_raw.get("name") or "").strip() + if entry_name not in candidate_names and entry_name.lower() not in lowered: + continue + k = str(entry_raw.get("api_key") or "").strip() + if k: + return k + + return "" + + +def _should_probe_ollama_vision( + provider: str, base_url: str, api_key: str = "" +) -> bool: + """True when the active provider likely fronts a local Ollama server. + + Server-fingerprint probing is only meaningful for *local* endpoints — + remote OpenAI-compatible APIs (sglang, vLLM, etc.) should never be probed, + and probing them without an api_key sprays 401s at the inference backend + (issue #89863). + """ p = (provider or "").strip().lower() if p == "ollama": return True if not base_url: return False + # Remote endpoints must never be fingerprinted: the probe waterfall is + # only valid for local/LM-Studio/Ollama boxes. Non-Ollama remotes (sglang, + # vLLM, OpenAI-compat) expose Ollama-compat endpoints that can misidentify + # and, without an api_key, return 401 on every leg (issue #89863). + if p != "ollama": + try: + from agent.model_metadata import is_local_endpoint + + if not is_local_endpoint(base_url): + return False + except Exception: + return False try: from agent.model_metadata import detect_local_server_type - return detect_local_server_type(base_url) == "ollama" + # Forward the API key: a remote API-keyed endpoint answers the + # probe waterfall with 401s without it, and an unauthorized probe + # can never produce a positive verdict (#89863). + return detect_local_server_type(base_url, api_key=api_key) == "ollama" except Exception: return False @@ -448,11 +536,18 @@ def _lookup_supports_vision( base_url = _resolve_inference_base_url(cfg, provider) if not base_url and (provider or "").strip().lower() == "ollama": base_url = "http://localhost:11434/v1" - if _should_probe_ollama_vision(provider, base_url): + + # Resolve the provider's API key so probe requests at keyed endpoints + # carry Authorization and don't spray 401s (issue #89863). + resolved_api_key = _resolve_inference_api_key(cfg, provider) + + if _should_probe_ollama_vision(provider, base_url, api_key=resolved_api_key): try: from agent.model_metadata import query_ollama_supports_vision - ollama_vision = query_ollama_supports_vision(model, base_url) + ollama_vision = query_ollama_supports_vision( + model, base_url, api_key=resolved_api_key + ) if ollama_vision is not None: return ollama_vision except Exception as exc: # pragma: no cover - defensive diff --git a/agent/interrupt_compat.py b/agent/interrupt_compat.py index bf56849495c7..22f2784c7979 100644 --- a/agent/interrupt_compat.py +++ b/agent/interrupt_compat.py @@ -6,12 +6,38 @@ from typing import Any -def request_hard_interrupt(agent: Any, message: str | None = None) -> bool: +def _accepts_keyword(callable_obj: Any, name: str) -> bool: + """Return whether a callable explicitly supports a keyword argument.""" + try: + parameters = inspect.signature(callable_obj).parameters.values() + except (TypeError, ValueError): + return False + return any( + parameter.kind is inspect.Parameter.VAR_KEYWORD + or ( + parameter.name == name + and parameter.kind is not inspect.Parameter.POSITIONAL_ONLY + ) + for parameter in parameters + ) + + +def request_hard_interrupt( + agent: Any, + message: str | None = None, + *, + tool_reason: str | None = None, +) -> bool: """Request an explicit stop, falling back to the legacy interrupt ABI. New agents expose ``hard_interrupt(message=None)``. Third-party agents and old test doubles may only expose ``interrupt(message=None)``; keep those - usable without sending the newer ``hard_cancel=`` keyword they do not know. + usable without sending newer keyword arguments they do not know. + + ``message`` is diagnostic/control-plane text. ``tool_reason`` is a trusted, + fixed category that may be exposed in model-visible tool cancellation + output. It is only forwarded when the modern callable explicitly supports + that channel. Returns ``False`` only when neither callable is available. """ # Avoid treating a dynamic ``__getattr__`` proxy (notably an unspecced @@ -28,8 +54,11 @@ def request_hard_interrupt(agent: Any, message: str | None = None) -> bool: interrupt = getattr(agent, "interrupt", None) if not callable(interrupt): return False + kwargs = {} + if tool_reason is not None and _accepts_keyword(interrupt, "tool_reason"): + kwargs["tool_reason"] = tool_reason if message is None: - interrupt() + interrupt(**kwargs) else: - interrupt(message) + interrupt(message, **kwargs) return True diff --git a/agent/lsp/client.py b/agent/lsp/client.py index 3411f5300b34..36c272102761 100644 --- a/agent/lsp/client.py +++ b/agent/lsp/client.py @@ -207,6 +207,7 @@ def __init__( self._proc: Optional[asyncio.subprocess.Process] = None self._stderr_task: Optional[asyncio.Task] = None self._reader_task: Optional[asyncio.Task] = None + self._cleanup_lock = asyncio.Lock() # Request/response correlation self._next_id: int = 0 @@ -255,7 +256,20 @@ def __init__( @property def is_running(self) -> bool: - return self._state == "running" and self._proc is not None and self._proc.returncode is None + return self._state == "running" and self._connection_is_open() + + def _connection_is_open(self) -> bool: + proc = self._proc + reader = self._reader_task + return ( + self._state in {"starting", "running"} + and proc is not None + and proc.returncode is None + and proc.stdin is not None + and not proc.stdin.is_closing() + and reader is not None + and not reader.done() + ) @property def state(self) -> str: @@ -274,6 +288,8 @@ async def start(self) -> None: try: await self._spawn() await self._initialize() + if not self._connection_is_open(): + raise LSPProtocolError("server connection closed during initialization") self._state = "running" except Exception: self._state = "error" @@ -370,11 +386,16 @@ async def _reader_loop(self) -> None: except (asyncio.CancelledError, OSError): pass finally: + unexpected_close = not self._stopping and self._state in {"starting", "running"} + if unexpected_close: + self._state = "error" # Wake up any pending requests so they can fail fast. for fut in list(self._pending.values()): if not fut.done(): fut.set_exception(LSPProtocolError("server connection closed")) self._pending.clear() + if unexpected_close: + await self._cleanup_process() async def _initialize(self) -> None: params = { @@ -474,43 +495,54 @@ async def shutdown(self) -> None: await self._cleanup_process() async def _cleanup_process(self) -> None: - if self._reader_task is not None and not self._reader_task.done(): - self._reader_task.cancel() - try: - await self._reader_task - except (asyncio.CancelledError, Exception): # noqa: BLE001 - pass - if self._stderr_task is not None and not self._stderr_task.done(): - self._stderr_task.cancel() - try: - await self._stderr_task - except (asyncio.CancelledError, Exception): # noqa: BLE001 - pass - proc = self._proc - self._proc = None - if proc is None: - return - if proc.returncode is None: - try: - proc.terminate() + async with self._cleanup_lock: + current_task = asyncio.current_task() + reader_task = self._reader_task + self._reader_task = None + if ( + reader_task is not None + and reader_task is not current_task + and not reader_task.done() + ): + reader_task.cancel() + try: + await reader_task + except (asyncio.CancelledError, Exception): # noqa: BLE001 + pass + stderr_task = self._stderr_task + self._stderr_task = None + if stderr_task is not None and not stderr_task.done(): + stderr_task.cancel() try: - await asyncio.wait_for(proc.wait(), timeout=SHUTDOWN_GRACE) - except asyncio.TimeoutError: + await stderr_task + except (asyncio.CancelledError, Exception): # noqa: BLE001 + pass + proc = self._proc + self._proc = None + if proc is None: + return + if proc.returncode is None: + try: + proc.terminate() try: - proc.kill() - await proc.wait() - except ProcessLookupError: - pass - except ProcessLookupError: - pass + await asyncio.wait_for(proc.wait(), timeout=SHUTDOWN_GRACE) + except asyncio.TimeoutError: + try: + proc.kill() + await proc.wait() + except ProcessLookupError: + pass + except ProcessLookupError: + pass # ------------------------------------------------------------------ # request / notification plumbing # ------------------------------------------------------------------ async def _send_request(self, method: str, params: Any) -> Any: - if self._proc is None or self._proc.stdin is None or self._proc.stdin.is_closing(): - raise LSPProtocolError(f"cannot send {method!r}: stdin closed") + if not self._connection_is_open(): + raise LSPProtocolError(f"cannot send {method!r}: server connection closed") + assert self._proc is not None and self._proc.stdin is not None loop = asyncio.get_running_loop() req_id = self._next_id self._next_id += 1 @@ -544,8 +576,9 @@ async def _send_request_with_retry(self, method: str, params: Any, *, timeout: f raise async def _send_notification(self, method: str, params: Any) -> None: - if self._proc is None or self._proc.stdin is None or self._proc.stdin.is_closing(): - return + if not self._connection_is_open(): + raise LSPProtocolError(f"cannot send {method!r}: server connection closed") + assert self._proc is not None and self._proc.stdin is not None try: self._proc.stdin.write(encode_message(make_notification(method, params))) await self._proc.stdin.drain() @@ -886,6 +919,10 @@ async def wait_for_diagnostics( abs_path = os.path.abspath(path) while True: + if not self._connection_is_open(): + raise LSPProtocolError( + "server connection closed while waiting for diagnostics" + ) remaining = deadline - asyncio.get_event_loop().time() if remaining <= 0: return False diff --git a/agent/memory_manager.py b/agent/memory_manager.py index 5b57dd16f478..818320ca723a 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -33,10 +33,14 @@ from concurrent.futures import Future, ThreadPoolExecutor, wait from typing import Any, Callable, Dict, List, Optional -from agent.memory_provider import MemoryProvider +from agent.memory_provider import MemoryProvider, PRE_COMPRESS_CHECKPOINT_API_VERSION from agent.skill_commands import extract_user_instruction_from_skill_message from tools.registry import tool_error +# Providers that predate the checkpoint-API attribute are implicitly on the +# historical best-effort contract (API v1). +_LEGACY_PRE_COMPRESS_API_VERSION = 1 + logger = logging.getLogger(__name__) # How long shutdown_all() waits for in-flight background sync/prefetch work @@ -107,6 +111,29 @@ def memory_provider_tools_enabled( return False +def memory_provider_tools_exposed(agent: Any) -> bool: + """Whether external memory-provider tools are exposed on ``agent``. + + Same gate as ``inject_memory_provider_tools`` so the provider's + ``system_prompt_block()`` and its tool schemas are presented to the + model together — otherwise the system prompt would advertise tools + that don't exist in the tool surface (#81014). + """ + tools = getattr(agent, "tools", None) + if isinstance(tools, (list, tuple)): + memory_tool_present = any( + isinstance(tool, dict) and tool.get("function", {}).get("name") == "memory" + for tool in tools + ) + else: + memory_tool_present = False + return memory_provider_tools_enabled( + getattr(agent, "enabled_toolsets", None), + getattr(agent, "disabled_toolsets", None), + memory_tool_present=memory_tool_present, + ) + + def inject_memory_provider_tools(agent: Any) -> int: """Append external memory-provider tool schemas to an agent tool surface.""" memory_manager = getattr(agent, "_memory_manager", None) @@ -119,11 +146,23 @@ def inject_memory_provider_tools(agent: Any) -> int: for tool in tools if isinstance(tool, dict) } - if not memory_provider_tools_enabled( - getattr(agent, "enabled_toolsets", None), - getattr(agent, "disabled_toolsets", None), - memory_tool_present="memory" in existing_tool_names, - ): + if not memory_provider_tools_exposed(agent): + # A provider is configured but the memory toolset is gated off + # (platform_toolsets / disabled_toolsets). Say so once — a silent + # return 0 here made #81014 undiagnosable: the provider looked + # "half on" with no clue which config key suppressed its tools. + _providers = [ + p for p in (getattr(memory_manager, "providers", None) or []) + if getattr(p, "name", "") != "builtin" + ] + if _providers: + logger.info( + "Memory provider(s) %s configured but the 'memory' toolset is " + "gated off for this session (platform_toolsets / " + "agent.disabled_toolsets) — provider tools and system-prompt " + "block are both withheld.", + [getattr(p, "name", type(p).__name__) for p in _providers], + ) return 0 get_schemas = getattr(memory_manager, "get_all_tool_schemas", None) @@ -1021,16 +1060,69 @@ def on_session_switch( provider.name, e, ) - def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str: + def supports_pre_compress_checkpoint( + self, + api_version: int = PRE_COMPRESS_CHECKPOINT_API_VERSION, + ) -> bool: + """Return whether an active provider guarantees checkpoint API support.""" + for provider in self._providers: + try: + provider_version = int( + getattr( + provider, + "pre_compress_checkpoint_api_version", + _LEGACY_PRE_COMPRESS_API_VERSION, + ) + ) + except (TypeError, ValueError): + continue + if provider_version >= api_version: + return True + return False + + def on_pre_compress( + self, + messages: List[Dict[str, Any]], + *, + evidence_messages: Optional[List[Dict[str, Any]]] = None, + require_checkpoint: bool = False, + checkpoint_api_version: int = PRE_COMPRESS_CHECKPOINT_API_VERSION, + ) -> str: """Notify all providers before context compression. Returns combined text from providers to include in the compression summary prompt. Empty string if no provider contributes. + + ``messages`` is the raw transcript — the historical (API v1) + contract every existing provider receives unchanged. + ``evidence_messages`` is the host-normalized direct-evidence list + handed only to providers that opted into checkpoint API v2+; when + omitted, v2 providers receive the raw list too. + + When ``require_checkpoint`` is true, at least one provider + advertising the requested checkpoint API must return successfully; + its exception is propagated so the caller can preserve the + uncompressed transcript. """ parts = [] + checkpoint_succeeded = False for provider in self._providers: try: - result = provider.on_pre_compress(messages) + provider_version = int( + getattr( + provider, + "pre_compress_checkpoint_api_version", + _LEGACY_PRE_COMPRESS_API_VERSION, + ) + ) + except (TypeError, ValueError): + provider_version = _LEGACY_PRE_COMPRESS_API_VERSION + is_checkpoint_provider = provider_version >= checkpoint_api_version + provider_messages = messages + if is_checkpoint_provider and evidence_messages is not None: + provider_messages = evidence_messages + try: + result = provider.on_pre_compress(provider_messages) if result and result.strip(): parts.append(result) except Exception as e: @@ -1038,6 +1130,16 @@ def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str: "Memory provider '%s' on_pre_compress failed: %s", provider.name, e, ) + if require_checkpoint and is_checkpoint_provider: + raise + else: + if is_checkpoint_provider: + checkpoint_succeeded = True + if require_checkpoint and not checkpoint_succeeded: + raise RuntimeError( + "No active memory provider completed pre-compress checkpoint " + f"API v{checkpoint_api_version}" + ) return "\n\n".join(parts) @staticmethod diff --git a/agent/memory_provider.py b/agent/memory_provider.py index 317ad73d5aeb..014f89550329 100644 --- a/agent/memory_provider.py +++ b/agent/memory_provider.py @@ -41,6 +41,12 @@ logger = logging.getLogger(__name__) +# Version 1 is the historical, implicit contract every provider is already +# on: best-effort on_pre_compress() with the raw message list. Version 2 is +# the opt-in fail-closed checkpoint contract (normalized evidence handoff + +# strict-mode failure propagation). +PRE_COMPRESS_CHECKPOINT_API_VERSION = 2 + # Default glyph for the deterministic memory indicators. Providers override # per-status with their own brand mark (e.g. Hindsight uses "👁️"). INDICATOR_GLYPH = "🧠" @@ -104,6 +110,12 @@ def is_trivial_prompt(text: Optional[str]) -> bool: class MemoryProvider(ABC): """Abstract base class for memory providers.""" + # Providers that durably checkpoint every successful on_pre_compress() + # call may opt into that host contract by setting the current version + # (PRE_COMPRESS_CHECKPOINT_API_VERSION). Version 1 is the implicit + # historical contract: best-effort semantics, raw message list. + pre_compress_checkpoint_api_version = 1 + @property @abstractmethod def name(self) -> str: diff --git a/agent/message_sanitization.py b/agent/message_sanitization.py index e68715cf3942..fed524f2ae13 100644 --- a/agent/message_sanitization.py +++ b/agent/message_sanitization.py @@ -493,6 +493,8 @@ def _walk(node): # call_id policy owners (F4 consolidation) "deterministic_call_id", "coalesce_tool_call_id", + "tool_call_id_variants", + "tool_result_id_variants", "uniquify_tool_call_ids", # reasoning_content policy owners (F4 consolidation) "reasoning_echo_family", @@ -536,16 +538,72 @@ def deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str: return f"call_{digest}" +def _expand_tool_id_variants(values: tuple[Any, ...]) -> frozenset[str]: + """Return every wire spelling of one or more tool-call identifiers. + + Responses bridges may expose the pairing id and response-item id + separately, or encode both as ``call_id|response_item_id``. The values + are aliases for one call, not distinct calls. Keeping the expansion in + the shared policy module prevents the repair and pre-send paths from + drifting apart again. + """ + variants: set[str] = set() + for raw in values: + if not isinstance(raw, str): + continue + value = raw.strip() + if not value: + continue + variants.add(value) + if "|" in value: + for part in value.split("|"): + part = part.strip() + if part: + variants.add(part) + return frozenset(variants) + + +def tool_call_id_variants(tc: Any) -> frozenset[str]: + """Return all pairing-id variants carried by a tool-call entry.""" + if isinstance(tc, dict): + values = ( + tc.get("call_id"), + tc.get("id"), + tc.get("response_item_id"), + ) + else: + values = ( + getattr(tc, "call_id", None), + getattr(tc, "id", None), + getattr(tc, "response_item_id", None), + ) + return _expand_tool_id_variants(values) + + +def tool_result_id_variants(tool_call_id: Any) -> frozenset[str]: + """Return all matching variants for a role=tool ``tool_call_id``.""" + return _expand_tool_id_variants((tool_call_id,)) + + def coalesce_tool_call_id(tc: Any) -> str: """Extract the effective call ID from a tool_call entry (dict or object). - Single owner for the ``call_id or id`` coalescing rule: Codex Responses - tool calls carry ``call_id`` (authoritative pairing key), Chat - Completions ones carry ``id`` only. Returns ``""`` when neither is set. + Single owner for the canonical pairing rule: Codex Responses tool calls + carry ``call_id`` (authoritative pairing key), Chat Completions ones carry + ``id`` only, and bridge ids may encode ``call_id|response_item_id``. + Returns ``""`` when neither pairing field is set. """ if isinstance(tc, dict): - return (tc.get("call_id", "") or tc.get("id", "") or "").strip() - return (getattr(tc, "call_id", "") or getattr(tc, "id", "") or "").strip() + values = (tc.get("call_id"), tc.get("id")) + else: + values = (getattr(tc, "call_id", None), getattr(tc, "id", None)) + for raw in values: + if not isinstance(raw, str): + continue + value = raw.strip() + if value: + return value.split("|", 1)[0].strip() or value + return "" def uniquify_tool_call_ids(tool_calls: list) -> list: diff --git a/agent/model_metadata.py b/agent/model_metadata.py index bad065573a0b..b1e4724ce9bb 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -158,6 +158,14 @@ def _strip_provider_prefix(model: str) -> str: # of being pinned to the stale type for the whole process lifetime. # Values are (server_type, monotonic_timestamp). _ENDPOINT_PROBE_TTL_SECONDS = 3600.0 +# A failed probe verdict (server_type is None — no known endpoint answered) +# is cached for a much shorter window: the in-memory entry exists only to +# keep one image-bearing turn from re-running the 5-request waterfall on +# every subsequent turn (#89863 — a keyed remote endpoint answered 401 to +# each leg and the None verdict was never cached, so every turn re-probed). +# Short TTL keeps a transient failure (server starting up, key being fixed) +# recoverable within minutes instead of pinning "undetected" for an hour. +_ENDPOINT_PROBE_FAILURE_TTL_SECONDS = 300.0 _endpoint_probe_path_cache: Dict[str, tuple] = {} # A configured endpoint that is routable-but-dead — e.g. a corp LAN address @@ -1009,8 +1017,18 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: lmstudio_url = _lmstudio_server_root(normalized) cached = _endpoint_probe_path_cache.get(server_url) - if cached is not None and (time.monotonic() - cached[1]) < _ENDPOINT_PROBE_TTL_SECONDS: - return cached[0] + if cached is not None: + # Positive verdicts live for the full TTL; a None verdict (probe + # waterfall answered nothing recognizable) gets the short failure + # TTL so it still throttles re-probing without pinning the + # endpoint as undetected for a whole hour (#89863). + ttl = ( + _ENDPOINT_PROBE_TTL_SECONDS + if cached[0] is not None + else _ENDPOINT_PROBE_FAILURE_TTL_SECONDS + ) + if (time.monotonic() - cached[1]) < ttl: + return cached[0] # The host already blackholed a connect: skip the waterfall below, each leg # of which would otherwise burn its full 2s timeout. Deliberately NOT @@ -1089,6 +1107,12 @@ def _probe_failed(exc: Exception) -> None: if result is not None: _endpoint_probe_path_cache[server_url] = (result, time.monotonic()) _local_probe_disk_put("server_type", server_url, result) + else: + # Cache the negative verdict in memory only (never on disk — a + # failure is often transient: server starting, key being fixed) + # so the very next turn does not re-run the whole waterfall + # against an endpoint that just answered nothing (#89863). + _endpoint_probe_path_cache[server_url] = (None, time.monotonic()) return result @@ -1128,6 +1152,25 @@ def _extract_first_int(payload: Dict[str, Any], keys: tuple[str, ...]) -> Option return None +def _extract_flat_context_length(payload: Dict[str, Any]) -> Optional[int]: + """Read a context WINDOW from the top level of a model-describe payload. + + Same key vocabulary as :func:`_extract_context_length` (the module's single + source of truth for what counts as a context window), but WITHOUT the + nested-dict walk — for callers that hold a specific model object and must + not pick up a same-named key from an unrelated nested section. + + Critically, ``max_tokens`` is NOT in ``_CONTEXT_LENGTH_KEYS``: it lives in + ``_MAX_COMPLETION_KEYS`` because on an OpenAI-compatible ``/v1/models`` + passthrough it is the max *output* tokens, not the context window. + """ + for key in _CONTEXT_LENGTH_KEYS: + coerced = _coerce_reasonable_int(payload.get(key)) + if coerced is not None: + return coerced + return None + + def _extract_context_length(payload: Dict[str, Any]) -> Optional[int]: return _extract_first_int(payload, _CONTEXT_LENGTH_KEYS) @@ -1136,6 +1179,36 @@ def _extract_max_completion_tokens(payload: Dict[str, Any]) -> Optional[int]: return _extract_first_int(payload, _MAX_COMPLETION_KEYS) +def _context_length_from_model_payload(payload: Dict[str, Any]) -> Optional[int]: + """Extract a context *window* from a ``/v1/models`` model object. + + Prefers input-window keys (``max_model_len``, ``max_input_tokens``, + ``context_length``, …) via :func:`_extract_flat_context_length`. Falls back to + ``max_tokens`` only when no input-window field is present. + + Anthropic (and Anthropic-compatible proxies such as local reverse + proxies) expose both ``max_input_tokens`` (context window, e.g. 1M) and + ``max_tokens`` (max *output* length, e.g. 128k). Using ``max_tokens`` as + the context window under-reports the real limit, persists a stale value + into ``context_length_cache.yaml``, and makes the compressor fire far too + early (e.g. at 75% of 128k instead of 75% of 1M). + """ + if not isinstance(payload, dict): + return None + ctx = _extract_flat_context_length(payload) + if ctx is not None: + return ctx + # Last resort for OpenAI-compat servers that only report max_tokens as + # the window. Safe for Anthropic shapes because max_input_tokens is + # present and already handled above. + raw = payload.get("max_tokens") + if isinstance(raw, (int, float)): + ivalue = int(raw) + if ivalue > 0: + return ivalue + return None + + def _extract_pricing(payload: Dict[str, Any]) -> Dict[str, Any]: novita_input = payload.get("input_token_price_per_m") novita_output = payload.get("output_token_price_per_m") @@ -2281,14 +2354,24 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str return int(ctx) break - # LM Studio / vLLM / llama.cpp: try /v1/models/{model} + # LM Studio / vLLM / llama.cpp / Anthropic-compat proxies: + # try /v1/models/{model} resp = client.get(f"{server_url}/v1/models/{model}") if resp.status_code == 200: data = resp.json() - # vLLM returns max_model_len - ctx = data.get("max_model_len") or data.get("context_length") or data.get("max_tokens") - if ctx and isinstance(ctx, (int, float)): - return int(ctx) + if isinstance(data, dict): + # Context-WINDOW keys only (canonical _CONTEXT_LENGTH_KEYS + # vocabulary). `max_tokens` is the max *output* tokens on + # OpenAI-compatible passthroughs (LiteLLM, Anthropic-compat + # shims, cloud proxies) — e.g. 393216 for a 1M-context + # model — so reading it ahead of real window keys collapses + # the window to the output cap and poisons the context + # cache. It is consulted only as an explicit last resort + # inside _context_length_from_model_payload, for servers + # that report nothing else. + ctx = _context_length_from_model_payload(data) + if ctx is not None: + return ctx # Try /v1/models and find the model in the list. # Use _model_id_matches to handle "publisher/slug" vs bare "slug". @@ -2301,6 +2384,8 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str # so fall back to the sole model when nothing matches. matched = None for m in models_list: + if not isinstance(m, dict): + continue if _model_id_matches(m.get("id", ""), model): matched = m break @@ -2311,21 +2396,25 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str # vLLM/OpenAI keys are also checked. Runtime n_ctx is # preferred over n_ctx_train (the training maximum, which # can be larger than what the server actually allocates). - for source in (matched, matched.get("meta") or {}): - if not isinstance(source, dict): - continue - for key in ( - "n_ctx", - "context_length", - "context_window", - "max_model_len", - "max_context_length", - "max_tokens", - "n_ctx_train", - ): - val = source.get(key) - if isinstance(val, (int, float)) and val: - return int(val) + sources = [ + s + for s in (matched, matched.get("meta") or {}) + if isinstance(s, dict) + ] + for source in sources: + val = source.get("n_ctx") + if isinstance(val, (int, float)) and val: + return int(val) + # Canonical context-WINDOW keys (via _CONTEXT_LENGTH_KEYS) + # with max_tokens demoted to an explicit last resort — + # sibling of the /v1/models/{id} path above; see that + # comment for why max_tokens must never win over a real + # window key (it is the max OUTPUT cap on + # Anthropic/OpenAI-compatible passthroughs). + for source in sources: + ctx = _context_length_from_model_payload(source) + if ctx is not None: + return ctx except Exception as exc: if _is_connect_timeout(exc): _note_endpoint_blackholed(server_url) @@ -2418,9 +2507,18 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) -> # ≥11K margin under the observed ceiling and matches the compaction point # Codex's own client config documents for the 1M window. # -# Applied ONLY when the resolved value (live probe or fallback table) is -# exactly the known-stale 272,000 advertisement — if OpenAI moves the -# advertised number in either direction (the gpt-5.6 family shifted +# OPT-IN ONLY (Aug 2026 policy, Teknium): the large window is exposed via +# explicit ``-900k`` picker variants (e.g. ``gpt-5.6-sol-900k``) — the base +# slugs keep the advertised 272K so the cheaper limit is the default. A +# week of the 900K default burned through subscription usage for people +# who never asked for it. The variant suffix is a Hermes-side alias: it is +# stripped before the model id hits the wire (see +# ``strip_codex_context_variant_suffix`` callers in agent/transports/codex.py +# and agent/auxiliary_client.py). +# +# The bump is applied ONLY when the resolved value (live probe or fallback +# table) is exactly the known-stale 272,000 advertisement — if OpenAI moves +# the advertised number in either direction (the gpt-5.6 family shifted # 272K → 372K → 272K during July 2026), the catalog is trusted again and # this table is inert. ``gpt-5.6`` is a FAMILY PREFIX (sol/terra/luna and # dated snapshots; ``-pro`` slugs are not routable on Codex OAuth — the @@ -2438,21 +2536,118 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) -> # The advertised value the verified-above table is allowed to override. _CODEX_OAUTH_STALE_ADVERTISED_CTX = 272_000 +# Hermes-side picker suffix that opts a Codex slug into the live-verified +# large window. Never sent on the wire. +CODEX_CONTEXT_VARIANT_SUFFIX = "-900k" + +# The ONLY base slugs eligible for a ``-900k`` variant: routable, +# live-verified models. gpt-5.6 family-prefix matching is deliberately NOT +# used here — it would synthesize dead variants for ``-pro`` slugs (the +# Codex backend 400s them) and accept arbitrary future descendants that +# were never probed. Dated snapshots of the routable 5.6 bases are allowed +# via _CODEX_900K_SNAPSHOT_RE. +_CODEX_900K_ELIGIBLE_BASES = frozenset({ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.4", # exact; gpt-5.4-mini enforces 272K + "gpt-daybreak-blue-latest", # verified Sol alias +}) +_CODEX_900K_SNAPSHOT_BASES = ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna") +_CODEX_900K_SNAPSHOT_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _bare_codex_slug(model: Optional[str]) -> str: + """Lowercased slug with any ``vendor/`` namespace removed. + + Display/auxiliary callers pass ids like ``openai/gpt-5.6-sol-900k``; + the main-agent path normalizes the namespace away earlier, but this + resolver must accept both shapes (#92797 review). + """ + return (model or "").strip().lower().rsplit("/", 1)[-1] + + +def is_codex_900k_base(model: Optional[str]) -> bool: + """True when *model* (a BASE slug, no suffix) may carry a ``-900k`` variant. + + Single source of truth for the eligibility check — used by picker + synthesis, context resolution, `/model` validation, and wire stripping + so the four sites can never drift apart. + """ + slug = _bare_codex_slug(model) + if not slug or slug.endswith(CODEX_CONTEXT_VARIANT_SUFFIX): + return False + if slug in _CODEX_900K_ELIGIBLE_BASES: + return True + # Dated snapshots of the routable 5.6 bases (gpt-5.6-sol-2026-07-09). + for base in _CODEX_900K_SNAPSHOT_BASES: + if slug.startswith(base + "-") and _CODEX_900K_SNAPSHOT_RE.match( + slug[len(base) + 1:] + ): + return True + return False + + +def is_codex_context_variant(model: Optional[str]) -> bool: + """True when the model id is a VALID ``-900k`` opt-in variant. + + Requires both the suffix and an eligible base — ``gpt-5.5-900k`` is not + a variant, it's an invalid alias. + """ + slug = _bare_codex_slug(model) + if not slug.endswith(CODEX_CONTEXT_VARIANT_SUFFIX): + return False + return is_codex_900k_base(slug[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)]) + + +def strip_codex_context_variant_suffix(model: Optional[str]) -> str: + """Return the wire-safe slug with a VALID ``-900k`` suffix removed. + + The suffix is a Hermes picker alias (``gpt-5.6-sol-900k``); the Codex + backend only knows the base slug. Stripping is conditional on base + eligibility: an ineligible alias like ``gpt-5.5-900k`` is returned + unchanged so it fails honestly at the API instead of silently running + as a different model. Case-insensitive; preserves any ``vendor/`` + namespace prefix. + """ + raw = (model or "").strip() + if not raw.lower().endswith(CODEX_CONTEXT_VARIANT_SUFFIX): + return raw + base = raw[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)] + if is_codex_900k_base(base): + return base + return raw + + +def has_codex_context_variant(model_bare: str) -> bool: + """True when a Codex BASE slug should get a synthetic ``-900k`` entry. + + Thin alias over :func:`is_codex_900k_base` kept for the picker call + sites' readability. + """ + return is_codex_900k_base(model_bare) + def _verified_codex_ctx_for_slug(model_bare: str) -> Optional[int]: - """Return the live-verified Codex cap for a slug, or ``None``. + """Return the live-verified Codex cap for an OPTED-IN slug, or ``None``. - Exact slugs first, then family prefixes (````, ``-``, - ``.``) so dated snapshots of a verified family inherit the bump. + The large window is opt-in: only VALID ``-900k`` picker variants + (e.g. ``gpt-5.6-sol-900k``) resolve to the verified cap. Base slugs + keep the advertised 272K so the cheaper default limit applies unless + the user explicitly selects the large-context variant; ineligible + aliases (``gpt-5.5-900k``) never resolve here. """ - slug = (model_bare or "").strip().lower() - if not slug: + slug = _bare_codex_slug(model_bare) + if not slug.endswith(CODEX_CONTEXT_VARIANT_SUFFIX): + return None + base = slug[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)] + if not is_codex_900k_base(base): return None - exact = _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_EXACT.get(slug) + exact = _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_EXACT.get(base) if exact is not None: return exact for key, ctx in _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_PREFIXES.items(): - if slug == key or slug.startswith(key + "-") or slug.startswith(key + "."): + if base == key or base.startswith(key + "-") or base.startswith(key + "."): return ctx return None @@ -2586,8 +2781,9 @@ def _resolve_codex_oauth_context_length_with_source( def _apply_verified_bump(ctx: int, source: str) -> Tuple[int, str]: """Lift a known-stale 272K advertisement to the live-verified cap. - Only fires when the resolved value is EXACTLY the stale 272,000 - advertisement for a slug we have probed above it (see + Only fires for explicit ``-900k`` picker variants (opt-in), and only + when the resolved value is EXACTLY the stale 272,000 advertisement + for a slug we have probed above it (see ``_verified_codex_ctx_for_slug``). Any other advertised value — higher or lower — is trusted as a real server-side change. """ @@ -2600,19 +2796,26 @@ def _apply_verified_bump(ctx: int, source: str) -> Tuple[int, str]: return bumped, source return ctx, source + # ``-900k`` variants are Hermes picker aliases — the Codex catalog only + # knows the base slug, so resolve against the stripped id. Also drop any + # ``vendor/`` namespace (``openai/gpt-5.6-sol-900k``): the main-agent + # path normalizes it away before reaching here, but display/auxiliary + # callers pass it through (#92797 review). + lookup_bare = _bare_codex_slug(strip_codex_context_variant_suffix(model_bare)) + if access_token: live, fresh_probe = _fetch_codex_oauth_context_lengths_with_source(access_token) live_source = "live" if fresh_probe else "memory" - if model_bare in live: - return _apply_verified_bump(live[model_bare], live_source) + if lookup_bare in live: + return _apply_verified_bump(live[lookup_bare], live_source) # Case-insensitive match in case casing drifts - model_lower = model_bare.lower() + model_lower = lookup_bare.lower() for slug, ctx in live.items(): if slug.lower() == model_lower: return _apply_verified_bump(ctx, live_source) # Fallback: longest-key-first substring match over hardcoded defaults. - model_lower = model_bare.lower() + model_lower = lookup_bare.lower() for slug, ctx in sorted( _CODEX_OAUTH_CONTEXT_FALLBACK.items(), key=lambda x: len(x[0]), reverse=True ): diff --git a/agent/monitoring/cron_health.py b/agent/monitoring/cron_health.py index 2ed0f1cb9b2e..2c4fe8d35ff2 100644 --- a/agent/monitoring/cron_health.py +++ b/agent/monitoring/cron_health.py @@ -24,7 +24,9 @@ logger = logging.getLogger(__name__) _KNOWN_STATUSES = {"claimed", "running", "completed", "failed", "unknown"} _KNOWN_SOURCES = {"builtin", "direct", "external"} -_KNOWN_DELIVERY_OUTCOMES = {"delivered", "failed", "suppressed", "not_configured"} +_KNOWN_DELIVERY_OUTCOMES = { + "delivered", "failed", "suppressed", "suppressed_acked", "not_configured", +} @dataclass(frozen=True, slots=True) diff --git a/agent/native_compaction.py b/agent/native_compaction.py index d718ad70b287..5dbd374825ee 100644 --- a/agent/native_compaction.py +++ b/agent/native_compaction.py @@ -116,6 +116,26 @@ def resolve_compact_threshold( return max(1_024, min(configured, upper)) +_checkpoint_suppression_logged = False + + +def _warn_native_compaction_suppressed_by_checkpoint_gate() -> None: + """Log once per process that the checkpoint gate suppresses native compaction. + + The suppression itself is re-evaluated per request; only the log line is + deduplicated so a long session does not repeat it on every API call. + """ + global _checkpoint_suppression_logged + if _checkpoint_suppression_logged: + return + _checkpoint_suppression_logged = True + logger.warning( + "compression.checkpoint_required is enabled: server-side native " + "compaction (context_management) is disabled for this agent so the " + "checkpoint-aware Hermes compressor stays authoritative." + ) + + def native_compaction_context_management( agent: Any, *, @@ -137,6 +157,14 @@ def native_compaction_context_management( # included — mirrors the codex_app_server_auto contract. if not bool(getattr(agent, "compression_enabled", True)): return None + # compression.checkpoint_required: server-side compaction is a lossy + # boundary the provider owns — no pre-compress checkpoint can run before + # the server replaces older context. Keep the checkpoint-aware Hermes + # compressor authoritative instead of silently letting the server + # compact. Explicit-True check matches the compress_context() gate. + if getattr(agent, "compression_checkpoint_required", False) is True: + _warn_native_compaction_suppressed_by_checkpoint_gate() + return None if is_xai_responses or is_github_responses: return None if not is_native_compaction_model(getattr(agent, "model", None)): diff --git a/agent/process_bootstrap.py b/agent/process_bootstrap.py index 89b6278a8c25..7ef4cf2df84d 100644 --- a/agent/process_bootstrap.py +++ b/agent/process_bootstrap.py @@ -15,6 +15,9 @@ 3. **HTTP proxy resolution** — ``_get_proxy_from_env`` reads ``HTTPS_PROXY`` / ``HTTP_PROXY`` / ``ALL_PROXY``; ``_get_proxy_for_base_url`` respects ``NO_PROXY`` for the given base URL. +4. **Codex dual-stack resilience** — the synchronous ChatGPT/Codex transport + races resolved IPv6/IPv4 addresses so a blackholed family cannot exhaust + the request watchdog before a working address is attempted. ``run_agent`` re-exports every name so existing ``from run_agent import _get_proxy_from_env`` imports keep working @@ -23,8 +26,12 @@ from __future__ import annotations +import errno import os +import selectors +import socket import sys +import time import urllib.request from typing import Any, Optional @@ -34,6 +41,232 @@ # Cached at module level so we only pay the OpenAI SDK import cost once # per process (after the first lazy load). _OPENAI_CLS_CACHE = None +_HAPPY_EYEBALLS_DELAY_SECONDS = 0.25 + + +def _interleave_addrinfos(addrinfos: list[tuple]) -> list[tuple]: + """Interleave resolved address families while preserving resolver order.""" + queues: dict[int, list[tuple]] = {} + family_order: list[int] = [] + seen: set[tuple] = set() + for addrinfo in addrinfos: + family, socktype, proto, _canonname, sockaddr = addrinfo + marker = (family, socktype, proto, sockaddr) + if marker in seen: + continue + seen.add(marker) + if family not in queues: + queues[family] = [] + family_order.append(family) + queues[family].append(addrinfo) + + interleaved: list[tuple] = [] + while any(queues.values()): + for family in family_order: + if queues[family]: + interleaved.append(queues[family].pop(0)) + return interleaved + + +def _happy_eyeballs_create_connection( + address: tuple[str, int], + timeout: Optional[float], + source_address: Optional[tuple[str, int]] = None, + socket_options=(), +): + """Connect using staggered non-blocking attempts across resolved families. + + ``socket.create_connection`` tries every address serially. A host with + broken-but-advertised IPv6 can therefore consume the full connect timeout + for each AAAA record before trying a working IPv4 address. This follows the + Happy Eyeballs shape from RFC 8305: retain resolver preference, interleave + families, and start the next candidate after a short delay. + """ + host, port = address + addrinfos = _interleave_addrinfos( + socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + ) + if not addrinfos: + raise OSError(f"getaddrinfo returned no addresses for {host}") + + selector = selectors.DefaultSelector() + active: set[socket.socket] = set() + winner = None + last_error: Optional[OSError] = None + deadline = None if timeout is None else time.monotonic() + max(timeout, 0.0) + next_launch = time.monotonic() + pending = list(addrinfos) + in_progress = { + 0, + errno.EINPROGRESS, + errno.EWOULDBLOCK, + errno.EALREADY, + errno.EINTR, + getattr(errno, "WSAEWOULDBLOCK", 10035), + } + + def start_attempt(addrinfo): + family, socktype, proto, _canonname, sockaddr = addrinfo + candidate = socket.socket(family, socktype, proto) + try: + if source_address is not None: + local_infos = socket.getaddrinfo( + source_address[0], + source_address[1], + family=family, + type=socktype, + ) + if not local_infos: + raise OSError( + f"getaddrinfo returned no local {family} address for " + f"{source_address[0]}" + ) + candidate.bind(local_infos[0][4]) + candidate.setblocking(False) + result = candidate.connect_ex(sockaddr) + if result == 0 or result == errno.EISCONN: + return candidate + if result not in in_progress: + raise OSError(result, os.strerror(result)) + selector.register(candidate, selectors.EVENT_WRITE) + active.add(candidate) + return None + except Exception: + candidate.close() + raise + + try: + while pending or active: + now = time.monotonic() + if deadline is not None and now >= deadline: + raise socket.timeout("timed out") + + if pending and now >= next_launch: + addrinfo = pending.pop(0) + try: + winner = start_attempt(addrinfo) + except OSError as exc: + last_error = exc + if not active: + next_launch = now + continue + if winner is not None: + break + next_launch = now + _HAPPY_EYEBALLS_DELAY_SECONDS + + wait_timeout = None if deadline is None else max(0.0, deadline - now) + if pending: + until_launch = max(0.0, next_launch - now) + wait_timeout = ( + until_launch + if wait_timeout is None + else min(wait_timeout, until_launch) + ) + + events = selector.select(wait_timeout) + for key, _mask in events: + candidate = key.fileobj + error_code = candidate.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) + selector.unregister(candidate) + active.discard(candidate) + if error_code == 0: + winner = candidate + break + candidate.close() + last_error = OSError(error_code, os.strerror(error_code)) + if winner is not None: + break + if not active and pending: + next_launch = time.monotonic() + + if winner is None: + if last_error is not None: + raise last_error + raise OSError(f"Could not connect to {host}:{port}") + + try: + selector.unregister(winner) + except Exception: + pass + active.discard(winner) + winner.settimeout(timeout) + for option in socket_options or (): + winner.setsockopt(*option) + winner.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + return winner + finally: + for candidate in active: + try: + selector.unregister(candidate) + except Exception: + pass + candidate.close() + selector.close() + + +class _HappyEyeballsSyncBackend: + """httpcore sync backend with concurrent IPv6/IPv4 connection fallback.""" + + def __init__(self): + self._fallback = None + + def _default_backend(self): + if self._fallback is None: + from httpcore import SyncBackend + + self._fallback = SyncBackend() + return self._fallback + + def connect_tcp( + self, + host: str, + port: int, + timeout: Optional[float] = None, + local_address: Optional[str] = None, + socket_options=None, + ): + from httpcore import ConnectError, ConnectTimeout + from httpcore._backends.sync import SyncStream + + source_address = None if local_address is None else (local_address, 0) + try: + sock = _happy_eyeballs_create_connection( + (host, port), + timeout, + source_address=source_address, + socket_options=socket_options or (), + ) + except socket.timeout as exc: + raise ConnectTimeout(str(exc)) from exc + except OSError as exc: + raise ConnectError(str(exc)) from exc + return SyncStream(sock) + + def connect_unix_socket(self, *args, **kwargs): + return self._default_backend().connect_unix_socket(*args, **kwargs) + + def sleep(self, seconds: float) -> None: + self._default_backend().sleep(seconds) + + +def _uses_codex_cloud_transport(base_url: str) -> bool: + return ( + base_url_hostname(base_url).lower() == "chatgpt.com" + and "/backend-api/codex" in str(base_url).lower() + ) + + +def _enable_happy_eyeballs(transport) -> None: + """Install the sync racing backend on one httpx transport, if compatible. + + Reaches into httpx/httpcore private attributes (``transport._pool`` / + ``pool._network_backend``); safe because httpcore is pinned (1.0.x) and + both lookups are hasattr-guarded — on an incompatible httpcore this + degrades to the default serial backend instead of crashing. + """ + pool = getattr(transport, "_pool", None) + if pool is not None and hasattr(pool, "_network_backend"): + pool._network_backend = _HappyEyeballsSyncBackend() def _load_openai_cls() -> type: @@ -186,10 +419,12 @@ def build_keepalive_http_client( client_cls = httpx.AsyncClient if async_mode else httpx.Client mounts = {} if proxy is None: - mounts = { - "http://": transport_cls(verify=verify), - "https://": transport_cls(verify=verify), - } + http_transport = transport_cls(verify=verify) + https_transport = transport_cls(verify=verify) + if not async_mode and _uses_codex_cloud_transport(base_url): + _enable_happy_eyeballs(http_transport) + _enable_happy_eyeballs(https_transport) + mounts = {"http://": http_transport, "https://": https_transport} return client_cls( limits=limits, timeout=timeout, diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 4f26ffcf2a93..91bff527298b 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -168,6 +168,20 @@ def _strip_yaml_frontmatter(content: str) -> str: "of truth when the two differ." ) +# Variant injected when the skill tools are not in the session's toolset +# (e.g. a Blank Slate install with the skills toolset disabled). Pointing the +# model at skill_view() there would be a dangling reference — the docs URL is +# the only actionable pointer. +HERMES_AGENT_HELP_GUIDANCE_NO_SKILLS = ( + "You run on Hermes Agent (by Nous Research). When the user needs help with " + "Hermes itself — configuring, setting up, using, extending, or troubleshooting " + "it — or when you need to understand your own features, tools, or capabilities, " + "the documentation at https://hermes-agent.nousresearch.com/docs is the " + "authoritative reference and always holds the latest, most up-to-date " + "information. Point the user there (or read it yourself if you have a way to " + "fetch web content)." +) + MEMORY_GUIDANCE = ( "You have persistent memory across sessions. Save durable facts using the memory " "tool: user preferences, environment details, tool quirks, and stable conventions. " @@ -563,6 +577,26 @@ def _strip_yaml_frontmatter(content: str) -> str: "" ) + +def execution_guidance_text(valid_tool_names=None) -> str: + """Render OPENAI_MODEL_EXECUTION_GUIDANCE for the session's toolset. + + The block names ``web_search`` as the lookup tool for current facts; on + sessions without web tools (e.g. Blank Slate) that's a dangling + reference, so the web_search lines are dropped/adjusted. Deterministic + per-session (toolset is fixed at construction), so cache-safe. + """ + text = OPENAI_MODEL_EXECUTION_GUIDANCE + if valid_tool_names is not None and "web_search" not in valid_tool_names: + text = text.replace( + "- Current facts (weather, news, versions) → use web_search\n", "" + ) + text = text.replace( + "(search_files, web_search, read_file, etc.)", + "(search_files, read_file, etc.)", + ) + return text + # Gemini/Gemma-specific operational guidance, adapted from OpenCode's gemini.txt. # Injected alongside TOOL_USE_ENFORCEMENT_GUIDANCE when the model is Gemini or Gemma. GOOGLE_MODEL_OPERATIONAL_GUIDANCE = ( @@ -1195,6 +1229,35 @@ def hud_surface_note(valid_tool_names: "set[str] | None" = None) -> str: # Only states what we know from the backend choice itself (container type, # likely OS family). Does NOT invent cwd, user, or $HOME — the agent is # told to probe those directly if it needs them. +def _plugin_backend_is_remote(backend: str) -> bool: + """Whether a plugin-registered terminal backend runs commands remotely. + + Fail-soft: unknown names return False (treated as local, matching the + historical behavior for unrecognized TERMINAL_ENV values). + """ + if not backend or backend in _REMOTE_TERMINAL_BACKENDS or backend == "local": + return False + try: + from agent.terminal_env_registry import provider_flag + + return bool(provider_flag(backend, "is_remote", False)) + except Exception: + return False + + +def _plugin_backend_description(backend: str) -> str | None: + """Prompt fallback description declared by a plugin backend, if any.""" + try: + from agent.terminal_env_registry import get_provider + + provider = get_provider(backend) + if provider is not None: + return provider.env_description + except Exception: + pass + return None + + _BACKEND_FALLBACK_DESCRIPTIONS: dict[str, str] = { "docker": "a Docker container (Linux)", "singularity": "a Singularity container (Linux)", @@ -1309,7 +1372,9 @@ def _probe_remote_backend(env_type: str) -> str | None: } container_config = None - if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: + from tools.terminal_tool import _is_container_backend as _is_container + + if _is_container(env_type): container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), @@ -1324,6 +1389,7 @@ def _probe_remote_backend(env_type: str) -> str | None: "docker_extra_args": config.get("docker_extra_args", []), "docker_shm_size": config.get("docker_shm_size", "1g"), "docker_persist_across_processes": config.get("docker_persist_across_processes", True), + "docker_shared_container_key": config.get("docker_shared_container_key", ""), "docker_orphan_reaper": config.get("docker_orphan_reaper", True), } @@ -1413,7 +1479,7 @@ def build_environment_hints() -> str: hints: list[str] = [] backend = (os.getenv("TERMINAL_ENV") or "local").strip().lower() - is_remote_backend = backend in _REMOTE_TERMINAL_BACKENDS + is_remote_backend = backend in _REMOTE_TERMINAL_BACKENDS or _plugin_backend_is_remote(backend) if not is_remote_backend: # --- Host info block (local backend: host == where tools run) --- @@ -1461,7 +1527,9 @@ def build_environment_hints() -> str: ) else: description = _BACKEND_FALLBACK_DESCRIPTIONS.get( - backend, f"a {backend} environment (likely Linux)" + backend, + ) or _plugin_backend_description(backend) or ( + f"a {backend} environment (likely Linux)" ) hints.append( f"Terminal backend: {backend}. Your `terminal`, `read_file`, " @@ -2144,6 +2212,11 @@ def _build_skills_system_prompt_inner( if not skills_by_category: result = "" else: + # "basic tools like web_search or terminal" — don't name web_search + # when the session has no web tools (dangling reference otherwise). + _basic_tools = "web_search or terminal" + if available_tools is not None and "web_search" not in available_tools: + _basic_tools = "terminal" index_lines = [] for category in sorted(skills_by_category.keys()): # Deduplicate and sort skills within each category @@ -2174,7 +2247,7 @@ def _build_skills_system_prompt_inner( "than to miss critical steps, pitfalls, or established workflows. " "Skills contain specialized knowledge — API endpoints, tool-specific commands, " "and proven workflows that outperform general-purpose approaches. Load the skill " - "even if you think you could handle the task with basic tools like web_search or terminal. " + f"even if you think you could handle the task with basic tools like {_basic_tools}. " "Skills also encode the user's preferred approach, conventions, and quality standards " "for tasks like code review, planning, and testing — load them even for tasks you " "already know how to do, because the skill defines how it should be done here.\n" @@ -2206,72 +2279,6 @@ def _build_skills_system_prompt_inner( return result -def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -> str: - """Build a compact Nous subscription capability block for the system prompt.""" - try: - from hermes_cli.nous_subscription import get_nous_subscription_features - from tools.tool_backend_helpers import managed_nous_tools_enabled - except Exception as exc: - logger.debug("Failed to import Nous subscription helper: %s", exc) - return "" - - if not managed_nous_tools_enabled(): - return "" - - valid_names = set(valid_tool_names or set()) - relevant_tool_names = { - "web_search", - "web_extract", - "browser_navigate", - "browser_snapshot", - "browser_click", - "browser_type", - "browser_scroll", - "browser_console", - "browser_press", - "browser_get_images", - "browser_vision", - "image_generate", - "text_to_speech", - "terminal", - "process", - "execute_code", - } - - if valid_names and not (valid_names & relevant_tool_names): - return "" - - features = get_nous_subscription_features() - - def _status_line(feature) -> str: - if feature.managed_by_nous: - return f"- {feature.label}: active via Nous subscription" - if feature.active: - current = feature.current_provider or "configured provider" - return f"- {feature.label}: currently using {current}" - if feature.included_by_default and features.nous_auth_present: - return f"- {feature.label}: included with Nous subscription, not currently selected" - if feature.key == "modal" and features.nous_auth_present: - return f"- {feature.label}: optional via Nous subscription" - return f"- {feature.label}: not currently available" - - lines = [ - "# Nous Subscription", - "Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, OpenAI Whisper STT, and browser automation (Browser Use) by default. Modal execution is optional.", - "Current capability status:", - ] - lines.extend(_status_line(feature) for feature in features.items()) - lines.extend( - [ - "When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, OpenAI Whisper, or Browser-Use API keys.", - "If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.", - "Do not mention subscription unless the user asks about it or it directly solves the current missing capability.", - "Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.", - ] - ) - return "\n".join(lines) - - # ========================================================================= # Context files (SOUL.md, AGENTS.md, .cursorrules) # ========================================================================= diff --git a/agent/redact.py b/agent/redact.py index 7fa3c11e1d39..66e4e6003802 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -322,6 +322,72 @@ def _key_has_secret_keyword(key: str) -> bool: re.IGNORECASE, ) +# Python ``repr`` uses single-quoted mapping fields, so opaque credentials in +# tracebacks and pytest failure introspection bypass the double-quoted JSON rule +# above: ``{'BRAVE_API_KEY': 'opaque-value'}``. Capture identifier-shaped keys +# here, then apply the canonical high-confidence key policy in the callback. +_PYTHON_REPR_SECRET_KEYS = frozenset({ + "access_token", + "refresh_token", + "id_token", + "auth_token", + "token", + "api_key", + "apikey", + "client_secret", + "secret", + "password", + "passwd", + "private_key", + "credential", + "credentials", + "authorization", + "bearer", + "secret_value", + "raw_secret", + "secret_input", + "key_material", +}) +_PYTHON_REPR_ENV_SUFFIXES = ( + "_API_KEY", + "_TOKEN", + "_SECRET", + "_PASSWORD", + "_PASSWD", + "_CREDENTIAL", + "_CREDENTIALS", +) +# Casefolded credential suffixes for mixed/camel-case key names +# (``UserPassword``, ``sessionToken``, ``clientApiKey``). Suffix-only so +# ``token_count`` / ``password_policy`` metadata keys never match. Widened per +# OpenHands/software-agent-sdk#4508. +_PYTHON_REPR_CREDENTIAL_SUFFIXES = ( + "apikey", + "api_key", + "token", + "secret", + "password", + "passwd", + "credential", + "credentials", +) +_PYTHON_REPR_FIELD_RE = re.compile( + r"'(?P[A-Za-z_][A-Za-z0-9_]*)'(?P\s*:\s*)" + r"(?:" + r"(?P[bB]?)'(?P(?:\\.|[^'\\])+)'" + r"|(?P[bB]?)\"(?P(?:\\.|[^\"\\])+)\"" + r")" +) + +# Terminal/process output normally uses ``code_file=True`` to preserve source. +# Add repr masking only to high-confidence diagnostic lines: pytest assertion +# introspection (``E ...``) and final Python exception lines. +_PYTEST_DIAGNOSTIC_LINE_RE = re.compile(r"^(?P[ \t]*E[ \t]{2,})(?P.*)$") +_PYTHON_EXCEPTION_LINE_RE = re.compile( + r"^(?P(?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*" + r"(?:Error|Exception|Warning):[ \t]*)(?P.*)$" +) + # Authorization headers — any scheme (Bearer, Basic, Token, Digest, …) plus the # bare-credential form, and Proxy-Authorization. The credential token is masked # while the header name and scheme word are preserved for debuggability. The @@ -609,6 +675,74 @@ def _mask_token(token: str) -> str: return mask_secret(token, head=6, tail=4, floor=18) +def _is_python_repr_secret_key(key: str) -> bool: + """Return True for exact secret keys or credential-suffixed key names.""" + folded = key.casefold() + if folded in _PYTHON_REPR_SECRET_KEYS: + return True + if key.isupper() and key.endswith(_PYTHON_REPR_ENV_SUFFIXES): + return True + # Mixed/camel-case keys ending in a credential word (``UserPassword``, + # ``sessionToken``, ``clientApiKey``) — the exact-set and uppercase-suffix + # rules above miss these. Suffix-only matching keeps metadata names like + # ``TOKEN_COUNT`` / ``PASSWORD_POLICY`` / ``SECRET_NAME`` untouched. + # Class widened per OpenHands/software-agent-sdk#4508 (their dict-entry + # redaction was uppercase-only and leaked mixed-case keys). + return folded.endswith(_PYTHON_REPR_CREDENTIAL_SUFFIXES) + + +def _redact_python_repr_fields(text: str) -> str: + """Fully mask credential fields in Python mapping ``repr`` output.""" + def _sub(match: re.Match) -> str: + key = match.group("key") + if not _is_python_repr_secret_key(key): + return match.group(0) + + single_value = match.group("single_value") + if single_value is not None: + prefix = match.group("single_prefix") or "" + quote = "'" + value = single_value + else: + prefix = match.group("double_prefix") or "" + quote = '"' + value = match.group("double_value") + + # Mapping repr can contain code-shaped fixture values too. Preserve + # programmatic env lookups just like the ENV/JSON/YAML passes do. + if _ENV_LOOKUP_VALUE_RE.match(value): + return match.group(0) + # Do not retain head/tail characters here: escaped repr atoms can cross + # a slicing boundary and leave an unescaped quote behind. A full mask is + # parseable for both str and bytes values and leaks no opaque bytes. + return f"'{key}'{match.group('sep')}{prefix}{quote}***{quote}" + + return _PYTHON_REPR_FIELD_RE.sub(_sub, text) + + +def _redact_python_diagnostic_repr_fields(text: str) -> str: + """Mask repr fields only on pytest/error lines in source-preserving output.""" + lines = text.splitlines(keepends=True) + for index, line in enumerate(lines): + ending = "" + body_line = line + if line.endswith("\r\n"): + body_line, ending = line[:-2], "\r\n" + elif line.endswith("\n") or line.endswith("\r"): + body_line, ending = line[:-1], line[-1:] + + match = _PYTEST_DIAGNOSTIC_LINE_RE.match(body_line) + if match is None: + match = _PYTHON_EXCEPTION_LINE_RE.match(body_line) + if match is not None: + lines[index] = ( + match.group("prefix") + + _redact_python_repr_fields(match.group("body")) + + ending + ) + return "".join(lines) + + def _redact_query_string(query: str) -> str: """Redact sensitive parameter values in a URL query string. @@ -791,7 +925,7 @@ def redact_sensitive_text( URL userinfo. The default remains False because actionable OAuth callback, magic-link, and pre-signed URLs must survive ordinary tool flows unchanged. - Set code_file=True to skip the ENV-assignment and JSON-field regex + Set code_file=True to skip the ENV-assignment, JSON-field, and Python-repr patterns when the text is known to be source code (e.g. MAX_TOKENS=*** constants, "apiKey": "test" fixtures). Prefix patterns, auth headers, private keys, DB connstrings, JWTs, and URL secrets are still redacted. @@ -897,6 +1031,12 @@ def _redact_json(m): return f'{key}: "{_mask_token(value)}"' text = _JSON_FIELD_RE.sub(_redact_json, text) + # Python mapping repr fields: {'API_KEY': '***'}. This is a log / + # traceback shape, not ordinary source-code handling; terminal output + # from Python commands gets a narrowly scoped pass below. + if ":" in text and "'" in text: + text = _redact_python_repr_fields(text) + # Unquoted YAML / colon config: password: *** (after JSON so quoted # values are handled there; the lookahead in _YAML_ASSIGN_RE skips # quotes). Skip URLs — web-URL query params pass through by design. @@ -1118,7 +1258,8 @@ def redact_terminal_output( ENV pass is the right one (keys whose names carry no secret keyword can still slip through it — same limit as the env-dump path). - anything else (or unknown command) → ``code_file=True`` to avoid - false positives on source/config dumps. + false positives on source/config dumps. High-confidence pytest diagnostic + and final exception lines still receive the Python-repr credential pass. ``force=True`` bypasses the global ``security.redact_secrets`` preference for safety boundaries that must never emit raw credentials. @@ -1127,7 +1268,15 @@ def redact_terminal_output( return output cmd = command or "" code_file = not (is_env_dump_command(cmd) or _command_reads_env_file(cmd)) - return redact_sensitive_text(output, force=force, code_file=code_file) + redacted = redact_sensitive_text(output, force=force, code_file=code_file) + if ( + code_file + and (force or _REDACT_ENABLED) + and ":" in redacted + and "'" in redacted + ): + redacted = _redact_python_diagnostic_repr_fields(redacted) + return redacted # Substrings used to gate ``_PREFIX_RE`` execution. If none of these appear in diff --git a/agent/review_engine.py b/agent/review_engine.py new file mode 100644 index 000000000000..6d5e8cd62d6d --- /dev/null +++ b/agent/review_engine.py @@ -0,0 +1,299 @@ +"""Shared engine for the /review command — every surface calls this. + +/review spawns an independent, full-privilege background subagent (the same +async delegation rail as ``delegate_task(background=true)``) whose job is to +thoroughly review whatever the recent conversation presented: a PR, a diff, +code, documentation, or any other work product. The reviewer's result +re-enters the spawning session as a normal async-delegation completion, so +the primary agent sees the review and can act on it. + +Model routing: the reviewer runs on ``auxiliary.review`` (provider/model/ +base_url/api_key/api_mode in config.yaml) when configured; otherwise it +inherits the parent agent's credentials — main-model-first, same convention +as every other auxiliary task. Resolution reuses the delegation credential +resolver (``tools.delegate_tool._resolve_delegation_credentials``) via the +internal ``credentials_cfg`` parameter of ``delegate_task`` so native-SDK +providers, api_mode detection, and credential pools all behave identically +to ``delegation.provider`` pins. + +Surfaces (CLI ``/review``, gateway ``/review``, TUI/Desktop live dispatch) +are thin adapters: snapshot the conversation, call :func:`start_review`, +print the dispatch note. +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +# How many recent chat messages (user + assistant turns) the reviewer gets. +DEFAULT_CONTEXT_MESSAGES = 10 + +# Per-message excerpt cap. Generous — a PR summary or diff excerpt the primary +# agent just printed is exactly what the reviewer needs — but bounded so a +# pathological turn can't blow up the child's opening context. +_MESSAGE_CHAR_CAP = 12_000 + + +def _message_text(message: Dict[str, Any]) -> str: + """Extract display text from a conversation message dict. + + Handles both plain-string content and OpenAI-style multimodal content + lists (text parts joined; non-text parts noted). + """ + content = message.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts: List[str] = [] + for part in content: + if isinstance(part, dict): + if part.get("type") == "text": + parts.append(str(part.get("text") or "")) + else: + parts.append(f"[{part.get('type', 'attachment')}]") + return "\n".join(p for p in parts if p) + return "" + + +def snapshot_recent_messages( + messages: List[Dict[str, Any]], + limit: int = DEFAULT_CONTEXT_MESSAGES, +) -> List[Dict[str, str]]: + """Return the last ``limit`` user/assistant messages as {role, text} dicts. + + System messages and tool results are excluded — the chat turns are what + the user and their primary agent actually said (the PR link, the summary, + the diff excerpt). Empty-text messages (pure tool-call assistant stubs) + are skipped. + """ + out: List[Dict[str, str]] = [] + for message in reversed(list(messages or [])): + if not isinstance(message, dict): + continue + role = str(message.get("role") or "") + if role not in ("user", "assistant"): + continue + text = _message_text(message).strip() + if not text: + continue + if len(text) > _MESSAGE_CHAR_CAP: + text = text[:_MESSAGE_CHAR_CAP] + "\n[... truncated ...]" + out.append({"role": role, "text": text}) + if len(out) >= limit: + break + out.reverse() + return out + + +def collect_parent_loaded_skills( + parent_agent, + messages: List[Dict[str, Any]], + limit: int = 8, +) -> List[str]: + """Names of skills the parent agent was operating under. + + Two sources, both surface-independent: + + * Launch-preloaded skills (``hermes -s``, kanban lanes, TUI skills env): + their activation notes are embedded in the parent's + ``ephemeral_system_prompt`` with a stable marker + (see ``agent.skill_commands.build_preloaded_skills_prompt``). + * Mid-session loads: ``skill_view`` tool calls in the parent's + conversation history (assistant ``tool_calls`` entries). + + Order: preloaded first, then history loads, deduped, capped at ``limit`` + (a reviewer told to load 30 skills would burn its budget before working). + """ + names: List[str] = [] + seen: set = set() + + def _add(name: str) -> None: + cleaned = (name or "").strip() + if cleaned and cleaned not in seen: + seen.add(cleaned) + names.append(cleaned) + + prompt = str(getattr(parent_agent, "ephemeral_system_prompt", "") or "") + for match in re.finditer(r'with the "([^"]+)" skill\s+preloaded', prompt): + _add(match.group(1)) + + for message in messages or []: + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + for tool_call in message.get("tool_calls") or []: + if not isinstance(tool_call, dict): + continue + fn = tool_call.get("function") or {} + if fn.get("name") != "skill_view": + continue + try: + args = json.loads(fn.get("arguments") or "{}") + except Exception: + continue + # Only whole-skill loads seed the reviewer; a reference-file read + # (file_path=...) is a detail of the parent's task, and the + # reviewer loading the main SKILL.md covers it. + if isinstance(args, dict) and not args.get("file_path"): + _add(str(args.get("name") or "")) + + return names[:limit] + + +def build_review_task( + snapshot: List[Dict[str, str]], + user_prompt: str = "", + loaded_skills: Optional[List[str]] = None, +) -> tuple: + """Compose the reviewer subagent's (goal, context) pair.""" + goal = ( + "Act as an independent senior reviewer. Thoroughly review the work " + "presented in the conversation excerpt provided in your context: " + "investigate any code, pull request, branch, commit, documentation, " + "design, or other artifact it references (open the PR, read the " + "diff, run the code or tests where feasible) rather than judging " + "from the excerpt alone. Produce a full, structured review: what " + "the work does, whether it is correct and complete, concrete " + "defects or risks found (with file/line references where possible), " + "what was verified vs. only read, and a clear final verdict with " + "recommended next steps." + ) + + lines = [ + "You were spawned by the /review command. The following is an " + "excerpt of the most recent conversation between the user and " + "their primary agent. It is your starting evidence — the work to " + "review is referenced in it.", + "", + "--- Recent conversation (oldest first) ---", + ] + for message in snapshot: + label = "USER" if message["role"] == "user" else "PRIMARY AGENT" + lines.append(f"[{label}]") + lines.append(message["text"]) + lines.append("") + lines.append("--- End of conversation excerpt ---") + if loaded_skills: + skill_list = ", ".join(loaded_skills) + lines.append("") + lines.append( + "The primary agent was operating under these loaded skills: " + f"{skill_list}. Before reviewing, load each with " + "skill_view(name=...) and treat their conventions, invariants, " + "and review standards as binding for your assessment — the work " + "was produced under them and must be judged against them." + ) + if user_prompt.strip(): + lines.append("") + lines.append("Additional review instructions from the user:") + lines.append(user_prompt.strip()) + lines.append("") + lines.append( + "Your review is delivered back into that conversation, addressed to " + "the primary agent and its user. Be direct and specific; do not " + "soften findings." + ) + return goal, "\n".join(lines) + + +def _load_review_credentials_cfg() -> Optional[Dict[str, Any]]: + """Read ``auxiliary.review`` into a delegation-credentials-shaped dict. + + Returns None when the user configured nothing (provider=auto/empty and no + model/base_url), which makes the reviewer inherit the parent agent's + credentials — the main-model-first default. + """ + try: + from hermes_cli.config import load_config_readonly + + full = load_config_readonly() + aux = full.get("auxiliary") or {} + review = aux.get("review") or {} + if not isinstance(review, dict): + return None + except Exception: + return None + + provider = str(review.get("provider") or "").strip() + if provider.lower() == "auto": + provider = "" + model = str(review.get("model") or "").strip() + base_url = str(review.get("base_url") or "").strip() + if not (provider or model or base_url): + return None + return { + "provider": provider, + "model": model, + "base_url": base_url, + "api_key": str(review.get("api_key") or "").strip(), + "api_mode": str(review.get("api_mode") or "").strip(), + } + + +def start_review( + parent_agent, + messages: List[Dict[str, Any]], + user_prompt: str = "", +) -> Dict[str, Any]: + """Dispatch the reviewer subagent in the background. + + Returns the parsed ``delegate_task`` dispatch dict (``status: + "dispatched"`` with a ``delegation_id`` on success, or the synchronous + result dict on channels that cannot route async completions). + + Raises ValueError when there is nothing to review or the dispatch is + rejected/errored. + """ + if parent_agent is None: + raise ValueError("No active agent — send a message first.") + + snapshot = snapshot_recent_messages(messages) + if not snapshot: + raise ValueError("Nothing to review yet — the conversation is empty.") + + loaded_skills = collect_parent_loaded_skills(parent_agent, messages) + goal, context = build_review_task(snapshot, user_prompt, loaded_skills) + credentials_cfg = _load_review_credentials_cfg() + + from tools.delegate_tool import delegate_task + + raw = delegate_task( + goal=goal, + context=context, + background=True, + parent_agent=parent_agent, + credentials_cfg=credentials_cfg, + ) + try: + result = json.loads(raw) + except Exception: + raise ValueError(f"Review dispatch failed: {raw!r}") + if isinstance(result, dict) and result.get("error"): + raise ValueError(str(result["error"])) + if not isinstance(result, dict): + raise ValueError(f"Review dispatch failed: {raw!r}") + result.setdefault("review_model", (credentials_cfg or {}).get("model") or "") + return result + + +def format_dispatch_note(result: Dict[str, Any], user_prompt: str = "") -> str: + """Human-facing one-liner for a successful dispatch. Shared by surfaces.""" + model = str(result.get("review_model") or "").strip() + model_note = f" on {model}" if model else "" + focus_note = f" (focus: {user_prompt.strip()})" if user_prompt.strip() else "" + if result.get("status") == "dispatched": + return ( + f"⚖ Review subagent dispatched{model_note}{focus_note} — it is " + f"investigating the last {DEFAULT_CONTEXT_MESSAGES} messages in " + f"the background and its full review will re-enter this " + f"conversation when it finishes." + ) + # Synchronous fallback (channels that cannot route async completions). + return ( + f"⚖ Review completed synchronously{model_note}{focus_note} — " + f"results:\n{json.dumps(result.get('results', result), ensure_ascii=False)[:4000]}" + ) diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 01ad461879a3..0c893ab4652b 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -434,6 +434,15 @@ def _load_raw_config() -> Dict[str, Any]: return parsed +# Skills that must stay available regardless of configuration. The +# `hermes-agent` skill is the agent's own operating manual — it drives +# configuring, extending, and troubleshooting Hermes itself, and the system +# prompt unconditionally points at it. Disabling it leaves the agent unable +# to help with Hermes, so disable requests for these names are ignored +# everywhere the disabled list is consulted. +ESSENTIAL_SKILLS: frozenset = frozenset({"hermes-agent"}) + + def get_disabled_skill_names(platform: str | None = None) -> Set[str]: """Read disabled skill names from config.yaml. @@ -468,8 +477,10 @@ def get_disabled_skill_names(platform: str | None = None) -> Set[str]: resolved_platform ) if platform_disabled is not None: - return global_disabled | _normalize_string_set(platform_disabled) - return global_disabled + return ( + global_disabled | _normalize_string_set(platform_disabled) + ) - ESSENTIAL_SKILLS + return global_disabled - ESSENTIAL_SKILLS def parse_config_string_list(value) -> List[str]: diff --git a/agent/subagent_lifecycle.py b/agent/subagent_lifecycle.py index 55e110aae5a6..319e85a7845e 100644 --- a/agent/subagent_lifecycle.py +++ b/agent/subagent_lifecycle.py @@ -307,7 +307,9 @@ def cancel(self, handle: SubagentHandle, *, reason: str) -> SubagentCancelResult ) try: accepted = request_hard_interrupt( - agent, f"Lifecycle cancellation requested: {reason[:500]}" + agent, + f"Lifecycle cancellation requested: {reason[:500]}", + tool_reason="subagent cancellation requested", ) except Exception: return SubagentCancelResult( diff --git a/agent/system_prompt.py b/agent/system_prompt.py index b176a2554fd4..0a5d40c2e78c 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -36,6 +36,7 @@ EXECUTION_GUIDANCE_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, HERMES_AGENT_HELP_GUIDANCE, + HERMES_AGENT_HELP_GUIDANCE_NO_SKILLS, KANBAN_GUIDANCE, MEMORY_GUIDANCE, USER_PROFILE_GUIDANCE, @@ -68,7 +69,7 @@ def _ra(): """Lazy reference to the ``run_agent`` module. Helpers like ``load_soul_md``, ``build_environment_hints``, - ``build_context_files_prompt``, ``build_nous_subscription_prompt``, + ``build_context_files_prompt``, ``build_skills_system_prompt`` and ``get_toolset_for_tool`` are imported into ``run_agent``'s namespace. Many tests ``patch("run_agent.load_soul_md", ...)``; if we imported them @@ -391,8 +392,15 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # Fallback to hardcoded identity stable_parts.append(DEFAULT_AGENT_IDENTITY) - # Pointer to the hermes-agent skill + docs for user questions about Hermes itself. - stable_parts.append(HERMES_AGENT_HELP_GUIDANCE) + # Pointer to the hermes-agent skill + docs for user questions about Hermes + # itself. When the session has no skill tools (Blank Slate with the skills + # toolset off), skill_view() would be a dangling reference — inject the + # docs-only variant instead. Toolset is fixed per-session, so cache-safe. + _has_skill_view = "skill_view" in (agent.valid_tool_names or set()) + stable_parts.append( + HERMES_AGENT_HELP_GUIDANCE if _has_skill_view + else HERMES_AGENT_HELP_GUIDANCE_NO_SKILLS + ) # Universal task-completion / no-fabrication guidance. Applied to ALL # models regardless of tool_use_enforcement gating — the failure modes @@ -461,9 +469,6 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) from agent.prompt_builder import computer_use_guidance stable_parts.append(computer_use_guidance()) - nous_subscription_prompt = _r.build_nous_subscription_prompt(agent.valid_tool_names) - if nous_subscription_prompt: - stable_parts.append(nous_subscription_prompt) # Tool-use enforcement: tells the model to actually call tools instead # of describing intended actions. Controlled by config.yaml # agent.tool_use_enforcement: @@ -521,7 +526,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) model_lower = (agent.model or "").lower() _exec_inject = any(p in model_lower for p in EXECUTION_GUIDANCE_MODELS) if _exec_inject: - stable_parts.append(OPENAI_MODEL_EXECUTION_GUIDANCE) + from agent.prompt_builder import execution_guidance_text + stable_parts.append(execution_guidance_text(agent.valid_tool_names)) has_skills_tools = any(name in agent.valid_tool_names for name in ['skills_list', 'skill_view', 'skill_manage']) if has_skills_tools: @@ -590,6 +596,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) platform=agent.platform, cwd=resolve_context_cwd(), model=agent.model, + valid_tool_names=agent.valid_tool_names, ) stable_parts.extend(coding_prefix_parts) except Exception: @@ -828,14 +835,22 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) if user_block: volatile_parts.append(user_block) - # External memory provider system prompt block (additive to built-in) + # External memory provider system prompt block (additive to built-in). + # Gated on the same check ``inject_memory_provider_tools`` uses so we + # never advertise provider tools that the agent's toolset configuration + # has already gated off (#81014). if agent._memory_manager: try: - _ext_mem_block = agent._memory_manager.build_system_prompt() - if _ext_mem_block: - volatile_parts.append(_ext_mem_block) + from agent.memory_manager import memory_provider_tools_exposed as _mem_exposed except Exception: - pass + _mem_exposed = None + if _mem_exposed is None or _mem_exposed(agent): + try: + _ext_mem_block = agent._memory_manager.build_system_prompt() + if _ext_mem_block: + volatile_parts.append(_ext_mem_block) + except Exception: + pass # Plugin sections are intentionally confined to one coarse anchor in the # volatile tail. This preserves deterministic ordering and lets a resumed diff --git a/agent/terminal_env_provider.py b/agent/terminal_env_provider.py new file mode 100644 index 000000000000..b7f28e5a1a3e --- /dev/null +++ b/agent/terminal_env_provider.py @@ -0,0 +1,222 @@ +""" +Terminal Environment Provider ABC +================================= + +Defines the pluggable-backend interface for terminal execution environments +(cloud sandboxes, remote runners). Providers register instances via +:meth:`PluginContext.register_terminal_environment_provider`; the dispatch +ladder in :func:`tools.terminal_tool._create_environment` consults the +registry for any ``TERMINAL_ENV`` / ``terminal.backend`` value that is not a +built-in backend (local, docker, singularity, modal, daytona, +vercel_sandbox, ssh). + +Providers live in ``~/.hermes/plugins//`` (user, opt-in via +``plugins.enabled``) or ship as standalone plugin repos. Built-in backends +stay in-tree under ``tools/environments/`` — this extension point exists so +third-party sandbox vendors do NOT have to live in core (see AGENTS.md: +"Third-party products ... ship them as a standalone plugin repo"). + +This ABC mirrors :class:`agent.browser_provider.BrowserProvider` — same +registration flow, same scope semantics, same plugin-context gating. + +Classification contract +----------------------- + +Beyond creating environments, a terminal backend participates in several +core policy decisions that were historically frozensets of built-in names. +Each is expressed as a declarative attribute so the class of "new backend +missed classification site N" bugs (see PR #30112's seven-site sweep) cannot +recur for plugin backends: + +* ``is_remote`` — commands run somewhere other than the host machine. + Suppresses host OS/home/cwd hints in the system prompt, the host Python + env probe, and remote-aware skill env handling. +* ``is_container`` — the backend behaves like a container/sandbox with its + own filesystem rooted away from the host: container resource config is + passed through, host-looking cwds are sanitized, and file tools use + container path resolution. +* ``skip_container_guards`` — the sandbox is isolated enough that + dangerous-command approval prompts are skipped (a wiped filesystem is + disposable). Defaults to ``is_container``. Backends that can mount host + paths should override to ``False``. +* ``cache_path_base`` — where auto-synced ``~/.hermes/cache`` files land + inside the backend (e.g. ``"~/.hermes"`` for home-synced backends, + ``"/root/.hermes"`` for root-homed containers), or ``None`` when host + paths remain correct (nothing is translated). +* ``strip_env_keys`` — credential env var names owned by this backend + (API tokens for the sandbox vendor). Stripped from every subprocess the + agent spawns so a model-authored command can never read them. +* ``session_isolated_when_nonpersistent`` — non-persistent mode gives each + session its own sandbox identity instead of sharing one (the #82731 + contract; opt in when a shared name would let two ephemeral runs attach + and destroy each other's sandbox). + +Environment object contract +--------------------------- + +:meth:`create_environment` returns an object satisfying the same duck-typed +interface as :class:`tools.environments.base.BaseEnvironment` (``execute()``, +``cleanup()`` …). Subclassing ``BaseEnvironment`` is recommended but not +required — the registry does not isinstance-check the returned environment. +The factory stamps ``_hermes_backend_name`` on the returned object so +file-path resolution can identify plugin backends without class-name +sniffing. +""" + +from __future__ import annotations + +import abc +from typing import Any, Dict, List, Optional, Tuple + + +class TerminalEnvironmentProvider(abc.ABC): + """Abstract base class for a pluggable terminal execution backend.""" + + # ------------------------------------------------------------------ + # Identity + # ------------------------------------------------------------------ + + @property + @abc.abstractmethod + def name(self) -> str: + """Stable short identifier used as the ``terminal.backend`` / + ``TERMINAL_ENV`` value. + + Lowercase, ``[a-z0-9_]``. Must not collide with a built-in backend + name (local, docker, singularity, modal, managed_modal, daytona, + vercel_sandbox, ssh) — the registry rejects such registrations. + """ + + @property + def display_name(self) -> str: + """Human-readable label for pickers. Defaults to ``name``.""" + return self.name + + @property + def description(self) -> str: + """One-line description shown in backend pickers.""" + return f"Run commands in a {self.display_name} environment." + + # ------------------------------------------------------------------ + # Classification flags (see module docstring) + # ------------------------------------------------------------------ + + is_remote: bool = True + is_container: bool = True + session_isolated_when_nonpersistent: bool = False + + @property + def skip_container_guards(self) -> bool: + """Whether dangerous-command approval prompts are skipped.""" + return self.is_container + + @property + def cache_path_base(self) -> Optional[str]: + """Base dir for synced Hermes cache files inside the backend.""" + return None + + @property + def strip_env_keys(self) -> frozenset: + """Backend-owned credential env var names to strip from subprocesses.""" + return frozenset() + + @property + def env_description(self) -> str: + """Prompt-builder fallback description of where commands run. + + Used when the live backend probe fails at system-prompt build time, + e.g. ``"a Daytona workspace (Linux)"``. + """ + return f"a {self.display_name} environment (likely Linux)" + + # ------------------------------------------------------------------ + # Availability / setup UX + # ------------------------------------------------------------------ + + @abc.abstractmethod + def is_available(self) -> bool: + """Return True when this backend can service commands. + + Cheap check only (env var present, SDK importable). Must NOT make + network calls — this runs during requirement checks and UI paints. + """ + + def check_requirements(self, config: Dict[str, Any]) -> bool: + """Full requirements check for :func:`check_terminal_requirements`. + + ``config`` is the merged terminal env config dict. Default defers to + :meth:`is_available`. Log actionable errors before returning False. + """ + return self.is_available() + + def probe(self) -> Tuple[str, str]: + """Dashboard picker health probe: ``(status, detail)``. + + ``status`` is ``"ready"`` / ``"needs_setup"`` / ``"unavailable"``; + ``detail`` carries setup guidance for non-ready rows. Must never + raise and must stay fast (<~2s). + """ + if self.is_available(): + return ("ready", "") + return ("needs_setup", f"{self.display_name} is not configured.") + + def setup_instructions(self) -> List[str]: + """Lines printed by ``hermes setup`` after this backend is selected. + + Use for token acquisition hints, SDK install commands, etc. The + wizard persists ``terminal.backend`` itself; providers that need an + interactive flow can run it in :meth:`post_setup`. + """ + return [] + + def post_setup(self) -> None: + """Optional interactive setup hook run by ``hermes setup`` after the + backend is selected (prompt for tokens, install SDKs). Default no-op. + """ + + def doctor_checks(self) -> List[Tuple[bool, str, str]]: + """``hermes doctor`` rows: ``(ok, label, detail)`` triples. + + Default: a single row reflecting :meth:`is_available`. + """ + ok = False + try: + ok = bool(self.is_available()) + except Exception: + ok = False + detail = "(configured)" if ok else "(not configured — see setup instructions)" + return [(ok, f"{self.display_name} backend", detail)] + + # ------------------------------------------------------------------ + # The factory + # ------------------------------------------------------------------ + + @abc.abstractmethod + def create_environment( + self, + *, + cwd: str, + timeout: int, + task_id: str = "default", + image: Optional[str] = None, + container_config: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ): + """Create and return an execution environment instance. + + MUST accept ``**kwargs`` and ignore unknown keys — the forward-compat + contract that lets the factory signature evolve without breaking + older plugins. + + Args: + cwd: Working directory inside the backend. + timeout: Default per-command timeout in seconds. + task_id: Task identifier for environment reuse/persistence keying. + image: Configured container image name (may be irrelevant). + container_config: Resource config dict (``container_cpu``, + ``container_memory``, ``container_disk``, + ``container_persistent``) when :attr:`is_container` is True. + + Returns: + An object satisfying the ``BaseEnvironment`` duck-typed contract. + """ diff --git a/agent/terminal_env_registry.py b/agent/terminal_env_registry.py new file mode 100644 index 000000000000..3c65cd2242b8 --- /dev/null +++ b/agent/terminal_env_registry.py @@ -0,0 +1,221 @@ +""" +Terminal Environment Registry +============================= + +Central map of registered pluggable terminal backends. Populated by plugins +at load time via :meth:`PluginContext.register_terminal_environment_provider`; +consumed by :func:`tools.terminal_tool._create_environment` and the +classification helpers spread across the terminal/file/approval/prompt +surfaces. + +Unlike the image/video/web/browser registries there is **no active-provider +resolution here**: the active backend is whatever ``TERMINAL_ENV`` / +``terminal.backend`` names, exactly as for built-in backends. The registry's +only job is mapping that name to a provider instance (and answering the +classification questions the core historically answered with frozensets of +built-in names). + +Built-in backend names are reserved — :func:`register_provider` rejects a +provider whose ``name`` collides with one, so a plugin can never shadow the +in-tree docker/modal/... implementations. + +Mirrors :mod:`agent.browser_registry` scope semantics: providers register +into a per-profile scope (multiplexed gateways) or the global base map. +""" + +from __future__ import annotations + +import logging +import threading +from typing import Dict, List, Optional + +from agent.terminal_env_provider import TerminalEnvironmentProvider +from hermes_constants import hermes_home_key + +logger = logging.getLogger(__name__) + + +#: Names owned by in-tree backends in tools/environments/ — never +#: registrable by plugins. Includes internal-mode aliases (managed_modal). +BUILTIN_BACKEND_NAMES = frozenset({ + "local", "docker", "singularity", "modal", "managed_modal", + "daytona", "vercel_sandbox", "ssh", +}) + + +_providers: Dict[str, TerminalEnvironmentProvider] = {} +_scoped_providers: Dict[str, Dict[str, TerminalEnvironmentProvider]] = {} +_generation = 0 +_scoped_generations: Dict[str, int] = {} +_lock = threading.Lock() + + +def register_provider( + provider: TerminalEnvironmentProvider, *, scope: Optional[str] = None +) -> None: + """Register a terminal environment provider. + + Re-registration (same ``name``) overwrites the previous entry — makes + hot-reload scenarios (tests, dev loops) behave predictably. + + Raises: + TypeError: not a TerminalEnvironmentProvider instance. + ValueError: empty name or collision with a built-in backend name. + """ + if not isinstance(provider, TerminalEnvironmentProvider): + raise TypeError( + f"register_provider() expects a TerminalEnvironmentProvider " + f"instance, got {type(provider).__name__}" + ) + raw_name = provider.name + if not isinstance(raw_name, str) or not raw_name.strip(): + raise ValueError("Terminal environment provider .name must be a non-empty string") + name = raw_name.strip().lower() + if name in BUILTIN_BACKEND_NAMES: + raise ValueError( + f"Terminal backend name '{name}' is reserved for the built-in " + f"{name} backend and cannot be registered by a plugin" + ) + global _generation + with _lock: + target = _providers if scope is None else _scoped_providers.setdefault(scope, {}) + existing = target.get(name) + target[name] = provider + if scope is None: + _generation += 1 + else: + _scoped_generations[scope] = _scoped_generations.get(scope, 0) + 1 + if existing is not None: + logger.debug( + "Terminal environment provider '%s' re-registered (was %r)", + name, type(existing).__name__, + ) + else: + logger.debug( + "Registered terminal environment provider '%s' (%s)", + name, type(provider).__name__, + ) + + +def list_providers(*, scope: Optional[str] = None) -> List[TerminalEnvironmentProvider]: + """Return all registered providers, sorted by name.""" + with _lock: + merged = dict(_providers) + merged.update(_scoped_providers.get(scope or hermes_home_key(), {})) + items = list(merged.values()) + return sorted(items, key=lambda p: p.name) + + +def get_provider( + name: str, *, scope: Optional[str] = None +) -> Optional[TerminalEnvironmentProvider]: + """Return the provider registered under *name*, or None.""" + if not isinstance(name, str): + return None + key = name.strip().lower() + with _lock: + return ( + _scoped_providers.get(scope or hermes_home_key(), {}).get(key) + or _providers.get(key) + ) + + +def plugin_backend_names(*, scope: Optional[str] = None) -> List[str]: + """Names of all registered plugin backends (sorted).""" + return [p.name.strip().lower() for p in list_providers(scope=scope)] + + +def provider_flag(name: str, attr: str, default=False): + """Read a classification attribute off the provider for *name*. + + Fail-soft: unknown backend or a raising property returns *default* so a + misbehaving plugin degrades to built-in-equivalent behavior instead of + taking the terminal tool down. + """ + provider = get_provider(name) + if provider is None: + return default + try: + return getattr(provider, attr, default) + except Exception: + logger.debug( + "Terminal environment provider '%s' attribute '%s' raised", + name, attr, exc_info=True, + ) + return default + + +def plugin_strip_env_keys() -> frozenset: + """Union of every registered provider's ``strip_env_keys``. + + Secrets are stripped for ALL registered backends, not just the active + one — a token in the process environment is strippable regardless of + which backend is selected (mirrors how MODAL_*/DAYTONA_API_KEY sit in + the static tier-1 set unconditionally). + """ + keys: set = set() + with _lock: + all_providers = list(_providers.values()) + for scoped in _scoped_providers.values(): + all_providers.extend(scoped.values()) + for provider in all_providers: + try: + keys.update(provider.strip_env_keys) + except Exception: + logger.debug( + "Terminal environment provider strip_env_keys raised", + exc_info=True, + ) + return frozenset(keys) + + +def snapshot_registration( + name: str, *, scope: Optional[str] = None +) -> Optional[TerminalEnvironmentProvider]: + with _lock: + target = _providers if scope is None else _scoped_providers.get(scope, {}) + return target.get(name.strip().lower()) + + +def registry_generation(*, scope: Optional[str] = None) -> tuple: + """Return a cache fingerprint for the global base and one profile.""" + active_scope = scope or hermes_home_key() + with _lock: + return _generation, _scoped_generations.get(active_scope, 0) + + +def restore_registration( + name: str, + current: TerminalEnvironmentProvider, + previous: Optional[TerminalEnvironmentProvider], + *, + scope: Optional[str] = None, +) -> bool: + """Restore a plugin registration only when *current* is still installed.""" + key = name.strip().lower() + global _generation + with _lock: + target = _providers if scope is None else _scoped_providers.setdefault(scope, {}) + if target.get(key) is not current: + return False + if previous is None: + target.pop(key, None) + else: + target[key] = previous + if scope is None: + _generation += 1 + else: + _scoped_generations[scope] = _scoped_generations.get(scope, 0) + 1 + if not target: + _scoped_providers.pop(scope, None) + return True + + +def _reset_for_tests() -> None: + """Clear all registrations. Test hook — mirrors sibling registries.""" + global _generation + with _lock: + _providers.clear() + _scoped_providers.clear() + _scoped_generations.clear() + _generation = 0 diff --git a/agent/tool_dispatch_helpers.py b/agent/tool_dispatch_helpers.py index 0c4259b65656..41dc4066b9e4 100644 --- a/agent/tool_dispatch_helpers.py +++ b/agent/tool_dispatch_helpers.py @@ -114,6 +114,38 @@ def _is_mcp_tool_parallel_safe(tool_name: str) -> bool: return False +# Read-only bridge lookups: dispatch_tool_search / dispatch_tool_describe are +# stateless catalog reads (the catalog is rebuilt from the current tool-defs +# list on every call), so a batch of them can run concurrently. +_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 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: + 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 + return underlying, underlying_args + except Exception: + return tool_name, function_args + + def _plan_tool_batch_segments(tool_calls, *, execution_cwd: Optional[Path] = None) -> List[tuple]: """Split a tool-call batch into ordered ``(kind, calls)`` segments. @@ -193,14 +225,23 @@ def _add_sequential(tc) -> None: _add_sequential(tool_call) continue - if tool_name in _PATH_SCOPED_TOOLS: + # Bridge unwrap: admission is decided on the UNDERLYING tool, not on + # the literal wrapper name the model emitted. Read-only bridge + # lookups (tool_search / tool_describe) are parallel-safe as-is. + effective_name, effective_args = _peel_bridge_call(tool_name, function_args) + + if effective_name in _NEVER_PARALLEL_TOOLS: + _add_sequential(tool_call) + continue + + if effective_name in _PATH_SCOPED_TOOLS: scoped_paths = _extract_parallel_scope_paths( - tool_name, function_args, execution_cwd=execution_cwd + effective_name, effective_args, execution_cwd=execution_cwd ) if not scoped_paths: _add_sequential(tool_call) continue - is_writer = tool_name in _PATH_SCOPED_WRITERS + is_writer = effective_name in _PATH_SCOPED_WRITERS if any( (is_writer or existing_is_writer) and _paths_overlap(scoped_path, existing) @@ -216,7 +257,11 @@ def _add_sequential(tc) -> None: current.append(tool_call) continue - if tool_name in _PARALLEL_SAFE_TOOLS or _is_mcp_tool_parallel_safe(tool_name): + if ( + effective_name in _PARALLEL_SAFE_TOOLS + or effective_name in _PARALLEL_SAFE_BRIDGE_LOOKUPS + or _is_mcp_tool_parallel_safe(effective_name) + ): current.append(tool_call) continue @@ -531,6 +576,13 @@ def _trajectory_normalize_msg(msg: Dict[str, Any]) -> Dict[str, Any]: return msg +def _normalize_tool_call_id(tool_call_id: Any) -> Any: + """Normalize a composite bridge id to its canonical call-id half.""" + if isinstance(tool_call_id, str) and "|" in tool_call_id: + return tool_call_id.split("|", 1)[0].strip() + return tool_call_id + + def make_tool_result_message( name: str, content: Any, @@ -557,6 +609,10 @@ def make_tool_result_message( The outer list itself is rebuilt rather than returned by identity, so callers should compare by value, not by ``is``. """ + # Keep the constructor safe for every caller, including replay recovery + # paths that do not go through the live executor's canonical-id helper. + tool_call_id = _normalize_tool_call_id(tool_call_id) + # Order matters: detect provider-side elision on the RAW content and # append the notice first, THEN wrap — so the notice lives inside the # untrusted block next to the data it describes, appended exactly once diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 3f0d64fbb138..5eeef6f4d558 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -32,6 +32,7 @@ redact_tool_args_for_display as _redact_tool_args_for_display, _detect_tool_failure, ) +from agent.message_sanitization import coalesce_tool_call_id from agent.tool_dispatch_helpers import ( _NEVER_PARALLEL_TOOLS, _is_destructive_command, @@ -55,6 +56,11 @@ logger = logging.getLogger(__name__) +def _pairing_tool_call_id(tool_call: Any) -> str: + """Return the canonical id used by the persisted assistant message.""" + return coalesce_tool_call_id(tool_call) + + def _record_persisted_path_for_stub(agent, tool_call_id: str, function_result) -> None: """Tell the stall guards where a persisted result's full content lives. @@ -117,8 +123,8 @@ def _budget_for_agent(agent) -> BudgetConfig: # Mirrors the constant in ``run_agent`` for tests/imports that look here. _MAX_TOOL_WORKERS = 8 _DEFAULT_IMAGE_PARALLEL_REQUESTS = 4 -# Keep this above the stock auxiliary.web_extract timeout (360s) so the batch -# guard does not preempt a slow-but-valid summarization attempt. +# Generous ceiling for slow-but-valid tool work (large page fetches, slow +# remote backends) so the batch guard does not preempt a legitimate attempt. _DEFAULT_CONCURRENT_TOOL_TIMEOUT_S = 420.0 # Upper bound a concurrent worker will wait at the start-order gate for all # earlier-ordered tools to advance before proceeding out of order. Long enough @@ -274,7 +280,15 @@ def _ra(): def _is_interpreter_shutdown_submit_error(exc: RuntimeError) -> bool: - return "cannot schedule new futures after interpreter shutdown" in str(exc) + """Shutdown-race predicate — shared home in ``tools.interpreter_shutdown``. + + Delegates so all sites (cron delivery, conversation-loop retry, tool + submission) recognize both CPython shutdown-message variants instead of + each matching its own substring (the bug class behind #55924/#58720). + """ + from tools.interpreter_shutdown import interpreter_shutting_down + + return interpreter_shutting_down(exc) def _emit_terminal_post_tool_call( @@ -891,7 +905,11 @@ def _run() -> _ManagedToolResult: # tids, but the worker may have registered after the fan-out ran. for tid in worker_tid: try: - _ra()._set_interrupt(True, tid) + _ra()._set_interrupt( + True, + tid, + reason=getattr(agent, "_tool_interrupt_reason", None), + ) except Exception: pass # Give a cooperative tool a moment to notice its per-thread @@ -902,13 +920,17 @@ def _run() -> _ManagedToolResult: return future.result() timed_out = True # reuse the abandon-shutdown path in finally future.cancel() + interrupt_reason = ( + getattr(agent, "_tool_interrupt_reason", None) + or "interrupt requested" + ) message = ( - f"[Tool execution cancelled — {function_name} was abandoned " - "after user interrupt]" + f"[Tool execution cancelled — {function_name} was abandoned: " + f"{interrupt_reason}]" ) logger.info( - "sequential tool %s abandoned after user interrupt (%.1fs elapsed)", - function_name, time.monotonic() - started, + "sequential tool %s abandoned due to %s (%.1fs elapsed)", + function_name, interrupt_reason, time.monotonic() - started, ) trace = middleware_trace if middleware_trace is not None else [] _emit_terminal_post_tool_call( @@ -920,8 +942,8 @@ def _run() -> _ManagedToolResult: tool_call_id=tool_call_id, duration_ms=int((time.monotonic() - started) * 1000), status="cancelled", - error_type="keyboard_interrupt", - error_message="Tool execution cancelled by user interrupt", + error_type="tool_interrupted", + error_message=f"Tool execution cancelled: {interrupt_reason}", middleware_trace=list(trace), ) return _ManagedToolResult( @@ -1092,10 +1114,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe f"[Tool execution cancelled — {tc.function.name} was skipped " "due to user interrupt]" ) + tool_call_id = _pairing_tool_call_id(tc) messages.append(make_tool_result_message( tc.function.name, cancelled_result, - tc.id, + tool_call_id, effect_disposition="none", )) _emit_terminal_post_tool_call( @@ -1104,7 +1127,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe function_args={}, result=cancelled_result, effective_task_id=effective_task_id, - tool_call_id=getattr(tc, "id", "") or "", + tool_call_id=tool_call_id, status="cancelled", error_type="user_interrupt", error_message="Tool execution skipped due to user interrupt", @@ -1298,7 +1321,11 @@ def _run_tool( # the tool returns True on the next poll. if agent._interrupt_requested: try: - _ra()._set_interrupt(True, _worker_tid) + _ra()._set_interrupt( + True, + _worker_tid, + reason=getattr(agent, "_tool_interrupt_reason", None), + ) except Exception: pass # Set the activity callback on THIS worker thread so @@ -1314,6 +1341,7 @@ def _run_tool( # ContextVars are propagated by propagate_context_to_thread() at the # submit site below (GHSA-qg5c-hvr5-hjgr, #13617). start = time.time() + tool_call_id = _pairing_tool_call_id(tool_call) blocked = False dispatched = False start_advanced = False @@ -1343,7 +1371,7 @@ def _execute(next_args: dict[str, Any]) -> Any: function_name, next_args, effective_task_id, - tool_call.id, + tool_call_id, messages=messages, pre_tool_block_checked=True, skip_tool_request_middleware=True, @@ -1356,7 +1384,7 @@ def _execute(next_args: dict[str, Any]) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=scope_block, display_index=index + 1, @@ -1390,7 +1418,7 @@ def _execute(next_args: dict[str, Any]) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, start_time=start, middleware_trace=list(middleware_trace), ) @@ -1417,7 +1445,7 @@ def _execute(next_args: dict[str, Any]) -> Any: function_args=function_args, result=result, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, duration_ms=int(duration * 1000), middleware_trace=list(middleware_trace), ) @@ -1668,6 +1696,7 @@ def _execute(next_args: dict[str, Any]) -> Any: parsed_calls ): r = results[i] + tool_call_id = _pairing_tool_call_id(tc) blocked = False is_error = True progress_function_name = name @@ -1686,7 +1715,7 @@ def _execute(next_args: dict[str, Any]) -> Any: function_args=args, result=function_result, effective_task_id=effective_task_id, - tool_call_id=getattr(tc, "id", "") or "", + tool_call_id=tool_call_id, duration_ms=int((timeout_s or 0.0) * 1000), status="timeout", error_type="tool_timeout", @@ -1704,7 +1733,7 @@ def _execute(next_args: dict[str, Any]) -> Any: function_args=args, result=function_result, effective_task_id=effective_task_id, - tool_call_id=getattr(tc, "id", "") or "", + tool_call_id=tool_call_id, status="cancelled", error_type="keyboard_interrupt", error_message="Tool execution cancelled by user interrupt", @@ -1718,7 +1747,7 @@ def _execute(next_args: dict[str, Any]) -> Any: function_args=args, result=function_result, effective_task_id=effective_task_id, - tool_call_id=getattr(tc, "id", "") or "", + tool_call_id=tool_call_id, status="error", error_type="thread_missing_result", error_message=function_result, @@ -1737,7 +1766,7 @@ def _execute(next_args: dict[str, Any]) -> Any: function_args=function_args, result=function_result, effective_task_id=effective_task_id, - tool_call_id=getattr(tc, "id", "") or "", + tool_call_id=tool_call_id, status="error", error_type="invalid_tool_arguments", error_message="Tool arguments must be a valid JSON object", @@ -1752,7 +1781,7 @@ def _execute(next_args: dict[str, Any]) -> Any: function_args, function_result, failed=is_error, - tool_call_id=getattr(tc, "id", "") or "", + tool_call_id=tool_call_id, ) if is_error: @@ -1783,11 +1812,11 @@ def _execute(next_args: dict[str, Any]) -> Any: function_result = maybe_persist_tool_result( content=function_result, tool_name=name, - tool_use_id=tc.id, + tool_use_id=tool_call_id, env=get_active_env(effective_task_id), config=_tool_budget, ) if not _is_multimodal_tool_result(function_result) else function_result - _record_persisted_path_for_stub(agent, tc.id, function_result) + _record_persisted_path_for_stub(agent, tool_call_id, function_result) subdir_hints = agent._subdirectory_hints.check_tool_call(name, args) if subdir_hints: @@ -1810,7 +1839,7 @@ def _execute(next_args: dict[str, Any]) -> Any: tool_message = make_tool_result_message( name, _tool_content, - tc.id, + tool_call_id, effect_disposition=effect_disposition, ) messages.append(tool_message) @@ -1854,7 +1883,7 @@ def _execute(next_args: dict[str, Any]) -> Any: try: display_args = _redact_tool_args_for_display(name, args) or args agent.tool_complete_callback( - tc.id, name, display_args, display_function_result, + tool_call_id, name, display_args, display_function_result, ) except Exception as cb_err: logging.debug("Tool complete callback error: %s", cb_err) @@ -1870,7 +1899,7 @@ def _execute(next_args: dict[str, Any]) -> Any: name, None, None, - tool_call_id=tc.id, + tool_call_id=tool_call_id, risk_metadata=risk_metadata, ) except Exception as cb_err: @@ -1909,7 +1938,7 @@ def _append_cancelled_tool_results(messages: list, tool_calls, *, reason: str) - messages.append(make_tool_result_message( name, f"[Tool execution cancelled — {name} was skipped due to {reason}]", - getattr(tc, "id", "") or "", + _pairing_tool_call_id(tc), effect_disposition="none", )) @@ -1930,6 +1959,7 @@ def _run_agent_tool_execution_middleware(agent, **kwargs): return _run_sequential_tool_execution_middleware(agent, **kwargs) for i, tool_call in enumerate(assistant_message.tool_calls, 1): + tool_call_id = _pairing_tool_call_id(tool_call) if getattr(agent, "_incremental_persistence_failed", False): return # SAFETY: check interrupt BEFORE starting each tool. @@ -1948,7 +1978,7 @@ def _run_agent_tool_execution_middleware(agent, **kwargs): messages.append(make_tool_result_message( skipped_name, cancelled_result, - skipped_tc.id, + _pairing_tool_call_id(skipped_tc), effect_disposition="none", )) _emit_terminal_post_tool_call( @@ -1982,7 +2012,7 @@ def _run_agent_tool_execution_middleware(agent, **kwargs): function_args=function_args, result=malformed_args_result, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, status="error", error_type="invalid_tool_arguments", error_message="Tool arguments must be a valid JSON object", @@ -1991,7 +2021,7 @@ def _run_agent_tool_execution_middleware(agent, **kwargs): make_tool_result_message( function_name, malformed_args_result, - tool_call.id, + tool_call_id, ) ) if not _flush_session_db_after_tool_progress( @@ -2058,7 +2088,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=_ts_scope_block, display_index=i, @@ -2083,7 +2113,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=_ts_scope_block, display_index=i, @@ -2115,7 +2145,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=_ts_scope_block, display_index=i, @@ -2145,7 +2175,7 @@ def _execute(next_args: dict) -> Any: next_args, build_metadata=lambda: agent._build_memory_write_metadata( task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", None), + tool_call_id=tool_call_id, ), ) return result @@ -2154,7 +2184,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=_ts_scope_block, display_index=i, @@ -2177,7 +2207,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=_ts_scope_block, display_index=i, @@ -2198,7 +2228,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=_ts_scope_block, display_index=i, @@ -2219,7 +2249,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=_ts_scope_block, display_index=i, @@ -2247,7 +2277,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=_ts_scope_block, display_index=i, @@ -2270,7 +2300,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=_ts_scope_block, display_index=i, @@ -2289,7 +2319,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=_ts_scope_block, display_index=i, @@ -2316,7 +2346,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=_ts_scope_block, display_index=i, @@ -2375,7 +2405,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=_ts_scope_block, display_index=i, @@ -2408,7 +2438,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=_ts_scope_block, display_index=i, @@ -2444,7 +2474,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=_ts_scope_block, display_index=i, @@ -2479,7 +2509,7 @@ def _execute(next_args: dict) -> Any: function_name, next_args, effective_task_id, - tool_call_id=tool_call.id, + tool_call_id=tool_call_id, session_id=agent.session_id or "", turn_id=getattr(agent, "_current_turn_id", "") or "", api_request_id=getattr(agent, "_current_api_request_id", "") @@ -2509,7 +2539,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=_ts_scope_block, display_index=i, @@ -2523,7 +2553,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, start_time=tool_start_time, middleware_trace=list(middleware_trace), ) @@ -2561,7 +2591,7 @@ def _execute(next_args: dict) -> Any: function_name, next_args, effective_task_id, - tool_call_id=tool_call.id, + tool_call_id=tool_call_id, session_id=agent.session_id or "", turn_id=getattr(agent, "_current_turn_id", "") or "", api_request_id=getattr(agent, "_current_api_request_id", "") @@ -2591,7 +2621,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, execute=_execute, scope_block=_ts_scope_block, display_index=i, @@ -2604,7 +2634,7 @@ def _execute(next_args: dict) -> Any: function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, start_time=tool_start_time, middleware_trace=list(middleware_trace), ) @@ -2659,7 +2689,7 @@ def _execute(next_args: dict) -> Any: function_args=function_args, result=function_result, effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, duration_ms=int(tool_duration * 1000), middleware_trace=list(middleware_trace), ) @@ -2669,7 +2699,7 @@ def _execute(next_args: dict) -> Any: function_args, function_result, failed=_is_error_result, - tool_call_id=getattr(tool_call, "id", "") or "", + tool_call_id=tool_call_id, ) result_preview = function_result if agent.verbose_logging else ( function_result[:200] if len(function_result) > 200 else function_result @@ -2704,11 +2734,11 @@ def _execute(next_args: dict) -> Any: function_result = maybe_persist_tool_result( content=function_result, tool_name=function_name, - tool_use_id=tool_call.id, + tool_use_id=tool_call_id, env=get_active_env(effective_task_id), config=_tool_budget, ) if not _is_multimodal_tool_result(function_result) else function_result - _record_persisted_path_for_stub(agent, tool_call.id, function_result) + _record_persisted_path_for_stub(agent, tool_call_id, function_result) # Discover subdirectory context files from tool arguments subdir_hints = agent._subdirectory_hints.check_tool_call(function_name, function_args) @@ -2724,7 +2754,7 @@ def _execute(next_args: dict) -> Any: tool_message = make_tool_result_message( function_name, _tool_content, - tool_call.id, + tool_call_id, effect_disposition="unknown" if _execution_timed_out else None, ) messages.append(tool_message) @@ -2755,7 +2785,7 @@ def _execute(next_args: dict) -> Any: or function_args ) agent.tool_complete_callback( - tool_call.id, + tool_call_id, function_name, display_args, display_function_result, @@ -2774,7 +2804,7 @@ def _execute(next_args: dict) -> Any: function_name, None, None, - tool_call_id=tool_call.id, + tool_call_id=tool_call_id, risk_metadata=risk_metadata, ) except Exception as cb_err: @@ -2797,7 +2827,7 @@ def _execute(next_args: dict) -> Any: messages.append(make_tool_result_message( skipped_name, f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]", - skipped_tc.id, + _pairing_tool_call_id(skipped_tc), effect_disposition="none", )) if not _flush_session_db_after_tool_progress( diff --git a/agent/tool_guardrails.py b/agent/tool_guardrails.py index e4afbbba8222..ff30ee516c6e 100644 --- a/agent/tool_guardrails.py +++ b/agent/tool_guardrails.py @@ -68,7 +68,6 @@ STALL_GUARD_REPEATABLE_TOOLS = frozenset( { "process", - "bfl_flux3_get_result", } ) diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 38e86a654cfe..45b53410c58b 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -567,8 +567,17 @@ def build_kwargs( # request is issued (openai==2.24.0). Reported for the # ``openai-codex`` / ``gpt-5.5`` combo on chatgpt.com/backend-api/codex # (#32892) when the agent runs without external tools registered. + # Function-level import: agent.model_metadata is imported lazily + # because provider plugins import this transport during + # model_metadata's own module init (circular otherwise). + from agent.model_metadata import ( + strip_codex_context_variant_suffix as _strip_ctx_variant, + ) kwargs = { - "model": model, + # ``-900k`` large-context picker variants are Hermes-side aliases + # (gpt-5.6-sol-900k etc.) — the Codex/OpenAI backend only knows + # the base slug, so strip the suffix before it hits the wire. + "model": _strip_ctx_variant(model), "instructions": instructions, "input": _chat_messages_to_responses_input( payload_messages, diff --git a/agent/turn_context.py b/agent/turn_context.py index f9bc38f126bf..5982e3846fdf 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -274,18 +274,26 @@ def reanchor_current_turn_user_idx(messages: List[Any], user_message: Any) -> in scaffolding, not the active ask. Returns -1 when the list has no user-originated message at all. """ - from agent.context_compressor import is_user_originated_turn + from agent.context_compressor import user_originated_turn_view fallback = -1 for i in range(len(messages) - 1, -1, -1): msg = messages[i] if not (isinstance(msg, dict) and msg.get("role") == "user"): continue + # Typed synthetic current events still need their physical persistence + # anchor when their raw content is unchanged. They are not eligible + # for the human-only fallback below. if msg.get("content") == user_message: return i + live_view = user_originated_turn_view(msg) + if live_view is None: + continue + if live_view.get("content") == user_message: + return i # Prefer a real human turn over a synthetic handoff / continuation # marker when the exact content was rewritten by merge-into-tail. - if fallback < 0 and is_user_originated_turn(msg): + if fallback < 0: fallback = i return fallback @@ -320,6 +328,24 @@ def compression_made_progress( _compression_made_progress = compression_made_progress +def _review_fork_first_request_pending(agent: Any) -> bool: + """Whether a detached review fork has yet to send its first provider request. + + The background-review fork (issue #93057) replays the parent's FULL + snapshot on its first provider request as a warm prompt-cache read + (same-model cache parity). Compaction must not rewrite the snapshot + before that first request goes out — a compacted transcript would miss + the parent's cached prefix and turn a cheap cached replay into a cold + over-threshold write. Once the first provider response has arrived the + fork's tool loop is its own context, and both compression gates resume. + Dormant for every agent without the attribute. + """ + return bool( + getattr(agent, "_review_defer_compaction_before_first_response", False) + and not getattr(agent, "_turn_received_provider_response", False) + ) + + def _compression_warrants_another_preflight_pass( orig_tokens: int, new_tokens: int, threshold_tokens: int ) -> bool: @@ -879,11 +905,15 @@ def build_turn_context( _preflight_compression_blocked = False agent._turn_received_provider_response = False agent._turn_preflight_display_snapshot = None - if agent.compression_enabled and _should_run_preflight_estimate( - messages, - agent.context_compressor.protect_first_n, - agent.context_compressor.protect_last_n, - agent.context_compressor.threshold_tokens, + if ( + agent.compression_enabled + and not _review_fork_first_request_pending(agent) + and _should_run_preflight_estimate( + messages, + agent.context_compressor.protect_first_n, + agent.context_compressor.protect_last_n, + agent.context_compressor.threshold_tokens, + ) ): _preflight_tokens = estimate_request_tokens_rough( messages, @@ -1331,10 +1361,15 @@ def build_turn_context( # Clear stale per-thread interrupt state, preserving a pending interrupt. ra()._set_interrupt(False, agent._execution_thread_id) if agent._interrupt_requested: - ra()._set_interrupt(True, agent._execution_thread_id) + ra()._set_interrupt( + True, + agent._execution_thread_id, + reason=getattr(agent, "_tool_interrupt_reason", None), + ) agent._interrupt_thread_signal_pending = False else: agent._interrupt_message = None + agent._tool_interrupt_reason = None agent._interrupt_thread_signal_pending = False # Notify memory providers of the new turn (BEFORE prefetch_all). diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index fbdf19cd91b4..8b1c8e64e2a6 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -26,6 +26,7 @@ import os from agent.codex_responses_adapter import _summarize_user_message_for_log +from agent.context_compressor import _DB_PERSISTED_MARKER from agent.message_content import flatten_message_text from agent.message_metadata import append_message, stamp_message_timestamp from agent.message_sanitization import _sanitize_surrogates @@ -377,7 +378,7 @@ def finalize_turn( # re-writes the filled content to the durable store — # otherwise ``/resume`` reloads ``content=""`` and the bug # resurfaces cross-session. - _tail.pop("_db_persisted", None) + _tail.pop(_DB_PERSISTED_MARKER, None) # The bounded flush-scan cursor (run_agent.py) skips the # identity-matched prefix of its previous snapshot on the # assumption that no live dict loses the marker in place — @@ -413,6 +414,14 @@ def finalize_turn( and getattr(_compressor, '_micro_compact_enabled', False) is True and callable(getattr(_compressor, '_micro_compact', None)) and final_response + # compression.checkpoint_required: agent init already + # forces _micro_compact_enabled off, but the compressor + # attribute is plain state a future path could flip on a + # live agent. Micro-compaction has no checkpoint hook in + # its path, so it must never run while the gate is armed. + and getattr( + agent, "compression_checkpoint_required", False + ) is not True # Persistence-isolated agents (background review fork) # must not micro-compact: the pass burns a real aux-LLM # call on a throwaway replay transcript, and if the diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index c955bdc7f048..9af08f6dc0e5 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -127,6 +127,16 @@ class PricingEntry: source_url: Optional[str] = None pricing_version: Optional[str] = None fetched_at: Optional[datetime] = None + # Context-tiered pricing (e.g. Gemini Pro models charge higher rates once + # the prompt exceeds 200k tokens). When ``tier_threshold_tokens`` is set + # and ``usage.prompt_tokens`` (input + cache read + cache write) exceeds + # it, the ``*_above`` rates replace the base rates for the WHOLE request — + # that matches Google's billing semantics (not marginal/bracketed rates). + # Any ``*_above`` field left as None falls back to its base rate. + tier_threshold_tokens: Optional[int] = None + input_cost_per_million_above: Optional[Decimal] = None + output_cost_per_million_above: Optional[Decimal] = None + cache_read_cost_per_million_above: Optional[Decimal] = None @dataclass(frozen=True) @@ -594,6 +604,10 @@ class CostResult: input_cost_per_million=Decimal("2.00"), output_cost_per_million=Decimal("12.00"), cache_read_cost_per_million=Decimal("0.20"), + tier_threshold_tokens=200_000, + input_cost_per_million_above=Decimal("4.00"), + output_cost_per_million_above=Decimal("18.00"), + cache_read_cost_per_million_above=Decimal("0.40"), source="official_docs_snapshot", source_url="https://ai.google.dev/pricing", pricing_version="google-pricing-2026-07-07", @@ -638,6 +652,9 @@ class CostResult: input_cost_per_million=Decimal("1.25"), output_cost_per_million=Decimal("10.00"), cache_read_cost_per_million=Decimal("0.125"), + tier_threshold_tokens=200_000, + input_cost_per_million_above=Decimal("2.50"), + output_cost_per_million_above=Decimal("15.00"), source="official_docs_snapshot", source_url="https://ai.google.dev/pricing", pricing_version="google-pricing-2026-07-07", @@ -994,11 +1011,16 @@ class CostResult: # GPT-5.6 "-pro" high-effort variants bill at the same per-token rates as # their base tiers (more tokens per task, not a higher rate). Alias them -# onto the base entries so the snapshot stays single-source. +# onto the base entries so the snapshot stays single-source. The Hermes-side +# "-900k" large-context Codex picker variants are the same underlying model +# (the suffix is stripped on the wire), so they alias identically. for _base_56 in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"): _OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-pro")] = _OFFICIAL_DOCS_PRICING[ ("openai", _base_56) ] + _OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-900k")] = _OFFICIAL_DOCS_PRICING[ + ("openai", _base_56) + ] del _base_56 # The direct Gemini provider currently exposes preview IDs for these two @@ -1449,12 +1471,30 @@ def estimate_usage_cost( notes: list[str] = [] amount = _ZERO - if usage.input_tokens and entry.input_cost_per_million is None: + # Whole-request context-tier selection (e.g. Gemini Pro >200k prompts): + # once the prompt (input + cache read + cache write) exceeds the entry's + # threshold, the above-threshold rates apply to the entire request. Any + # tier rate left as None falls back to the base rate. + input_rate = entry.input_cost_per_million + output_rate = entry.output_cost_per_million + cache_read_rate = entry.cache_read_cost_per_million + if ( + entry.tier_threshold_tokens is not None + and usage.prompt_tokens > entry.tier_threshold_tokens + ): + if entry.input_cost_per_million_above is not None: + input_rate = entry.input_cost_per_million_above + if entry.output_cost_per_million_above is not None: + output_rate = entry.output_cost_per_million_above + if entry.cache_read_cost_per_million_above is not None: + cache_read_rate = entry.cache_read_cost_per_million_above + + if usage.input_tokens and input_rate is None: return CostResult(amount_usd=None, status="unknown", source=entry.source, label="n/a") - if usage.output_tokens and entry.output_cost_per_million is None: + if usage.output_tokens and output_rate is None: return CostResult(amount_usd=None, status="unknown", source=entry.source, label="n/a") if usage.cache_read_tokens: - if entry.cache_read_cost_per_million is None: + if cache_read_rate is None: return CostResult( amount_usd=None, status="unknown", @@ -1472,12 +1512,12 @@ def estimate_usage_cost( notes=("cache-write pricing unavailable for route",), ) - if entry.input_cost_per_million is not None: - amount += Decimal(usage.input_tokens) * entry.input_cost_per_million / _ONE_MILLION - if entry.output_cost_per_million is not None: - amount += Decimal(usage.output_tokens) * entry.output_cost_per_million / _ONE_MILLION - if entry.cache_read_cost_per_million is not None: - amount += Decimal(usage.cache_read_tokens) * entry.cache_read_cost_per_million / _ONE_MILLION + if input_rate is not None: + amount += Decimal(usage.input_tokens) * input_rate / _ONE_MILLION + if output_rate is not None: + amount += Decimal(usage.output_tokens) * output_rate / _ONE_MILLION + if cache_read_rate is not None: + amount += Decimal(usage.cache_read_tokens) * cache_read_rate / _ONE_MILLION if entry.cache_write_cost_per_million is not None: amount += Decimal(usage.cache_write_tokens) * entry.cache_write_cost_per_million / _ONE_MILLION if entry.request_cost is not None and usage.request_count: diff --git a/agent/vertex_adapter.py b/agent/vertex_adapter.py index 6e425753f053..190c4beb3526 100644 --- a/agent/vertex_adapter.py +++ b/agent/vertex_adapter.py @@ -226,3 +226,26 @@ def has_vertex_credentials() -> bool: if _resolve_project_override(): return True return False + + +def has_explicit_vertex_config() -> bool: + """True only when the user deliberately pointed Hermes at Vertex. + + Stricter than :func:`has_vertex_credentials`, which also returns True for + an ambient ``GOOGLE_APPLICATION_CREDENTIALS`` path — a var commonly set + globally for unrelated GCP work. That ambient signal must NOT mark Vertex + "explicitly configured" for the model-picker gate, or a user who never set + Hermes up for Vertex would suddenly see it (and could spend against those + credentials). So this checks only Hermes-scoped signals: + + * ``VERTEX_PROJECT_ID`` env or ``vertex.project_id`` in config.yaml + (``_resolve_project_override``), or + * a resolvable ``VERTEX_CREDENTIALS_PATH`` service-account file + (the Hermes-specific path var — NOT ``GOOGLE_APPLICATION_CREDENTIALS``). + """ + if _resolve_project_override(): + return True + sa_path = _get_secret("VERTEX_CREDENTIALS_PATH") + if sa_path and os.path.isfile(sa_path) and os.access(sa_path, os.R_OK): + return True + return False diff --git a/apps/desktop/e2e/at-rest-connection-token.spec.ts b/apps/desktop/e2e/at-rest-connection-token.spec.ts index 5ebf3ac66275..8fc8914d48ab 100644 --- a/apps/desktop/e2e/at-rest-connection-token.spec.ts +++ b/apps/desktop/e2e/at-rest-connection-token.spec.ts @@ -523,10 +523,19 @@ test.afterEach(async () => { }) test.describe('remote gateway session token at rest', () => { - test('a newly configured token is never written to userData in plaintext, and still works after restart', async () => { + test('with keychain encryption opted IN, a newly configured token is never written to userData in plaintext, and still works after restart', async () => { const fake = gateway! sandbox = createSandbox('at-rest-fresh') + // Keychain-backed encryption is opt-in (default OFF — see + // electron/secret-storage-policy.ts). This test covers the opted-IN + // posture, so seed the policy the way the Settings toggle writes it. + fs.writeFileSync( + path.join(sandbox.userDataDir, 'secure-token-storage.json'), + JSON.stringify({ migrated: true, on: true }), + 'utf8', + ) + const first = await launchAgainst(sandbox) app = first.app @@ -648,6 +657,63 @@ test.describe('remote gateway session token at rest', () => { ).toContain(SENTINEL_TOKEN) }) + /** + * The DEFAULT posture: keychain encryption opted out (no policy file at + * all). Saving a token must (a) succeed without ever touching safeStorage + * — this is the whole point of the opt-in: no macOS Keychain dialog on + * machines with a broken login keychain — (b) store the token with a + * non-safeStorage encoding at 0600, and (c) round-trip it across a + * restart. The plaintext-on-disk trade-off is the user's chosen (default) + * mode; owner-only file bits remain the at-rest boundary. + */ + test('with the default policy (no keychain), a token saves without secure storage, is owner-only on disk, and survives a restart', async () => { + const fake = gateway! + sandbox = createSandbox('at-rest-default') + + const first = await launchAgainst(sandbox) + app = first.app + + const userDataDir = await resolveUserDataDir(app) + const connectionFile = path.join(userDataDir, 'connection.json') + + // Must succeed regardless of host keyring state — the default policy + // never consults safeStorage, so "no keyring" cannot refuse the save. + const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN) + + expect(saved.error, 'the default (opted-out) policy must save without secure storage').toBeNull() + expect(saved.config?.remoteTokenSet).toBe(true) + + // Not a safeStorage blob, and owner-only on disk. + expect(storedTokenEncoding(connectionFile)).not.toBe('safeStorage') + expectOwnerOnlyMode( + connectionFile, + 'connection.json is group/other-accessible; owner-only bits are the at-rest boundary for opted-out storage', + ) + + // Round trip across a restart, same witness as the opted-in test. + await app.close().catch(() => undefined) + app = null + + const second = await launchAgainst(sandbox) + app = second.app + + const reread = await second.page.evaluate(async () => { + const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop + + return desktop.getConnectionConfig() + }) + + expect(reread.remoteTokenSet, 'the stored token must survive a restart').toBe(true) + + const before = fake.sessionTokens.length + await exerciseStoredToken(second.page, fake.url) + + expect( + fake.sessionTokens.slice(before), + 'the app must send the exact stored token to the gateway after a restart', + ).toContain(SENTINEL_TOKEN) + }) + /** * The read side of the same contract: an install written BEFORE the file was * owner-only keeps its 0644 bits until something chmods it, and the write diff --git a/apps/desktop/e2e/zoom-preservation.spec.ts b/apps/desktop/e2e/zoom-preservation.spec.ts new file mode 100644 index 000000000000..9684bb3bfc8c --- /dev/null +++ b/apps/desktop/e2e/zoom-preservation.spec.ts @@ -0,0 +1,87 @@ +/** + * E2E regression: in-page route navigation must preserve the chosen UI scale. + * + * Desktop is a HashRouter over one file:// document, so every route is a + * distinct URL to Chromium's per-URL zoom store, and a route with no record of + * its own resolves to the host default (100%). In-page navigation fires no load + * or window event, so nothing re-asserted the persisted level: switching + * sessions dropped the window to 100% while Appearance kept reading the chosen + * scale (#48658, #38854, #79863). + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { expect, test } from './test' + +const SCALE = 110 + +let fixture: MockBackendFixture | null = null + +async function readZoomPercent(): Promise { + return fixture!.page.evaluate(async () => { + const desktop = window as unknown as { + hermesDesktop: { zoom: { get: () => Promise<{ percent: number }> } } + } + + return (await desktop.hermesDesktop.zoom.get()).percent + }) +} + +async function setZoomPercent(percent: number): Promise { + await fixture!.page.evaluate(target => { + const desktop = window as unknown as { + hermesDesktop: { zoom: { setPercent: (percent: number) => void } } + } + + desktop.hermesDesktop.zoom.setPercent(target) + }, percent) + await expect.poll(readZoomPercent).toBe(percent) +} + +async function gotoRoute(route: string): Promise { + const page = fixture!.page + + await page.evaluate(target => { + window.location.hash = target + }, route) + await page.waitForFunction(target => window.location.hash === `#${target}`, route) +} + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('a non-default UI scale survives navigation to never-zoomed routes', async () => { + await setZoomPercent(SCALE) + + // Routes Chromium has no zoom record for — what opening a new session looks + // like to the per-URL store. Pre-fix, the first hop reports 100%. + const fresh = `/e2e-zoom-${Date.now()}` + + for (const route of [`${fresh}-one`, `${fresh}-two`, '/settings?tab=config%3Aappearance']) { + await gotoRoute(route) + await expect.poll(readZoomPercent, { message: `UI scale after navigating to ${route}` }).toBe(SCALE) + } +}) + +test('Cmd/Ctrl+N preserves a non-default UI scale', async () => { + const page = fixture!.page + + await gotoRoute('/settings') + await setZoomPercent(SCALE) + + await page.evaluate(() => { + ;(document.activeElement as HTMLElement | null)?.blur() + }) + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+N' : 'Control+N') + await page.waitForFunction(() => window.location.hash === '#/') + + await expect.poll(readZoomPercent).toBe(SCALE) +}) diff --git a/apps/desktop/electron/api-transport.test.ts b/apps/desktop/electron/api-transport.test.ts new file mode 100644 index 000000000000..bc622b67c4ca --- /dev/null +++ b/apps/desktop/electron/api-transport.test.ts @@ -0,0 +1,383 @@ +/** + * Unit + live-transport tests for the Electron main process's Hermes REST + * retry policy (#92976 / PR #92977 salvage). + * + * The live tests run REAL node http servers that misbehave the way the + * reported backend does (closing sockets under burst keep-alive traffic) and + * prove two things end to end: + * + * - idempotent GETs that die with ECONNRESET are retried and succeed, where + * a single bare attempt (pre-PR behavior) surfaces the raw reset; + * - a POST whose socket is reset AFTER the server processed it is NOT + * retried: the server-side hit counter stays at 1 and the error surfaces. + */ +import http from 'node:http' +import type { AddressInfo } from 'node:net' + +import { afterAll, describe, expect, it } from 'vitest' + +import { + destroyKeepaliveAgents, + downloadAgentFor, + isIdempotentMethod, + isTransientTransportError, + jsonAgentFor, + shouldRetryRequest, + withRetry +} from './api-transport' + +function errWithCode(code: string, message = code): NodeJS.ErrnoException { + const e: NodeJS.ErrnoException = new Error(message) + e.code = code + + return e +} + +afterAll(() => { + destroyKeepaliveAgents() +}) + +describe('isTransientTransportError', () => { + it('accepts transient socket-level codes and messages', () => { + for (const code of ['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN']) { + expect(isTransientTransportError(errWithCode(code))).toBe(true) + } + + expect(isTransientTransportError(new Error('socket hang up'))).toBe(true) + expect(isTransientTransportError(new Error('read ECONNRESET'))).toBe(true) + }) + + it('rejects non-transport errors', () => { + expect(isTransientTransportError(new Error('404: not found'))).toBe(false) + expect(isTransientTransportError(new Error('Invalid JSON from http://x'))).toBe(false) + expect(isTransientTransportError(null)).toBe(false) + expect(isTransientTransportError(undefined)).toBe(false) + }) +}) + +describe('isIdempotentMethod', () => { + it.each([ + ['GET', true], + ['get', true], + ['HEAD', true], + ['OPTIONS', true], + ['POST', false], + ['PUT', false], + ['PATCH', false], + ['DELETE', false], + [undefined, true] // node http defaults omitted method to GET + ])('%s -> %s', (method, expected) => { + expect(isIdempotentMethod(method)).toBe(expected) + }) +}) + +describe('shouldRetryRequest truth table', () => { + const reset = () => errWithCode('ECONNRESET', 'read ECONNRESET') + const refused = () => errWithCode('ECONNREFUSED', 'connect ECONNREFUSED 127.0.0.1:1') + const hangUp = () => new Error('socket hang up') + + it('GET: retries any transient error regardless of body state', () => { + expect(shouldRetryRequest(reset(), 'GET', { bodySent: true })).toBe(true) + expect(shouldRetryRequest(reset(), 'GET', { bodySent: false })).toBe(true) + expect(shouldRetryRequest(hangUp(), 'HEAD', { bodySent: true })).toBe(true) + }) + + it('GET: never retries non-transport errors (HTTP 4xx/5xx surfaced as Error)', () => { + expect(shouldRetryRequest(new Error('500: boom'), 'GET', { bodySent: true })).toBe(false) + }) + + it('POST: retries when the connection provably never happened', () => { + expect(shouldRetryRequest(refused(), 'POST', { bodySent: false })).toBe(true) + expect(shouldRetryRequest(refused(), 'POST', { bodySent: true })).toBe(true) // refused == nothing sent + expect(shouldRetryRequest(errWithCode('ENOTFOUND'), 'PUT', { bodySent: false })).toBe(true) + }) + + it('POST: retries transient errors thrown before the body was flushed', () => { + expect(shouldRetryRequest(reset(), 'POST', { bodySent: false })).toBe(true) + expect(shouldRetryRequest(hangUp(), 'DELETE', { bodySent: false })).toBe(true) + }) + + it('POST: does NOT retry ambiguous resets after the body went out', () => { + expect(shouldRetryRequest(reset(), 'POST', { bodySent: true })).toBe(false) + expect(shouldRetryRequest(hangUp(), 'POST', { bodySent: true })).toBe(false) + expect(shouldRetryRequest(errWithCode('EPIPE'), 'PUT', { bodySent: true })).toBe(false) + expect(shouldRetryRequest(errWithCode('ETIMEDOUT'), 'DELETE', { bodySent: true })).toBe(false) + }) + + it('POST: conservative when request state is unknown', () => { + // No bodySent flag at all — treat as "may have been sent", don't retry. + expect(shouldRetryRequest(reset(), 'POST', {})).toBe(false) + expect(shouldRetryRequest(reset(), 'POST')).toBe(false) + }) +}) + +describe('withRetry', () => { + const noDelay = { delayFn: () => Promise.resolve() } + + it('retries a GET through transient failures and resolves', async () => { + let attempts = 0 + + const result = await withRetry( + () => { + attempts += 1 + + if (attempts < 3) { + return Promise.reject(errWithCode('ECONNRESET')) + } + + return Promise.resolve('ok') + }, + { method: 'GET', ...noDelay } + ) + + expect(result).toBe('ok') + expect(attempts).toBe(3) + }) + + it('gives each attempt a fresh requestState', async () => { + const seen: boolean[] = [] + let attempts = 0 + await withRetry( + (state: any) => { + seen.push(state.bodySent) + state.bodySent = true + attempts += 1 + + if (attempts < 2) { + return Promise.reject(errWithCode('ECONNREFUSED')) + } + + return Promise.resolve(null) + }, + { method: 'POST', ...noDelay } + ) + expect(seen).toEqual([false, false]) + }) + + it('does not retry a POST that failed after the body was flushed', async () => { + let attempts = 0 + await expect( + withRetry( + (state: any) => { + attempts += 1 + state.bodySent = true + + return Promise.reject(errWithCode('ECONNRESET', 'read ECONNRESET')) + }, + { method: 'POST', ...noDelay } + ) + ).rejects.toThrow('read ECONNRESET') + expect(attempts).toBe(1) + }) + + it('retries a POST on ECONNREFUSED (never reached the server)', async () => { + let attempts = 0 + await expect( + withRetry( + () => { + attempts += 1 + + return Promise.reject(errWithCode('ECONNREFUSED')) + }, + { method: 'POST', maxRetries: 2, ...noDelay } + ) + ).rejects.toThrow('ECONNREFUSED') + expect(attempts).toBe(3) + }) + + it('bounds retries at maxRetries even for GET', async () => { + let attempts = 0 + await expect( + withRetry( + () => { + attempts += 1 + + return Promise.reject(errWithCode('ECONNRESET')) + }, + { method: 'GET', maxRetries: 2, ...noDelay } + ) + ).rejects.toThrow() + expect(attempts).toBe(3) + }) + + it('never retries non-transient errors', async () => { + let attempts = 0 + await expect( + withRetry( + () => { + attempts += 1 + + return Promise.reject(new Error('500: internal')) + }, + { method: 'GET', ...noDelay } + ) + ).rejects.toThrow('500') + expect(attempts).toBe(1) + }) +}) + +describe('keep-alive agent pools', () => { + it('separates JSON and download pools per protocol', () => { + expect(jsonAgentFor('http:')).not.toBe(jsonAgentFor('https:')) + expect(jsonAgentFor('http:')).not.toBe(downloadAgentFor('http:')) + expect(jsonAgentFor('https:')).not.toBe(downloadAgentFor('https:')) + // Stable across calls (a real pool, not a factory). + expect(jsonAgentFor('http:')).toBe(jsonAgentFor('http:')) + }) +}) + +// --------------------------------------------------------------------------- +// LIVE transport tests against real misbehaving HTTP servers. +// --------------------------------------------------------------------------- + +/** Minimal single-attempt GET mirroring the pre-PR fetchJson (no retry). */ +function bareJsonGet(url: string): Promise { + return new Promise((resolve, reject) => { + const req = http.request(new URL(url), { agent: jsonAgentFor('http:'), method: 'GET' }, res => { + const chunks: Buffer[] = [] + res.on('error', reject) + res.on('data', c => chunks.push(c)) + res.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))) + }) + + req.on('error', reject) + req.end() + }) +} + +/** The head behavior: same request under the verb-gated retry policy. */ +function retriedJsonGet(url: string): Promise { + return withRetry(() => bareJsonGet(url), { method: 'GET', delayFn: () => Promise.resolve() }) +} + +function listen(server: http.Server): Promise { + return new Promise(resolve => { + server.listen(0, '127.0.0.1', () => { + resolve(`http://127.0.0.1:${(server.address() as AddressInfo).port}`) + }) + }) +} + +describe('live: GET burst against a server that resets keep-alive sockets', () => { + it('bare attempts fail with ECONNRESET/hang-up; retried GETs all succeed', async () => { + // Deterministic misbehavior: every other request gets its socket + // destroyed instead of a response — the observable client-side effect of + // a backend killing idle keep-alive sockets mid-burst. + let hits = 0 + + const server = http.createServer((req, res) => { + hits += 1 + + if (hits % 2 === 1) { + req.socket.destroy() + + return + } + + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ n: hits })) + }) + + const base = await listen(server) + + try { + // BASE (pre-PR, single attempt): the burst surfaces raw transport errors. + let baseFailures = 0 + + for (let i = 0; i < 6; i++) { + try { + await bareJsonGet(`${base}/api/sessions`) + } catch (error: any) { + baseFailures += 1 + expect(isTransientTransportError(error)).toBe(true) + } + } + + expect(baseFailures).toBeGreaterThan(0) + + // HEAD (retry policy): the same burst fully succeeds. Sequential so the + // server's alternating destroy/respond pattern is deterministic per + // request (first attempt reset, retry served). + for (let i = 0; i < 6; i++) { + const r = await retriedJsonGet(`${base}/api/sessions`) + expect(r).toHaveProperty('n') + } + } finally { + server.close() + } + }, 20_000) +}) + +describe('live: POST reset after server-side processing', () => { + it('does not double-submit: server hit count stays 1, error surfaces', async () => { + // The server fully receives and "processes" the POST (counter increments), + // then RSTs the socket before responding — the dangerous ambiguous case. + let posts = 0 + + const server = http.createServer((req, res) => { + const chunks: Buffer[] = [] + req.on('data', c => chunks.push(c)) + req.on('end', () => { + posts += 1 // processed: prompt submitted / session created + req.socket.resetAndDestroy() + void res + }) + }) + + const base = await listen(server) + + const postOnce = () => + withRetry( + (state: any) => + new Promise((resolve, reject) => { + const body = Buffer.from(JSON.stringify({ prompt: 'hello' })) + + const req = http.request( + new URL(`${base}/api/prompt`), + { + agent: jsonAgentFor('http:'), + method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': String(body.length) } + }, + res => { + res.resume() + res.on('end', () => resolve(null)) + } + ) + + req.on('error', reject) + state.bodySent = true + req.write(body) + req.end() + }), + { method: 'POST', delayFn: () => Promise.resolve() } + ) + + try { + await expect(postOnce()).rejects.toSatisfy((error: any) => isTransientTransportError(error)) + expect(posts).toBe(1) // exactly one server-side submission — no retry + } finally { + server.close() + } + }, 20_000) + + it('sanity: an identical GET-shaped retry WOULD have re-hit the server', async () => { + // Companion proof that the verb gate (not luck) is what kept posts === 1: + // the same reset-after-processing server sees multiple hits under GET. + let gets = 0 + + const server = http.createServer(req => { + gets += 1 + req.socket.resetAndDestroy() + }) + + const base = await listen(server) + + try { + await expect(retriedJsonGet(`${base}/api/thing`)).rejects.toThrow() + expect(gets).toBeGreaterThan(1) // retried — proves the machinery fires + } finally { + server.close() + } + }, 20_000) +}) diff --git a/apps/desktop/electron/api-transport.ts b/apps/desktop/electron/api-transport.ts new file mode 100644 index 000000000000..552fa19c77d5 --- /dev/null +++ b/apps/desktop/electron/api-transport.ts @@ -0,0 +1,178 @@ +/** + * Shared HTTP transport policy for the Electron main process's Hermes REST + * helpers (fetchJson / fetchPublicJson / downloadViaTokenToFile). + * + * Two concerns live here so they can be unit-tested without Electron: + * + * 1. Connection-pooled keep-alive agents. Opening a fresh TCP socket per call + * is what produced the burst-traffic ECONNRESET storms (#92976): the + * backend closes idle keep-alive sockets and the next write on a reused + * raw socket dies with 'socket hang up'. JSON calls and streaming + * downloads get SEPARATE pools so a handful of long-lived download + * streams can never starve the small, latency-sensitive JSON calls out of + * the socket pool. + * + * 2. A retry policy that is safe for non-idempotent verbs. A transient + * transport error does NOT mean the server didn't process the request — + * an ECONNRESET can arrive after the backend already handled a POST + * (created the session, submitted the prompt) and merely lost the socket + * before the response was read. Blindly retrying every verb double-submits. + * + * The rule implemented by shouldRetryRequest(): + * - Idempotent verbs (GET / HEAD / OPTIONS) retry on any transient + * transport error — replaying them is harmless by definition. + * - Non-idempotent verbs (POST / PUT / PATCH / DELETE) retry ONLY when + * the request provably never reached the server: + * a) connection-establishment failures (ECONNREFUSED, ENOTFOUND, + * EAI_AGAIN, EHOSTUNREACH, ENETUNREACH) — no connection means no + * request; or + * b) a transient error thrown before we started flushing the + * request (requestState.bodySent === false). + * Anything ambiguous — ECONNRESET / EPIPE / 'socket hang up' after + * the body went out — is NOT retried; the error surfaces to the + * caller. When in doubt, don't retry a non-idempotent request. + */ + +import http from 'node:http' +import https from 'node:https' + +// JSON pool: many small concurrent calls (session lists, config, prompts). +const HTTP_JSON_AGENT = new http.Agent({ keepAlive: true, maxSockets: 50 }) +const HTTPS_JSON_AGENT = new https.Agent({ keepAlive: true, maxSockets: 50 }) + +// Download pool: few long-lived streaming bodies. Isolated from the JSON pool +// so saturating it with large file downloads can't block interactive calls. +const HTTP_DOWNLOAD_AGENT = new http.Agent({ keepAlive: true, maxSockets: 8 }) +const HTTPS_DOWNLOAD_AGENT = new https.Agent({ keepAlive: true, maxSockets: 8 }) + +function jsonAgentFor(protocol) { + return protocol === 'https:' ? HTTPS_JSON_AGENT : HTTP_JSON_AGENT +} + +function downloadAgentFor(protocol) { + return protocol === 'https:' ? HTTPS_DOWNLOAD_AGENT : HTTP_DOWNLOAD_AGENT +} + +// Close pooled sockets so lingering keep-alive connections can't hold the +// process open (or leak FDs) across quit. Wired to app 'will-quit' in main.ts. +function destroyKeepaliveAgents() { + for (const agent of [HTTP_JSON_AGENT, HTTPS_JSON_AGENT, HTTP_DOWNLOAD_AGENT, HTTPS_DOWNLOAD_AGENT]) { + agent.destroy() + } +} + +// Transient transport errors: retry MAY be safe (subject to verb gating). +const TRANSIENT_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'EPIPE', + 'ETIMEDOUT', + 'EAI_AGAIN', + 'ENOTFOUND', + 'EHOSTUNREACH', + 'ENETUNREACH' +]) + +// Errors that prove the request never reached the server: the TCP connection +// (or name resolution) failed outright, so nothing was submitted. +const NEVER_SENT_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH']) + +const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']) + +function isIdempotentMethod(method) { + return IDEMPOTENT_METHODS.has(String(method || 'GET').toUpperCase()) +} + +function isTransientTransportError(error) { + if (!error) { + return false + } + + if (TRANSIENT_CODES.has(error.code)) { + return true + } + + const msg = String(error.message || '') + + return msg.includes('socket hang up') || msg.includes('read ECONNRESET') +} + +/** + * The verb-gated retry decision. + * + * @param error the transport error from the failed attempt + * @param method HTTP verb of the request ('GET', 'POST', ...) + * @param requestState per-attempt state; requestState.bodySent is set true by + * the caller just BEFORE the first byte of the request is + * flushed, so a `false` here proves nothing went out. + */ +function shouldRetryRequest(error, method, requestState: any = {}) { + if (!isTransientTransportError(error)) { + return false + } + + if (isIdempotentMethod(method)) { + return true + } + + // Non-idempotent: only when the request provably never reached the server. + if (NEVER_SENT_CODES.has(error && error.code)) { + return true + } + + if (requestState.bodySent === false) { + return true + } + + // Ambiguous (reset/hang-up after the body was flushed): the server may have + // processed it. Surface the error rather than risk a double submit. + return false +} + +/** + * Run `makeAttempt` with bounded retries under the policy above. + * + * `makeAttempt(requestState)` must return a Promise and should set + * `requestState.bodySent = true` immediately before flushing the request + * (before the first req.write()/req.end()). Each attempt gets a fresh state + * object initialized to { bodySent: false }. + */ +async function withRetry(makeAttempt, options: any = {}) { + const method = String(options.method || 'GET').toUpperCase() + const maxRetries = Number.isInteger(options.maxRetries) ? options.maxRetries : 2 + + const delayFn = + options.delayFn || (attempt => new Promise(r => setTimeout(r, Math.min(200 * Math.pow(2, attempt), 2000)))) + + let lastError + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const requestState = { bodySent: false } + + try { + return await makeAttempt(requestState) + } catch (error) { + lastError = error + + if (attempt < maxRetries && shouldRetryRequest(error, method, requestState)) { + await delayFn(attempt) + + continue + } + + throw error + } + } + + throw lastError +} + +export { + destroyKeepaliveAgents, + downloadAgentFor, + isIdempotentMethod, + isTransientTransportError, + jsonAgentFor, + shouldRetryRequest, + withRetry +} diff --git a/apps/desktop/electron/backend-claim.test.ts b/apps/desktop/electron/backend-claim.test.ts new file mode 100644 index 000000000000..851f88d1c52e --- /dev/null +++ b/apps/desktop/electron/backend-claim.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' + +import { test } from 'vitest' + +import { + claimDecision, + createBackendOutputTail, + DEFAULT_OUTPUT_TAIL_LIMIT, + isPidOnlyStartMarker, + pidOnlyStartMarker, + probeStartMarker, + processStartMarker +} from './backend-claim' + +// --- claimDecision: the #93608 policy --------------------------------------- + +test('probe success claims with the full start marker (unchanged behavior)', () => { + const decision = claimDecision(true, { ok: true, startMarker: 'linux:12345' }) + + assert.deepEqual(decision, { action: 'claim', startMarker: 'linux:12345' }) +}) + +test('probe success claims even when the child already exited (ownership records the incarnation)', () => { + // The claim itself must not invent a failure: the exit handler owns cleanup. + const decision = claimDecision(false, { ok: true, startMarker: 'win:99' }) + + assert.deepEqual(decision, { action: 'claim', startMarker: 'win:99' }) +}) + +test('probe failure on a LIVE child degrades to PID-only identity — never kills a healthy backend (#93608)', () => { + const decision = claimDecision(true, { ok: false, reason: 'powershell.exe timed out after 30000ms' }) + + assert.equal(decision.action, 'degrade') + assert.match((decision as { reason: string }).reason, /timed out/) +}) + +test('probe failure on a DEAD child fails closed so the caller can attach the stderr tail', () => { + const decision = claimDecision(false, { ok: false, reason: 'Get-Process: no process with ID 4242' }) + + assert.equal(decision.action, 'fail') + assert.match((decision as { reason: string }).reason, /4242/) +}) + +// --- probeStartMarker: throw → value ---------------------------------------- + +test('probeStartMarker converts a probe throw into { ok: false, reason }', async () => { + const probe = await probeStartMarker(4242, async () => { + throw new Error('PowerShell 5.1 cold start exceeded budget') + }) + + assert.deepEqual(probe, { ok: false, reason: 'PowerShell 5.1 cold start exceeded budget' }) +}) + +test('probeStartMarker passes a successful marker through', async () => { + const probe = await probeStartMarker(4242, async pid => `linux:${pid}`) + + assert.deepEqual(probe, { ok: true, startMarker: 'linux:4242' }) +}) + +// --- real probe: drives the actual OS helper (PowerShell on the Windows lane) --- + +test('processStartMarker resolves a real marker for the current process', async () => { + const marker = await processStartMarker(process.pid) + + assert.match(marker, /^(linux|win|winms|ps):.+/) +}) + +test('processStartMarker rejects for a PID that does not exist', async () => { + // Largest PIDs are bounded well below this on every supported platform. + await assert.rejects(processStartMarker(2 ** 30 + 12345)) +}) + +// --- PID-only marker helpers -------------------------------------------------- + +test('pidOnlyStartMarker round-trips through isPidOnlyStartMarker', () => { + const marker = pidOnlyStartMarker(4242) + + assert.equal(marker, 'pid-only:4242') + assert.equal(isPidOnlyStartMarker(marker), true) + assert.equal(isPidOnlyStartMarker('linux:12345'), false) + assert.equal(isPidOnlyStartMarker(undefined), false) +}) + +// --- output tail ring buffer ---------------------------------------------------- + +test('output tail keeps only the most recent bytes once past the limit', () => { + const tail = createBackendOutputTail(16) + + tail.append('0123456789') + tail.append('abcdefghij') + + assert.equal(tail.text(), '456789abcdefghij') + assert.equal(tail.text().length, 16) +}) + +test('output tail default limit is ~8KB', () => { + const tail = createBackendOutputTail() + + tail.append('x'.repeat(DEFAULT_OUTPUT_TAIL_LIMIT + 500)) + + assert.equal(tail.text().length, DEFAULT_OUTPUT_TAIL_LIMIT) + assert.equal(DEFAULT_OUTPUT_TAIL_LIMIT, 8192) +}) + +test('output tail interleaves stdout and stderr attached from spawn time', () => { + const child = { stderr: new EventEmitter(), stdout: new EventEmitter() } + const tail = createBackendOutputTail(64) + + tail.attach(child) + child.stdout.emit('data', Buffer.from('booting\n')) + child.stderr.emit('data', Buffer.from("ModuleNotFoundError: No module named 'hermes_cli'\n")) + + assert.match(tail.text(), /booting/) + assert.match(tail.text(), /ModuleNotFoundError/) +}) + +test('describe() is empty when nothing was captured, formatted when output exists', () => { + const tail = createBackendOutputTail(64) + + assert.equal(tail.describe(), '') + + tail.append('Traceback (most recent call last):\n') + assert.match(tail.describe(), /^\nRecent backend output:\nTraceback/) +}) + +test('attach tolerates a child with missing stdio streams', () => { + const tail = createBackendOutputTail(64) + + tail.attach({ stderr: null, stdout: null }) + assert.equal(tail.text(), '') +}) diff --git a/apps/desktop/electron/backend-claim.ts b/apps/desktop/electron/backend-claim.ts new file mode 100644 index 000000000000..761b1bcf6051 --- /dev/null +++ b/apps/desktop/electron/backend-claim.ts @@ -0,0 +1,196 @@ +/** + * backend-claim.ts + * + * The start-marker probe and the claim decision for a freshly spawned local + * backend child, extracted from main.ts so the policy is testable without + * booting Electron — including on a Windows CI lane that drives the probe + * with REAL PowerShell (`processStartMarker` shells out to powershell.exe). + * + * Why this exists (#93608): `claimBackendChild` used to hard-fail on ANY + * probe error — `Get-Process` timing out on a PowerShell 5.1 cold start + * (see #87169) killed a perfectly healthy backend, the renderer "repaired" + * by respawning, and the next probe timeout killed that one too. The rule is + * now the same one `createParentStartMarkerResolver` already applies to the + * parent marker: a failed probe against a LIVE child degrades to PID-only + * identity instead of killing the child; only a child that actually DIED + * keeps the fail-closed throw (now carrying its stderr tail, so the real + * exit reason reaches desktop.log and the boot UI). + */ + +import { execFile } from 'node:child_process' +import fs from 'node:fs' + +import { electronProcessStartMarker } from './parent-process-identity' +import { hiddenWindowsChildOptions } from './windows-child-options' + +export function execText(command: string, args: string[], { timeout = 3000 } = {}): Promise { + return new Promise((resolve, reject) => { + execFile(command, args, hiddenWindowsChildOptions({ encoding: 'utf8', timeout }), (error, stdout) => { + if (error) { + reject(error) + } else { + resolve(String(stdout || '').trim()) + } + }) + }) +} + +/** + * Cross-platform process start marker: a value that changes when a PID is + * reused, so `pid + marker` identifies one specific process incarnation. + * Throws when the probe fails — callers decide what a failure means (see + * `claimDecision` / `probeStartMarker`). + */ +export async function processStartMarker(pid: number): Promise { + if (process.platform === 'linux') { + const stat = await fs.promises.readFile(`/proc/${pid}/stat`, 'utf8') + + const fields = stat + .slice(stat.lastIndexOf(')') + 1) + .trim() + .split(/\s+/) + + if (!/^\d+$/.test(fields[19] || '')) { + throw new Error(`Invalid /proc start marker for PID ${pid}`) + } + + return `linux:${fields[19]}` + } + + if (process.platform === 'win32') { + const electronMarker = + pid === process.pid ? electronProcessStartMarker(pid, process.pid, process.getCreationTime?.()) : null + + if (electronMarker) { + return electronMarker + } + + const ticks = await execText( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `$p = Get-Process -Id ${pid} -ErrorAction Stop; $p.StartTime.ToUniversalTime().Ticks` + ], + // PowerShell 5.1 cold starts routinely exceed the default 3s execText + // budget (2.4-8s observed in #87169); give the marker probe headroom. + { timeout: 30_000 } + ) + + if (!/^\d+$/.test(ticks)) { + throw new Error(`Invalid Windows start marker for PID ${pid}`) + } + + return `win:${ticks}` + } + + const started = await execText('ps', ['-p', String(pid), '-o', 'lstart=']) + + if (!started) { + throw new Error(`Missing process start marker for PID ${pid}`) + } + + return `ps:${started}` +} + +export type StartMarkerProbe = { ok: true; startMarker: string } | { ok: false; reason: string } + +/** Run the marker probe, converting a throw into a value the pure decision can consume. */ +export async function probeStartMarker( + pid: number, + probe: (pid: number) => Promise = processStartMarker +): Promise { + try { + return { ok: true, startMarker: await probe(pid) } + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : String(error) } + } +} + +const PID_ONLY_MARKER_PREFIX = 'pid-only:' + +/** + * Degraded identity marker recorded when the start-marker probe failed but + * the child was verifiably alive. It satisfies the ownership schema (a + * non-empty startMarker) while telling identity matchers that only PID + * liveness — plus the command-line check layered on top — can be verified. + */ +export function pidOnlyStartMarker(pid: number): string { + return `${PID_ONLY_MARKER_PREFIX}${pid}` +} + +export function isPidOnlyStartMarker(startMarker: unknown): boolean { + return typeof startMarker === 'string' && startMarker.startsWith(PID_ONLY_MARKER_PREFIX) +} + +export type ClaimDecision = + { action: 'claim'; startMarker: string } | { action: 'degrade'; reason: string } | { action: 'fail'; reason: string } + +/** + * Pure claim policy for a freshly spawned backend child: + * + * - probe succeeded → claim with the full start marker (unchanged). + * - probe failed, child ALIVE → degrade to PID-only identity; NEVER kill a + * healthy backend over a flaky identity probe. + * - probe failed, child DEAD → fail closed; the child's death is the real + * story and the caller attaches its stderr tail. + */ +export function claimDecision(childAlive: boolean, probe: StartMarkerProbe): ClaimDecision { + if (probe.ok === true) { + return { action: 'claim', startMarker: probe.startMarker } + } + + const { reason } = probe + + return childAlive ? { action: 'degrade', reason } : { action: 'fail', reason } +} + +export interface BackendOutputTail { + /** Attach stdout/stderr data listeners to a just-spawned child. */ + attach(child: { + stdout?: { on: (event: 'data', listener: (chunk: unknown) => void) => unknown } | null + stderr?: { on: (event: 'data', listener: (chunk: unknown) => void) => unknown } | null + }): void + append(chunk: unknown): void + /** The buffered tail (most recent `limit` characters), or ''. */ + text(): string + /** Human-readable suffix for error messages, or '' when nothing buffered. */ + describe(): string +} + +export const DEFAULT_OUTPUT_TAIL_LIMIT = 8192 + +/** + * Ring-buffered tail of a child's combined stdout+stderr, attached at SPAWN + * time — before the claim, before the READY wait — so an early crash's real + * stderr (traceback, missing module, bad config) survives into the ownership + * error and the before-ready exit messages instead of a bare exit code. + */ +export function createBackendOutputTail(limit: number = DEFAULT_OUTPUT_TAIL_LIMIT): BackendOutputTail { + let buffer = '' + + const append = (chunk: unknown) => { + buffer += String(chunk) + + if (buffer.length > limit) { + buffer = buffer.slice(buffer.length - limit) + } + } + + return { + append, + attach(child) { + child.stdout?.on('data', append) + child.stderr?.on('data', append) + }, + text() { + return buffer + }, + describe() { + const text = buffer.trim() + + return text ? `\nRecent backend output:\n${text}` : '' + } + } +} diff --git a/apps/desktop/electron/backend-health.test.ts b/apps/desktop/electron/backend-health.test.ts index a23ba5a73be3..71ff6924e79e 100644 --- a/apps/desktop/electron/backend-health.test.ts +++ b/apps/desktop/electron/backend-health.test.ts @@ -11,6 +11,7 @@ import { isReauthRequiredError, isServerSideHttpError, makeNousCloudBackendDownError, + makeUnsignedOauthError, waitForHermesReady } from './backend-health' @@ -219,6 +220,20 @@ test('a credentialed 401 fails fast for reauth instead of reporting a dead sessi assert.deepEqual(calls, [['probe', 'https://gateway.example/api/health']]) }) +test('unsigned OAuth is a terminal reauth failure; needsOauthLogin alone is not', () => { + // The unsigned-in throw must set isReauthRequired so startHermes latches. + // needsOauthLogin alone (ticket 401/403) stays a Sign-in hint, not a latch — + // a lapsed AT cookie can still rotate from a live RT on the next mint. + const unsigned = makeUnsignedOauthError() as any + + assert.equal(unsigned.needsOauthLogin, true) + assert.equal(unsigned.isReauthRequired, true) + assert.equal(isReauthRequiredError(unsigned), true) + assert.match(unsigned.message, /not signed in/i) + assert.equal(isReauthRequiredError({ needsOauthLogin: true }), false) + assert.equal(isReauthRequiredError(new Error('Could not reach the remote Hermes gateway')), false) +}) + test('a credentialed 403 is also a terminal reauth failure', async () => { await assert.rejects( waitForHermesReady('https://gateway.example', { diff --git a/apps/desktop/electron/backend-health.ts b/apps/desktop/electron/backend-health.ts index ff380f1390a6..5b08fecefb42 100644 --- a/apps/desktop/electron/backend-health.ts +++ b/apps/desktop/electron/backend-health.ts @@ -38,6 +38,10 @@ export interface HermesReadyOptions { export const REMOTE_SESSION_EXPIRED_MESSAGE = 'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.' +export const REMOTE_UNSIGNED_OAUTH_MESSAGE = + 'Remote Hermes gateway uses OAuth, but you are not signed in. ' + + 'Open Settings → Gateway and click "Sign in", or switch back to Local.' + /** * True for HTTP 502/503/504 from the backend — a server-side fault, not a * connectivity or auth issue. These keep polling in the readiness loop but, @@ -200,6 +204,19 @@ export function makeReauthRequiredError(detail?: string): Error { return error } +/** + * No native token and no live cookie: boot cannot self-heal. Must carry + * `isReauthRequired` so startHermes latches; `needsOauthLogin` alone only + * drives Sign in copy and would retry after #88070, hiding the overlay. + */ +export function makeUnsignedOauthError(): Error { + const error = new Error(REMOTE_UNSIGNED_OAUTH_MESSAGE) as any + error.needsOauthLogin = true + error.isReauthRequired = true + + return error +} + export function isReauthRequiredError(error: unknown): boolean { return Boolean((error as any)?.isReauthRequired) } diff --git a/apps/desktop/electron/backend-python-coherence.test.ts b/apps/desktop/electron/backend-python-coherence.test.ts new file mode 100644 index 000000000000..a40734ee2aa5 --- /dev/null +++ b/apps/desktop/electron/backend-python-coherence.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { test } from 'vitest' + +// ── Regression guard: backend interpreter / site-packages coherence ───────── +// +// Live repro (2026-08-23, macOS dev run): the checkout had BOTH venvs — +// .venv/ → Python 3.12 (dev tooling) +// venv/ → Python 3.11 (the CLI install venv, owns the real deps) +// +// findPythonForRoot() prefers `.venv/bin/python` (3.12), but +// createPythonBackend() hardcodes `venvRoot = path.join(root, 'venv')` and +// puts venv/lib/python3.11/site-packages on PYTHONPATH. The 3.12 interpreter +// then imports 3.11-compiled native wheels and dies on the FIRST import: +// ImportError: No module named 'pydantic_core._pydantic_core' +// → backend exits(1) before ready → "Gateway offline" → renderer falls back +// to a dead 127.0.0.1:9119 and every profile fails to activate. +// +// The invariant these tests pin down: the venv whose interpreter is selected +// and the venv whose site-packages go on PYTHONPATH must be THE SAME venv. +// One resolver must own that decision (AGENTS.md "observable ladder" rule 6). + +const here = path.dirname(fileURLToPath(import.meta.url)) +const mainTsSource = fs.readFileSync(path.join(here, 'main.ts'), 'utf8') + +function extractFunction(source: string, name: string): string { + const start = source.indexOf(`function ${name}(`) + assert.notEqual(start, -1, `function ${name} not found in main.ts`) + + // Slice to the next top-level `function ` declaration — crude but stable + // for the flat function layout main.ts uses. + const rest = source.slice(start) + const next = rest.slice(1).search(/\nfunction /) + + return next === -1 ? rest : rest.slice(0, next + 1) +} + +test('findPythonForRoot preference order includes .venv before venv (context for the coherence tests)', () => { + const fn = extractFunction(mainTsSource, 'findPythonForRoot') + const venvIdx = fn.indexOf("'.venv'") + const plainIdx = fn.indexOf("'venv'") + + assert.notEqual(venvIdx, -1, 'expected findPythonForRoot to probe .venv') + assert.notEqual(plainIdx, -1, 'expected findPythonForRoot to probe venv') + assert.ok( + venvIdx < plainIdx, + '.venv is probed before venv — this ordering is what the hardcoded venvRoot below disagrees with' + ) +}) + +// Fixed: createPythonBackend derives venvRoot from the selected interpreter +// via venvRootForPython(python, root), falling back to root/venv only for a +// system python. This test guards against re-hardcoding the venv path. +test('createPythonBackend derives venvRoot from the selected interpreter, not a hardcoded venv path', () => { + const fn = extractFunction(mainTsSource, 'createPythonBackend') + + // The buggy shape: interpreter picked by findPythonForRoot (may be .venv), + // while venvRoot/PYTHONPATH is unconditionally root/venv. + const hardcodesVenv = /venvRoot\s*=\s*path\.join\(root,\s*'venv'\)/.test(fn) + const derivesFromPython = /findPythonForRoot|python/.test(fn) && !hardcodesVenv + + assert.ok( + derivesFromPython, + 'createPythonBackend hardcodes venvRoot=path.join(root, "venv") while findPythonForRoot may select .venv/bin/python — ' + + 'a root with both venvs gets a 3.12 interpreter with 3.11 site-packages on PYTHONPATH and crashes on the first native import' + ) +}) + +// Pure-logic mirror of the same invariant, testable without main.ts exports: +// given a root where BOTH .venv and venv exist, whatever venv the interpreter +// came from must be the venv used for site-packages. This encodes the fix's +// contract so the implementation can be extracted against it later. +export function coherentVenvRootForPython(pythonPath: string, root: string): string | null { + // The venv root is the directory two levels up from /bin/python + // (Scripts/python.exe on Windows). A system interpreter (outside `root`) + // is NOT a venv — pairing it with any venv's site-packages needs the same + // version check, so it returns null here. + const posix = pythonPath.match(/^(.*)\/bin\/python[0-9.]*$/) + const win = pythonPath.match(/^(.*)[\\/]Scripts[\\/]python\.exe$/i) + const candidate = posix?.[1] ?? win?.[1] ?? null + + if (!candidate) { + return null + } + + const normalizedRoot = root.replace(/\\/g, '/').replace(/\/+$/, '') + const normalizedCandidate = candidate.replace(/\\/g, '/') + + return normalizedCandidate.startsWith(`${normalizedRoot}/`) ? candidate : null +} + +test('coherentVenvRootForPython maps a selected interpreter back to ITS venv root', () => { + assert.equal(coherentVenvRootForPython('/repo/.venv/bin/python', '/repo'), '/repo/.venv') + assert.equal(coherentVenvRootForPython('/repo/venv/bin/python', '/repo'), '/repo/venv') + assert.equal(coherentVenvRootForPython('C:\\repo\\venv\\Scripts\\python.exe', 'C:\\repo'), 'C:\\repo\\venv') + assert.equal(coherentVenvRootForPython('/usr/bin/python3', '/repo'), null) +}) + +test('dual-venv root: site-packages must come from the venv that owns the selected interpreter', () => { + // Simulates the live repro: interpreter resolved to .venv (3.12), so the + // ONLY coherent venvRoot for PYTHONPATH is .venv — never the sibling venv. + const selected = '/repo/.venv/bin/python' + const venvRoot = coherentVenvRootForPython(selected, '/repo') + + assert.equal(venvRoot, '/repo/.venv') + assert.notEqual( + venvRoot, + '/repo/venv', + 'pairing a .venv interpreter with venv/ site-packages is the crash from the live repro' + ) +}) diff --git a/apps/desktop/electron/backend-ready.test.ts b/apps/desktop/electron/backend-ready.test.ts index eb090686241b..b51987a773b8 100644 --- a/apps/desktop/electron/backend-ready.test.ts +++ b/apps/desktop/electron/backend-ready.test.ts @@ -216,3 +216,47 @@ test('waitForDashboardReadyFile rejects when the child exits before file readine tmp.cleanup() } }) + +// --------------------------------------------------------------------------- +// describeOutputTail (#93608): the child's real stderr reaches the exit error +// --------------------------------------------------------------------------- + +test('exit-before-announcement error carries the buffered output tail (stdout path)', async () => { + const child = makeFakeChild() + + const wait = waitForDashboardPortAnnouncement(child, { + describeOutputTail: () => '\nRecent backend output:\nModuleNotFoundError: hermes_cli' + }) + + child.emit('exit', 1, null) + + await assert.rejects(wait, /exited before port announcement \(1\)[\s\S]*ModuleNotFoundError: hermes_cli/) +}) + +test('exit-before-announcement error carries the buffered output tail (ready-file path)', async () => { + const child = makeFakeChild() + const readyFile = path.join(os.tmpdir(), `hermes-ready-${process.pid}-${Date.now()}.json`) + + const wait = waitForDashboardPortAnnouncement(child, { + describeOutputTail: () => '\nRecent backend output:\nTraceback (most recent call last)', + readyFile + }) + + child.emit('exit', null, 'SIGSEGV') + + await assert.rejects(wait, /exited before port announcement \(SIGSEGV\)[\s\S]*Traceback/) +}) + +test('exit-before-announcement error stays clean when no output was buffered', async () => { + const child = makeFakeChild() + + const wait = waitForDashboardPortAnnouncement(child, {}) + + child.emit('exit', 137, null) + + await assert.rejects(wait, error => { + assert.match((error as Error).message, /exited before port announcement \(137\)$/) + + return true + }) +}) diff --git a/apps/desktop/electron/backend-ready.ts b/apps/desktop/electron/backend-ready.ts index 05b9b6735164..d7644fc06ab5 100644 --- a/apps/desktop/electron/backend-ready.ts +++ b/apps/desktop/electron/backend-ready.ts @@ -51,7 +51,7 @@ function resolvePortAnnounceTimeoutMs(env = process.env) { * on every terminal path — resolve, reject, or timeout — so repeated * backend spawns don't leak listener slots on the child. */ -function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs()) { +function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs(), describeOutputTail = () => '') { return new Promise((resolve, reject) => { let buf = '' let done = false @@ -88,7 +88,7 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs()) function onExit(code, signal) { cleanup() - reject(new Error(`Hermes backend: exited before port announcement (${signal || code})`)) + reject(new Error(`Hermes backend: exited before port announcement (${signal || code})${describeOutputTail()}`)) } function onError(err) { @@ -122,7 +122,12 @@ function readDashboardReadyFile(readyFile: fs.PathOrFileDescriptor) { } } -function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnnounceTimeoutMs()) { +function waitForDashboardReadyFile( + readyFile, + child, + timeoutMs = resolvePortAnnounceTimeoutMs(), + describeOutputTail = () => '' +) { return new Promise((resolve, reject) => { let done = false let interval = null @@ -154,7 +159,7 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno function onExit(code, signal) { cleanup() - reject(new Error(`Hermes backend: exited before port announcement (${signal || code})`)) + reject(new Error(`Hermes backend: exited before port announcement (${signal || code})${describeOutputTail()}`)) } function onError(err) { @@ -182,17 +187,20 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno function waitForDashboardPortAnnouncement( child, options: { - readyFile?: fs.PathOrFileDescriptor + /** Returns a formatted stdout/stderr tail suffix for exit errors (#93608). */ + describeOutputTail?: () => string + readyFile?: fs.PathOrFileDescriptor | null timeoutMs?: number } = {} ) { const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs() + const describeOutputTail = options.describeOutputTail ?? (() => '') if (options.readyFile) { - return waitForDashboardReadyFile(options.readyFile, child, timeoutMs) + return waitForDashboardReadyFile(options.readyFile, child, timeoutMs, describeOutputTail) } - return waitForDashboardPort(child, timeoutMs) + return waitForDashboardPort(child, timeoutMs, describeOutputTail) } export { diff --git a/apps/desktop/electron/backend-start-failure.test.ts b/apps/desktop/electron/backend-start-failure.test.ts index f612171f4692..d92a2058bb5c 100644 --- a/apps/desktop/electron/backend-start-failure.test.ts +++ b/apps/desktop/electron/backend-start-failure.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' +import { isReauthRequiredError, makeUnsignedOauthError } from './backend-health' import { isHostKeyChangedBootFailure, isRetryableRemoteBootFailure, @@ -69,6 +70,19 @@ test('a CONFIRMED reauth rejection is never auto-retried (missing capability, no assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: true, isReauth: true }), false) }) +test('unsigned OAuth latches and is never auto-retried; needsOauthLogin alone still retries', () => { + // Production composition in startHermes: isReauth = isReauthRequiredError(error). + const unsigned = isReauthRequiredError(makeUnsignedOauthError()) + const ticketHint = isReauthRequiredError({ needsOauthLogin: true }) + + assert.equal(unsigned, true) + assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth: unsigned }), true) + assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: true, isReauth: unsigned }), false) + assert.equal(ticketHint, false) + assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth: ticketHint }), false) + assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: true, isReauth: ticketHint }), true) +}) + test('local failures are never auto-retried by the remote self-heal loop', () => { assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: false, isReauth: false }), false) assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: false, isReauth: true }), false) diff --git a/apps/desktop/electron/browser-windows.test.ts b/apps/desktop/electron/browser-windows.test.ts new file mode 100644 index 000000000000..00403d7314ed --- /dev/null +++ b/apps/desktop/electron/browser-windows.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { buildBrowserWindowUrl } from './browser-windows' + +test('buildBrowserWindowUrl puts win=browser before the hash (dev server)', () => { + const url = buildBrowserWindowUrl('url:browser-1', { devServer: 'http://localhost:5173' }) + + assert.equal(url, 'http://localhost:5173/?win=browser&tab=url%3Abrowser-1#/') + assert.ok(url.indexOf('?win=browser') < url.indexOf('#')) +}) + +test('buildBrowserWindowUrl encodes the tab id', () => { + const url = buildBrowserWindowUrl('url:browser a/b', { devServer: 'http://localhost:5173' }) + + assert.equal(url, 'http://localhost:5173/?win=browser&tab=url%3Abrowser%20a%2Fb#/') +}) + +test('buildBrowserWindowUrl avoids a double slash when the dev server has a trailing slash', () => { + const url = buildBrowserWindowUrl('t', { devServer: 'http://localhost:5173/' }) + + assert.equal(url, 'http://localhost:5173/?win=browser&tab=t#/') +}) + +test('buildBrowserWindowUrl omits a blank tab', () => { + assert.equal( + buildBrowserWindowUrl(' ', { devServer: 'http://localhost:5173' }), + 'http://localhost:5173/?win=browser#/' + ) + assert.equal( + buildBrowserWindowUrl(null, { devServer: 'http://localhost:5173' }), + 'http://localhost:5173/?win=browser#/' + ) +}) + +test('buildBrowserWindowUrl builds a packaged file URL with the flag before the hash', () => { + const url = buildBrowserWindowUrl('abc', { rendererIndexPath: '/opt/app/index.html' }) + + assert.match(url, /^file:\/\/.*index\.html\?win=browser&tab=abc#\/$/) +}) diff --git a/apps/desktop/electron/browser-windows.ts b/apps/desktop/electron/browser-windows.ts new file mode 100644 index 000000000000..526e5b177cf4 --- /dev/null +++ b/apps/desktop/electron/browser-windows.ts @@ -0,0 +1,31 @@ +// Popped-out in-app Browser windows. Same query-before-hash contract as +// session-windows / hud-url: `?win=browser` MUST sit in the search string +// before the '#', or HashRouter swallows it as part of the route. + +import { pathToFileURL } from 'node:url' + +export const BROWSER_WINDOW_WIDTH = 960 +export const BROWSER_WINDOW_HEIGHT = 720 +export const BROWSER_WINDOW_MIN_WIDTH = 480 +export const BROWSER_WINDOW_MIN_HEIGHT = 400 + +/** + * Renderer URL for a popped-out Browser. `tab` is the `$previewTabs` id the + * window should show — the tab stays in storage so closing the window can + * dock it again. Absent/blank tab is still a valid Browser window (blank page). + */ +export function buildBrowserWindowUrl( + tabId: null | string | undefined, + { devServer, rendererIndexPath }: { devServer?: null | string; rendererIndexPath?: string } = {} +): string { + const tab = typeof tabId === 'string' ? tabId.trim() : '' + const query = `?win=browser${tab ? `&tab=${encodeURIComponent(tab)}` : ''}` + + if (devServer) { + const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer + + return `${base}/${query}#/` + } + + return `${pathToFileURL(rendererIndexPath!).toString()}${query}#/` +} diff --git a/apps/desktop/electron/connection-config-apply.test.ts b/apps/desktop/electron/connection-config-apply.test.ts new file mode 100644 index 000000000000..f557caea4d4a --- /dev/null +++ b/apps/desktop/electron/connection-config-apply.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' + +import { applyConnectionConfigAtomically } from './connection-config-apply' + +describe('applyConnectionConfigAtomically', () => { + it('commits legacy and registry state before activation', async () => { + const events: string[] = [] + + await applyConnectionConfigAtomically({ + previousConfig: 'old-config', + previousRegistry: 'old-registry', + nextConfig: 'remote-config', + nextRegistry: 'remote-registry', + writeConfig: value => events.push(`config:${value}`), + writeRegistry: value => events.push(`registry:${value}`), + apply: async () => { + events.push('activate') + } + }) + + expect(events).toEqual(['config:remote-config', 'registry:remote-registry', 'activate']) + }) + + it('rolls both stores back when activation fails', async () => { + const writeConfig = vi.fn() + const writeRegistry = vi.fn() + + await expect( + applyConnectionConfigAtomically({ + previousConfig: 'local-config', + previousRegistry: 'local-registry', + nextConfig: 'remote-config', + nextRegistry: 'remote-registry', + writeConfig, + writeRegistry, + apply: async () => { + throw new Error('activation failed') + } + }) + ).rejects.toThrow('activation failed') + + expect(writeConfig.mock.calls).toEqual([['remote-config'], ['local-config']]) + expect(writeRegistry.mock.calls).toEqual([['remote-registry'], ['local-registry']]) + }) + + it('rolls legacy state back when the registry write fails', async () => { + const writes: string[] = [] + let registryWrites = 0 + + await expect( + applyConnectionConfigAtomically({ + previousConfig: 'local-config', + previousRegistry: 'local-registry', + nextConfig: 'remote-config', + nextRegistry: 'remote-registry', + writeConfig: value => writes.push(`config:${value}`), + writeRegistry: value => { + registryWrites += 1 + + if (registryWrites === 1) { + throw new Error('disk full') + } + + writes.push(`registry:${value}`) + }, + apply: vi.fn() + }) + ).rejects.toThrow('disk full') + + expect(writes).toEqual(['config:remote-config', 'config:local-config', 'registry:local-registry']) + }) + + it('preflights before writing either store', async () => { + const events: string[] = [] + + await applyConnectionConfigAtomically({ + previousConfig: 'local-config', + previousRegistry: 'local-registry', + nextConfig: 'remote-config', + nextRegistry: 'remote-registry', + preflight: async () => { + events.push('preflight') + }, + writeConfig: value => events.push(`config:${value}`), + writeRegistry: value => events.push(`registry:${value}`), + apply: async () => { + events.push('activate') + } + }) + + expect(events).toEqual(['preflight', 'config:remote-config', 'registry:remote-registry', 'activate']) + }) + + it('leaves both stores untouched when the preflight rejects', async () => { + const writeConfig = vi.fn() + const writeRegistry = vi.fn() + const apply = vi.fn() + + await expect( + applyConnectionConfigAtomically({ + previousConfig: 'local-config', + previousRegistry: 'local-registry', + nextConfig: 'remote-config', + nextRegistry: 'remote-registry', + preflight: async () => { + throw new Error('gateway unreachable') + }, + writeConfig, + writeRegistry, + apply + }) + ).rejects.toThrow('gateway unreachable') + + expect(writeConfig).not.toHaveBeenCalled() + expect(writeRegistry).not.toHaveBeenCalled() + expect(apply).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/electron/connection-config-apply.ts b/apps/desktop/electron/connection-config-apply.ts new file mode 100644 index 000000000000..1a19de4d0283 --- /dev/null +++ b/apps/desktop/electron/connection-config-apply.ts @@ -0,0 +1,53 @@ +interface ApplyConnectionConfigAtomicallyOptions { + apply: () => Promise + nextConfig: TConfig + nextRegistry: TRegistry + /** + * Optional reachability check (authenticated REST + a real WebSocket leg). + * Runs BEFORE either file is written, so a rejected OAuth session or a + * blocked /api/ws leaves the previous primary/current connection intact + * rather than committing a gateway the app cannot actually reach. + */ + preflight?: () => Promise + previousConfig: TConfig + previousRegistry: TRegistry + writeConfig: (config: TConfig) => void + writeRegistry: (registry: TRegistry) => void +} + +/** + * Commit the legacy config and v2 registry as one recoverable Apply boundary. + * File replacement itself is atomic per file; this wrapper restores both + * previous snapshots when the second write or synchronous re-home fails. + */ +export async function applyConnectionConfigAtomically({ + apply, + nextConfig, + nextRegistry, + preflight, + previousConfig, + previousRegistry, + writeConfig, + writeRegistry +}: ApplyConnectionConfigAtomicallyOptions): Promise { + // Outside the try: a preflight failure has written nothing, so there is + // nothing to roll back and no reason to touch either store. + await preflight?.() + + try { + writeConfig(nextConfig) + writeRegistry(nextRegistry) + await apply() + } catch (error) { + try { + writeConfig(previousConfig) + writeRegistry(previousRegistry) + } catch { + // Preserve the original activation/write failure. Both storage writers + // are atomic replacements, so a rollback failure cannot be repaired by + // retrying one side blindly here. + } + + throw error + } +} diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 36a042c6d3fe..9cc7cf66cfe2 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -35,6 +35,7 @@ import { normalizeRemoteHeaders, normalizeSshConfig, normAuthMode, + pathForRegistryBackendRequest, pathWithGlobalRemoteProfile, pathWithProfileScope, profileHasRemoteConnection, @@ -49,7 +50,8 @@ import { RT_COOKIE_VARIANTS, savedProfileSsh, tokenPreview, - translateSelfProfileQuery + translateSelfProfileQuery, + withTransientRetries } from './connection-config' // --- connectionScopeKey / normAuthMode --- @@ -483,6 +485,35 @@ test('pathWithProfileScope keeps an explicit profile query and no-ops on empty p assert.equal(pathWithProfileScope('/api/cron/jobs', null), '/api/cron/jobs') }) +test('pathForRegistryBackendRequest uses the resolved registry backend scope', () => { + assert.equal( + pathForRegistryBackendRequest('/api/fs/read-data-url?path=%2Fsrv%2Fimage.png', 'research', { + sharedRemote: true + }), + '/api/fs/read-data-url?path=%2Fsrv%2Fimage.png&profile=research' + ) + assert.equal( + pathForRegistryBackendRequest('/api/fs/download?path=%2Fsrv%2Freport.pdf&profile=mara', 'mara', { + remoteProfile: 'default' + }), + '/api/fs/download?path=%2Fsrv%2Freport.pdf&profile=default' + ) + assert.equal( + pathForRegistryBackendRequest('/api/fs/download?path=%2Fsrv%2Freport.pdf', 'mara', { + remoteProfile: 'default' + }), + '/api/fs/download?path=%2Fsrv%2Freport.pdf' + ) + assert.equal( + pathForRegistryBackendRequest( + '/api/profiles/sessions/sidebar?recents_profile=research&recents_exclude=cron%2Cdesktop', + 'research', + { remoteProfile: 'remote-research' } + ), + '/api/profiles/sessions/sidebar?recents_profile=remote-research&recents_exclude=cron%2Cdesktop' + ) +}) + // --- pathWithGlobalRemoteProfile --- test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => { @@ -581,8 +612,23 @@ test('translateSelfProfileQuery rewrites the self-profile filter into the backen ) }) +test('translateSelfProfileQuery rewrites sidebar recents_profile aliases for managed SSH', () => { + assert.equal( + translateSelfProfileQuery( + '/api/profiles/sessions/sidebar?recents_profile=research&recents_limit=20&cron_limit=50&messaging_limit=100', + 'research', + 'remote-research' + ), + '/api/profiles/sessions/sidebar?recents_profile=remote-research&recents_limit=20&cron_limit=50&messaging_limit=100' + ) +}) + test('translateSelfProfileQuery leaves cross-profile and unfiltered paths untouched', () => { assert.equal(translateSelfProfileQuery('/api/cron/jobs?profile=all', 'mara', 'default'), '/api/cron/jobs?profile=all') + assert.equal( + translateSelfProfileQuery('/api/profiles/sessions/sidebar?recents_profile=all', 'mara', 'default'), + '/api/profiles/sessions/sidebar?recents_profile=all' + ) assert.equal( translateSelfProfileQuery('/api/cron/jobs?profile=worker', 'mara', 'default'), '/api/cron/jobs?profile=worker' @@ -1134,6 +1180,54 @@ test('gateway ticket failures classify only explicit auth rejection statuses as assert.equal(serverFailure.needsOauthLogin, undefined) }) +test('withTransientRetries retries transport blips but not auth rejections', async () => { + const sleeps: number[] = [] + let transportAttempts = 0 + + const ticket = await withTransientRetries( + async () => { + transportAttempts += 1 + + if (transportAttempts < 3) { + throw Object.assign(new Error('500: unavailable'), { statusCode: 500 }) + } + + return 'tkt-ok' + }, + { + delaysMs: [10, 10], + sleep: async (ms: number) => { + sleeps.push(ms) + } + } + ) + + assert.equal(ticket, 'tkt-ok') + assert.equal(transportAttempts, 3) + assert.deepEqual(sleeps, [10, 10]) + + let authAttempts = 0 + await assert.rejects( + () => + withTransientRetries( + async () => { + authAttempts += 1 + throw Object.assign(new Error('401: rejected'), { statusCode: 401 }) + }, + { + delaysMs: [10], + sleep: async () => undefined + } + ), + (err: any) => { + assert.equal(err.statusCode, 401) + + return true + } + ) + assert.equal(authAttempts, 1) +}) + test('gateway WS URL IPC result serializes success and the auth-vs-transport matrix', async () => { assert.deepEqual(await gatewayWsUrlIpcResult(async () => 'wss://gateway.example.com/api/ws?ticket=fresh'), { ok: true, diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index 10cdd275b1fd..986758a598d9 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -151,6 +151,43 @@ function gatewayTicketFailure(error, authMessage, transportMessage) { return err } +/** + * Retry a one-shot mint/fetch that can flap on brief network blips. + * Auth rejections (401/403 / needsOauthLogin) fail immediately — retrying those + * just hammers a dead session. Transport/server failures retry with short delays. + */ +async function withTransientRetries(run, options: any = {}) { + const attempts = Number.isInteger(options.attempts) && options.attempts > 0 ? options.attempts : 3 + const delaysMs = Array.isArray(options.delaysMs) && options.delaysMs.length > 0 ? options.delaysMs : [250, 750] + + const sleep = + typeof options.sleep === 'function' + ? options.sleep + : (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + + const isRetryable = + typeof options.isRetryable === 'function' ? options.isRetryable : (error: unknown) => !isGatewayAuthRejection(error) + + let lastError: unknown + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return await run() + } catch (error) { + lastError = error + + if (!isRetryable(error) || attempt >= attempts - 1) { + throw error + } + + const delay = delaysMs[Math.min(attempt, delaysMs.length - 1)] + await sleep(delay) + } + } + + throw lastError +} + /** Serialize a fresh-WS-URL attempt across Electron's IPC boundary. */ async function gatewayWsUrlIpcResult(resolveWsUrl: () => Promise) { try { @@ -698,14 +735,23 @@ function pathWithGlobalRemoteProfile(path, profile, opts: ProfileRouteOptions = return pathWithProfileScope(path, profile) } +/** Extra profile-valued query keys, beyond `profile`, that name the same + * self-scope on a given path. The sidebar batches recents/cron/messaging + * behind `recents_profile` instead of `profile`, so an SSH alias rewrite + * that only looks at `?profile=` leaves those reads on the remote default. */ +const SELF_PROFILE_QUERY_KEYS_BY_PATH: Record = { + '/api/profiles/sessions/sidebar': ['recents_profile'] +} + /** * Translate an explicit self-profile query from a Desktop routing alias to the * backend's own profile namespace (a managed SSH `remoteProfile` can map local - * `mara` to remote `default`). Only a `?profile=` equal to the alias itself is - * rewritten; cross-profile selectors (`all`, another concrete profile) and - * unfiltered paths pass through untouched. Used by the v1 profile route above - * and by the registry SSH branch of the `hermes:api` handler — both routes - * reach a backend whose namespace is the remote profile, not the alias. + * `mara` to remote `default`). Only endpoint-declared profile-valued params + * equal to the alias itself are rewritten; cross-profile selectors (`all`, + * another concrete profile) and unfiltered paths pass through untouched. Used + * by the v1 profile route above and by the registry SSH branch of the + * `hermes:api` handler — both routes reach a backend whose namespace is the + * remote profile, not the alias. */ function translateSelfProfileQuery(path, profile, backendProfile) { const scopedProfile = connectionScopeKey(profile) @@ -729,11 +775,21 @@ function translateSelfProfileQuery(path, profile, backendProfile) { return path } - if (connectionScopeKey(parsed.searchParams.get('profile')) !== scopedProfile) { - return path + const profileQueryKeys = ['profile', ...(SELF_PROFILE_QUERY_KEYS_BY_PATH[parsed.pathname] || [])] + let changed = false + + for (const key of profileQueryKeys) { + if (connectionScopeKey(parsed.searchParams.get(key)) !== scopedProfile) { + continue + } + + parsed.searchParams.set(key, backend) + changed = true } - parsed.searchParams.set('profile', backend) + if (!changed) { + return path + } return `${parsed.pathname}${parsed.search}${parsed.hash}` } @@ -774,6 +830,23 @@ function pathWithProfileScope(path, profile) { return `${parsed.pathname}${parsed.search}${parsed.hash}` } +export interface RegistryBackendRequestScope { + remoteProfile?: null | string + sharedRemote?: boolean +} + +/** + * Scope a REST path for a resolved registry backend. Shared remotes serve + * multiple profiles from one process and need an explicit profile query; + * isolated SSH backends already own one profile but may translate a Desktop + * alias in an existing self-profile filter. + */ +function pathForRegistryBackendRequest(path, profile, backend: RegistryBackendRequestScope) { + return backend.sharedRemote + ? pathWithProfileScope(path, profile) + : translateSelfProfileQuery(path, profile, backend.remoteProfile) +} + /** * Registry connection a REST request is explicitly pinned to, or null for the * legacy profile-routed path. An explicit `local` id must stay registry-scoped: @@ -948,6 +1021,7 @@ export { normalizeRemoteHeaders, normalizeSshConfig, normAuthMode, + pathForRegistryBackendRequest, pathWithGlobalRemoteProfile, pathWithProfileScope, PRIVY_ACCESS_COOKIE_VARIANTS, @@ -964,5 +1038,6 @@ export { RT_COOKIE_VARIANTS, savedProfileSsh, tokenPreview, - translateSelfProfileQuery + translateSelfProfileQuery, + withTransientRetries } diff --git a/apps/desktop/electron/connection-registry.test.ts b/apps/desktop/electron/connection-registry.test.ts index d357597a0191..a5d7b6206fe8 100644 --- a/apps/desktop/electron/connection-registry.test.ts +++ b/apps/desktop/electron/connection-registry.test.ts @@ -25,6 +25,8 @@ import { normalizeConnectionInput, normalizeRegistry, parseRemoteProfileListing, + reconcileAppliedGlobalConnection, + reconcileRegistryDrift, REGISTRY_VERSION, rememberSshEnumeration, removeConnection, @@ -82,11 +84,18 @@ test('resolvedConnectionId identifies local and migrated remote descriptors', () resolvedConnectionId(registry, { baseUrl: 'http://127.0.0.1:49152', mode: 'remote', - remoteHost: 'root@work-host', + remoteHost: 'ROOT@WORK-HOST', remoteKind: 'ssh' }), work?.id ) + + const ambiguousLocal: ConnectionRegistry = { + ...registry, + connections: [...registry.connections, { id: 'local-copy', kind: 'local', label: 'Local copy' }] + } + + assert.equal(resolvedConnectionId(ambiguousLocal, { mode: 'local' }), null) }) test('resolvedConnectionId does not guess an unregistered remote', () => { @@ -107,6 +116,288 @@ test('agentHandle bare when unique, @name-device shape when duplicated', () => { assert.equal(agentHandle('', 'Homelab', false), 'default') }) +test('resolvedConnectionId accepts only a current exact descriptor id and never falls back', () => { + const registry: ConnectionRegistry = { + version: REGISTRY_VERSION, + primary: 'remote-a', + launchMode: 'primary', + lastUsed: 'remote-a', + connections: [ + { id: LOCAL_CONNECTION_ID, kind: 'local', label: 'This device' }, + { + id: 'remote-a', + kind: 'remote', + label: 'Remote A', + url: 'https://shared.example', + authMode: 'token', + token: { encoding: 'safeStorage', value: 'token-a' }, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } } + }, + { + id: 'remote-b', + kind: 'remote', + label: 'Remote B', + url: 'https://shared.example', + authMode: 'oauth', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-b' } } + } + ] + } + + assert.equal( + resolvedConnectionId(registry, { + authMode: 'token', + baseUrl: 'https://shared.example', + connectionId: 'remote-b', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } }, + mode: 'remote', + remoteKind: 'url', + token: { encoding: 'safeStorage', value: 'token-a' } + }), + 'remote-b' + ) + + const inferableRemoteA = { + authMode: 'token', + baseUrl: 'https://shared.example', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } }, + mode: 'remote' as const, + remoteKind: 'url' as const, + token: { encoding: 'safeStorage', value: 'token-a' } + } + + // Only true absence enters compatibility inference. Every explicitly + // present invalid value remains unresolved even though the remaining + // envelope uniquely identifies remote-a. + assert.equal(resolvedConnectionId(registry, inferableRemoteA), 'remote-a') + + for (const connectionId of ['', ' ', null, undefined, 42, {}, 'unknown-source', 'retired-source']) { + assert.equal(resolvedConnectionId(registry, { ...inferableRemoteA, connectionId }), null) + } + + // Registry order is never authority for either an exact current id or a + // rejected explicit claim. + const reordered = { ...registry, connections: [...registry.connections].reverse() } + + assert.equal( + resolvedConnectionId(reordered, { + ...inferableRemoteA, + connectionId: 'remote-b' + }), + 'remote-b' + ) + assert.equal(resolvedConnectionId(reordered, { ...inferableRemoteA, connectionId: 'retired-source' }), null) +}) + +test('resolvedConnectionId reuses the exact URL envelope and rejects weak or duplicate matches', () => { + const sharedUrl = 'https://shared.example/gateway' + + const registry: ConnectionRegistry = { + version: REGISTRY_VERSION, + primary: 'remote-token', + launchMode: 'primary', + lastUsed: 'remote-token', + connections: [ + { id: LOCAL_CONNECTION_ID, kind: 'local', label: 'This device' }, + { + id: 'remote-token', + kind: 'remote', + label: 'Token remote', + url: sharedUrl, + authMode: 'token', + token: { encoding: 'safeStorage', value: 'token-a' }, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } } + }, + { + id: 'remote-oauth', + kind: 'remote', + label: 'OAuth remote', + url: `${sharedUrl}/`, + authMode: 'oauth', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-b' } } + }, + { + id: 'cloud-nous', + kind: 'cloud', + label: 'Nous cloud', + url: sharedUrl, + authMode: 'oauth', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-cloud' } }, + org: 'nous' + }, + { + id: 'cloud-labs', + kind: 'cloud', + label: 'Labs cloud', + url: sharedUrl, + authMode: 'oauth', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-cloud' } }, + org: 'labs' + } + ] + } + + assert.equal( + resolvedConnectionId(registry, { + authMode: 'token', + baseUrl: sharedUrl, + headers: { 'cf-access-client-id': { encoding: 'safeStorage', value: 'header-a' } }, + mode: 'remote', + remoteKind: 'url', + token: { encoding: 'safeStorage', value: 'token-a' } + }), + 'remote-token' + ) + assert.equal( + resolvedConnectionId(registry, { + authMode: 'oauth', + baseUrl: sharedUrl, + headers: { 'CF-ACCESS-CLIENT-ID': { encoding: 'safeStorage', value: 'header-b' } }, + mode: 'remote', + remoteKind: 'url' + }), + 'remote-oauth' + ) + assert.equal( + resolvedConnectionId(registry, { + authMode: 'oauth', + baseUrl: sharedUrl, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-cloud' } }, + mode: 'remote', + org: 'nous', + remoteKind: 'cloud' + }), + 'cloud-nous' + ) + assert.equal( + resolvedConnectionId(registry, { + authMode: 'oauth', + baseUrl: sharedUrl, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-cloud' } }, + mode: 'remote', + org: 'labs', + remoteKind: 'cloud' + }), + 'cloud-labs' + ) + + // Post-dial URL-only shapes do not contain enough proof to choose a source. + assert.equal(resolvedConnectionId(registry, { baseUrl: sharedUrl, mode: 'remote', remoteKind: 'url' }), null) + assert.equal(resolvedConnectionId(registry, { baseUrl: sharedUrl, mode: 'remote', remoteKind: 'cloud' }), null) + + // Even a complete envelope fails closed when two registrations are exact twins. + const duplicate: ConnectionRegistry = { + ...registry, + connections: [ + ...registry.connections, + { ...registry.connections.find(connection => connection.id === 'remote-token')!, id: 'remote-token-copy' } + ] + } + + assert.equal( + resolvedConnectionId(duplicate, { + authMode: 'token', + baseUrl: sharedUrl, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } }, + mode: 'remote', + remoteKind: 'url', + token: { encoding: 'safeStorage', value: 'token-a' } + }), + null + ) + assert.equal( + resolvedConnectionId( + { ...duplicate, connections: [...duplicate.connections].reverse() }, + { + authMode: 'token', + baseUrl: sharedUrl, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } }, + mode: 'remote', + remoteKind: 'url', + token: { encoding: 'safeStorage', value: 'token-a' } + } + ), + null + ) +}) + +test('resolvedConnectionId keeps same-host SSH routes distinct by port, key, path, and profile', () => { + const base = { + host: 'work-host', + keyPath: '/keys/a', + kind: 'ssh' as const, + remoteHermesPath: '/srv/hermes', + remoteProfile: 'alpha', + user: 'root' + } + + const registry: ConnectionRegistry = { + version: REGISTRY_VERSION, + primary: 'ssh-base', + launchMode: 'primary', + lastUsed: 'ssh-base', + connections: [ + { id: LOCAL_CONNECTION_ID, kind: 'local', label: 'This device' }, + { ...base, id: 'ssh-base', label: 'SSH base' }, + { ...base, id: 'ssh-port', label: 'SSH port', port: 2222 }, + { ...base, id: 'ssh-key', keyPath: '/keys/b', label: 'SSH key' }, + { ...base, id: 'ssh-path', label: 'SSH path', remoteHermesPath: '/opt/hermes' }, + { ...base, id: 'ssh-profile', label: 'SSH profile', remoteProfile: 'beta' } + ] + } + + const resolve = (ssh: NonNullable[1]['ssh']>) => + resolvedConnectionId(registry, { mode: 'remote', remoteKind: 'ssh', ssh }) + + assert.equal(resolve(base), 'ssh-base') + assert.equal(resolve({ ...base, port: 2222 }), 'ssh-port') + assert.equal(resolve({ ...base, keyPath: '/keys/b' }), 'ssh-key') + assert.equal(resolve({ ...base, remoteHermesPath: '/opt/hermes' }), 'ssh-path') + assert.equal(resolve({ ...base, remoteProfile: 'beta' }), 'ssh-profile') + assert.equal( + resolvedConnectionId(registry, { + connectionId: 'ssh-port', + mode: 'remote', + remoteKind: 'ssh', + ssh: { ...base, port: 9999 } + }), + 'ssh-port' + ) + + // user@host is a transport hint, not a registry identity, when variants coexist. + assert.equal( + resolvedConnectionId(registry, { + mode: 'remote', + remoteHost: 'ROOT@WORK-HOST', + remoteKind: 'ssh' + }), + null + ) + assert.equal( + resolvedConnectionId(registry, { + mode: 'remote', + remoteHost: 'ROOT@WORK-HOST', + remoteKind: 'ssh', + ssh: undefined + }), + null + ) + + const duplicate: ConnectionRegistry = { + ...registry, + connections: [...registry.connections, { ...base, id: 'ssh-base-copy', label: 'SSH base copy' }] + } + + assert.equal(resolvedConnectionId(duplicate, { mode: 'remote', remoteKind: 'ssh', ssh: base }), null) + assert.equal( + resolvedConnectionId( + { ...duplicate, connections: [...duplicate.connections].reverse() }, + { mode: 'remote', remoteKind: 'ssh', ssh: base } + ), + null + ) +}) + test('connectionIdForLabel suffixes on collision and never mints "local"', () => { assert.equal(connectionIdForLabel('Homelab', []), 'homelab') assert.equal(connectionIdForLabel('Homelab', ['homelab']), 'homelab-2') @@ -929,6 +1220,198 @@ test('upsertConnection replaces by id and appends new ids', () => { assert.equal(registry.connections.find(c => c.id === a.id)?.url, 'http://a:2') }) +test('Apply remote inserts into an existing local-only registry and becomes primary/current', () => { + const registry = reconcileAppliedGlobalConnection(emptyRegistry(), { + mode: 'remote', + remote: { url: 'https://gateway.example.com/', authMode: 'oauth' } + }) + + const remote = registry.connections.find(connection => connection.kind === 'remote') + + assert.ok(remote) + assert.equal(registry.primary, remote.id) + assert.equal(registry.lastUsed, remote.id) + assert.equal( + resolvedConnectionId(registry, { + authMode: 'oauth', + baseUrl: 'https://gateway.example.com', + headers: {}, + mode: 'remote', + remoteKind: 'url' + }), + remote.id + ) +}) + +test('Apply remote preserves an existing URL identity and label without duplicates', () => { + let registry = emptyRegistry() + + registry = upsertConnection(registry, { + id: 'hermes-alex', + kind: 'remote', + label: 'Existing gateway', + url: 'https://gateway.example.com', + authMode: 'token', + token: { old: true } + }) + + const applied = reconcileAppliedGlobalConnection(registry, { + mode: 'remote', + remote: { url: 'https://GATEWAY.example.com/', authMode: 'oauth' } + }) + + const matches = applied.connections.filter(connection => connection.url === 'https://gateway.example.com') + + assert.equal(matches.length, 1) + assert.equal(matches[0].id, 'hermes-alex') + assert.equal(matches[0].label, 'Existing gateway') + assert.equal(matches[0].authMode, 'oauth') + assert.equal(applied.primary, 'hermes-alex') + assert.equal(applied.lastUsed, 'hermes-alex') +}) + +test('Apply local moves primary/current to This device without deleting registered remotes', () => { + const remoteRegistry = reconcileAppliedGlobalConnection(emptyRegistry(), { + mode: 'remote', + remote: { url: 'https://one.example.com', authMode: 'oauth' } + }) + + const localRegistry = reconcileAppliedGlobalConnection(remoteRegistry, { mode: 'local', remote: {} }) + + assert.equal(localRegistry.primary, LOCAL_CONNECTION_ID) + assert.equal(localRegistry.lastUsed, LOCAL_CONNECTION_ID) + assert.equal(localRegistry.connections.filter(connection => connection.kind === 'remote').length, 1) + assert.equal(resolvedConnectionId(localRegistry, { mode: 'local' }), LOCAL_CONNECTION_ID) +}) + +test('Apply between two remotes keeps each real registration once and activates the latest', () => { + const first = reconcileAppliedGlobalConnection(emptyRegistry(), { + mode: 'remote', + remote: { url: 'https://one.example.com', authMode: 'oauth' } + }) + + const second = reconcileAppliedGlobalConnection(first, { + mode: 'remote', + remote: { url: 'https://two.example.com/', authMode: 'oauth' } + }) + + const remotes = second.connections.filter(connection => connection.kind === 'remote') + + assert.deepEqual(remotes.map(connection => connection.url).sort(), [ + 'https://one.example.com', + 'https://two.example.com' + ]) + assert.equal(new Set(remotes.map(connection => connection.id)).size, 2) + assert.equal(second.primary, remotes.find(connection => connection.url === 'https://two.example.com')?.id) + assert.equal(second.lastUsed, second.primary) +}) + +// --- reconcileRegistryDrift (v1 ↔ v2 healing) --- + +test('drift heal registers a v1 remote the registry never learned about and makes it primary', () => { + // The exact shape users keep reporting: registry migrated while local-only, + // then Settings → Gateway pointed v1 at a remote. connections.json still + // says primary 'local', so every launch force-switches off the live remote. + const drifted = reconcileRegistryDrift(emptyRegistry(), { + mode: 'remote', + remote: { url: 'https://agent.example.com:4443', authMode: 'oauth' } + }) + + assert.equal(drifted.changed, true) + + const remote = drifted.registry.connections.find(connection => connection.kind === 'remote') + + assert.ok(remote) + assert.equal(drifted.registry.primary, remote.id) + assert.equal(drifted.registry.lastUsed, remote.id) + // The whole point: the live v1 descriptor can now be named, so the boot pick + // resolves to the remote instead of re-homing to 'local'. Descriptor shape + // matches what buildRemoteConnection emits for an oauth remote. + assert.equal( + resolvedConnectionId(drifted.registry, { + authMode: 'oauth', + baseUrl: 'https://agent.example.com:4443', + headers: {}, + mode: 'remote', + remoteKind: 'url' + }), + remote.id + ) +}) + +test('drift heal leaves a registry that already knows the v1 route untouched', () => { + const registered = reconcileAppliedGlobalConnection(emptyRegistry(), { + mode: 'remote', + remote: { url: 'https://agent.example.com', authMode: 'oauth' } + }) + + const drifted = reconcileRegistryDrift(registered, { + mode: 'remote', + remote: { url: 'https://AGENT.example.com/', authMode: 'oauth' } + }) + + assert.equal(drifted.changed, false) + assert.equal(drifted.registry, registered) +}) + +test('drift heal respects a deliberate primary pick on a registered route', () => { + // Route IS registered, but the user chose This device in the Connections + // panel. That is a choice, not drift — never override it. + let registry = reconcileAppliedGlobalConnection(emptyRegistry(), { + mode: 'remote', + remote: { url: 'https://agent.example.com', authMode: 'oauth' } + }) + + registry = setPrimaryConnection(registry, LOCAL_CONNECTION_ID) + + const drifted = reconcileRegistryDrift(registry, { + mode: 'remote', + remote: { url: 'https://agent.example.com', authMode: 'oauth' } + }) + + assert.equal(drifted.changed, false) + assert.equal(drifted.registry.primary, LOCAL_CONNECTION_ID) +}) + +test('drift heal ignores local, ssh, and unparseable v1 routes', () => { + const registry = emptyRegistry() + + for (const v1 of [ + { mode: 'local', remote: {} }, + { mode: 'ssh', remote: { host: 'box' } }, + { mode: 'remote', remote: { url: 'not a url' } }, + { mode: 'remote', remote: {} }, + null + ]) { + const drifted = reconcileRegistryDrift(registry, v1) + + assert.equal(drifted.changed, false, `expected no heal for ${JSON.stringify(v1)}`) + assert.equal(drifted.registry, registry) + } +}) + +test('drift heal adds the missing remote without disturbing other registered sources', () => { + let registry = emptyRegistry() + + registry = upsertConnection(registry, { + id: 'homelab', + kind: 'remote', + label: 'Homelab', + url: 'https://homelab.example.com', + authMode: 'token', + token: { keep: true } + }) + + const drifted = reconcileRegistryDrift(registry, { + mode: 'remote', + remote: { url: 'https://agent.example.com:4443', authMode: 'oauth' } + }) + + assert.equal(drifted.changed, true) + assert.equal(drifted.registry.connections.filter(connection => connection.kind === 'remote').length, 2) + assert.ok(drifted.registry.connections.some(connection => connection.id === 'homelab')) +}) + // --- connectionDialFieldsChanged (edit → recycle decision) --- test('connectionDialFieldsChanged: label-only edits do not recycle', () => { diff --git a/apps/desktop/electron/connection-registry.ts b/apps/desktop/electron/connection-registry.ts index cec23fea90c1..a98a175eacb2 100644 --- a/apps/desktop/electron/connection-registry.ts +++ b/apps/desktop/electron/connection-registry.ts @@ -35,6 +35,7 @@ import { normalizeSshConfig, normAuthMode } from './connection-config' +import { matchingConnectionId, type StoredRoute } from './connection-route-identity' export const REGISTRY_VERSION = 2 @@ -183,24 +184,61 @@ export interface RegistryLocalRoute { poolKey: string } +export interface ResolvedConnectionSshDescriptor { + host?: string + keyPath?: string + port?: number + remoteHermesPath?: string + remoteProfile?: string + user?: string +} + export interface ResolvedConnectionDescriptor { + authMode?: unknown baseUrl?: string + /** Property presence means this descriptor claims registry qualification. + * Invalid or retired claims fail closed; only descriptors with no such + * property may enter the legacy compatibility resolver. */ + connectionId?: unknown + headers?: Record mode?: 'local' | 'remote' + org?: unknown remoteHost?: string remoteKind?: 'cloud' | 'ssh' | 'url' + ssh?: ResolvedConnectionSshDescriptor + token?: unknown } /** * Recover registry identity for a descriptor resolved through the legacy v1 - * profile path. Registry-scoped routes already carry `connectionId`; this - * bridge keeps migrated per-profile remotes truthful until v1 is retired. + * profile path. Registry-scoped routes already carry `connectionId`; that + * exact identity is authoritative only while it names a current registry + * entry. Only genuinely unqualified descriptors may use compatibility + * inference, which keeps migrated per-profile remotes truthful until v1 is + * retired without letting malformed qualification fall through to a weaker + * endpoint-shaped identity. */ export function resolvedConnectionId( registry: ConnectionRegistry, descriptor: ResolvedConnectionDescriptor ): null | string { + if (Object.prototype.hasOwnProperty.call(descriptor, 'connectionId')) { + const explicitConnectionId = descriptor.connectionId + + // Presence is authoritative even when the value is unusable. Never turn + // a malformed, blank, unknown, or retired registry claim into permission + // to infer a different source from mutable endpoint metadata. + if (typeof explicitConnectionId !== 'string' || !explicitConnectionId.trim()) { + return null + } + + return registry.connections.some(connection => connection.id === explicitConnectionId) ? explicitConnectionId : null + } + if (descriptor.mode === 'local') { - return registry.connections.find(connection => connection.kind === 'local')?.id ?? null + const localConnections = registry.connections.filter(connection => connection.kind === 'local') + + return localConnections.length === 1 ? localConnections[0].id : null } if (descriptor.mode !== 'remote') { @@ -208,52 +246,109 @@ export function resolvedConnectionId( } if (descriptor.remoteKind === 'ssh') { - const remoteHost = String(descriptor.remoteHost || '') - .trim() - .toLowerCase() + if (Object.prototype.hasOwnProperty.call(descriptor, 'ssh')) { + if (!descriptor.ssh || typeof descriptor.ssh !== 'object') { + return null + } - if (!remoteHost) { - return null + return matchingConnectionId(registry, { ...descriptor.ssh, kind: 'ssh' }, 'unique') ?? null } - return ( - registry.connections.find(connection => { - if (connection.kind !== 'ssh') { - return false - } + // Old descriptors expose only user@host after the tunnel has discarded + // port/key/path/profile. That weak shape is compatible only when exactly + // one registered SSH source even shares the target, and the defaulted + // route still satisfies the canonical #88922 full-envelope matcher. + const ssh = normalizeSshConfig({ mode: 'ssh', host: descriptor.remoteHost }) - const host = String(connection.host || '') - .trim() - .toLowerCase() + if (!ssh) { + return null + } - const target = connection.user ? `${String(connection.user).trim().toLowerCase()}@${host}` : host + const target = normalizedSshTarget(ssh) - return target === remoteHost - })?.id ?? null + const coarseMatches = registry.connections.filter( + connection => connection.kind === 'ssh' && normalizedSshTarget(connection) === target ) + + if (!target || coarseMatches.length !== 1) { + return null + } + + return matchingConnectionId(registry, { kind: 'ssh', ...ssh }, 'unique') ?? null } - let baseUrl = '' + const kind = descriptor.remoteKind === 'cloud' ? 'cloud' : descriptor.remoteKind === 'url' ? 'remote' : null + + if (!kind) { + return null + } + + let url = '' try { - baseUrl = normalizeRemoteBaseUrl(descriptor.baseUrl) + url = normalizeRemoteBaseUrl(descriptor.baseUrl) } catch { return null } - return ( - registry.connections.find(connection => { - if (connection.kind !== 'cloud' && connection.kind !== 'remote') { + const authMode = normAuthMode(descriptor.authMode) + + const route: StoredRoute = { + authMode, + headers: descriptor.headers, + kind, + org: descriptor.org, + token: descriptor.token, + url + } + + const hasExactEnvelope = + Object.prototype.hasOwnProperty.call(descriptor, 'authMode') && + Object.prototype.hasOwnProperty.call(descriptor, 'headers') && + (authMode === 'oauth' || Object.prototype.hasOwnProperty.call(descriptor, 'token')) && + (kind === 'remote' || Object.prototype.hasOwnProperty.call(descriptor, 'org')) + + if (!hasExactEnvelope) { + // A URL alone cannot choose among legal registrations that differ by auth, + // headers, Cloud organization, or account. Require one coarse candidate + // before the same full-envelope matcher is allowed to accept the legacy + // defaults; otherwise zero/multiple candidates fail closed. + const coarseMatches = registry.connections.filter(connection => { + if (connection.kind !== kind) { return false } try { - return normalizeRemoteBaseUrl(connection.url) === baseUrl + return normalizeRemoteBaseUrl(connection.url) === url } catch { return false } - })?.id ?? null - ) + }) + + if (coarseMatches.length !== 1) { + return null + } + } + + return matchingConnectionId(registry, route, 'unique') ?? null +} + +function normalizedSshTarget(route: { host?: unknown; port?: unknown; user?: unknown }): null | string { + const ssh = normalizeSshConfig({ ...route, mode: 'ssh' }) + + if (!ssh) { + return null + } + + const host = String(ssh.host || '') + .trim() + .toLowerCase() + + const user = String(ssh.user || '') + .trim() + .toLowerCase() + + return user ? `${user}@${host}` : host } /** @@ -333,6 +428,8 @@ export interface RosterAgent { connectionKind: ConnectionKind connectionLabel: string profile: string + /** Backend profile when the registry route maps the Desktop profile name. */ + targetProfile?: string /** Bare profile name, or `-` when the profile name * exists on more than one registered source (the @name-device rule). */ handle: string @@ -486,6 +583,7 @@ export function buildAgentRoster( connectionKind: connection.kind, connectionLabel: connection.label, profile, + targetProfile: connection.remoteProfile || profile, handle: agentHandle(profile, connection.label, (counts.get(profile) || 0) > 1) }) } @@ -1109,6 +1207,142 @@ export function setLastUsedConnection(registry: ConnectionRegistry, id: string): return { ...registry, lastUsed: id } } +/** + * Reconcile a successfully-coerced global v1 connection config into the v2 + * registry. Settings still writes connection.json for compatibility, but an + * Apply must publish the same primary identity to connections.json in the + * same transaction or the live remote descriptor has no connectionId. + * + * Remote-shaped entries are matched by normalized URL across remote/cloud so + * changing provenance never duplicates a gateway. Existing identity and + * user-chosen label win; a new entry derives both from the host. Switching to + * local keeps registered remotes available while moving primary/last-used + * back to This device. + */ +export function reconcileAppliedGlobalConnection( + registry: ConnectionRegistry, + config: Record +): ConnectionRegistry { + const mode = config?.mode + + if (!modeIsRemoteLike(mode)) { + if (mode === 'local') { + return { ...registry, primary: LOCAL_CONNECTION_ID, lastUsed: LOCAL_CONNECTION_ID } + } + + // SSH registry identity is managed by its existing registry editor and + // migration path. Do not reinterpret or delete it here. + return registry + } + + const block = config.remote && typeof config.remote === 'object' ? config.remote : {} + const url = normalizeRemoteBaseUrl(block.url) + + const existing = registry.connections.find(connection => { + if (connection.kind !== 'remote' && connection.kind !== 'cloud') { + return false + } + + try { + return normalizeRemoteBaseUrl(connection.url) === url + } catch { + return false + } + }) + + const kind: ConnectionKind = mode === 'cloud' ? 'cloud' : 'remote' + + const label = + existing?.label || + uniqueLabel( + hostLabelFromBaseUrl(url) || (kind === 'cloud' ? 'Hermes Cloud' : 'Remote gateway'), + registry.connections.map(connection => connection.label) + ) + + const entry = normalizeConnectionInput( + { + id: existing?.id, + kind, + label, + url, + authMode: block.authMode, + token: block.token, + headers: block.headers, + org: block.org + }, + registry + ) + + return { + ...upsertConnection(registry, entry), + primary: entry.id, + lastUsed: entry.id + } +} + +/** + * Heal an already-created registry that never learned about the v1 route it is + * supposed to be serving. + * + * `migrateV1ToRegistry` runs exactly once — only when connections.json does not + * exist. A user who was local at that moment and configured a remote gateway + * afterwards (Settings -> Gateway writes connection.json alone) ends up with a + * live remote that the registry cannot name: `resolvedConnectionId` returns + * null, `primary` still says `local`, and every launch force-switches the + * window onto a fresh local backend seconds after boot. Deleting + * connections.json by hand is the only recovery today. + * + * Deliberately narrow: heal ONLY when the v1 global route has no matching + * registry entry at all. That is the drift state and nothing else. If the + * route is already registered but `primary` names another source, the user + * chose that in the Connections panel and we leave it alone. + */ +export function reconcileRegistryDrift( + registry: ConnectionRegistry, + v1: unknown +): { changed: boolean; registry: ConnectionRegistry } { + const config = v1 && typeof v1 === 'object' ? (v1 as Record) : {} + const unchanged = { changed: false, registry } + + if (!modeIsRemoteLike(config.mode)) { + return unchanged + } + + const block = config.remote && typeof config.remote === 'object' ? config.remote : {} + + let url = '' + + try { + url = normalizeRemoteBaseUrl(block.url) + } catch { + // An unparseable v1 URL is not a route we can register. The v1 path keeps + // failing the way it already does; do not corrupt the registry over it. + return unchanged + } + + if (!url) { + return unchanged + } + + const alreadyRegistered = registry.connections.some(connection => { + if (connection.kind !== 'remote' && connection.kind !== 'cloud') { + return false + } + + try { + return normalizeRemoteBaseUrl(connection.url) === url + } catch { + return false + } + }) + + if (alreadyRegistered) { + return unchanged + } + + return { changed: true, registry: reconcileAppliedGlobalConnection(registry, config) } +} + /** Choose whether launch restores the explicit primary or the last-used source. */ export function setConnectionLaunchMode(registry: ConnectionRegistry, launchMode: string): ConnectionRegistry { if (launchMode !== 'last-used' && launchMode !== 'primary') { diff --git a/apps/desktop/electron/connection-route-identity.ts b/apps/desktop/electron/connection-route-identity.ts new file mode 100644 index 000000000000..72124fe3e2a0 --- /dev/null +++ b/apps/desktop/electron/connection-route-identity.ts @@ -0,0 +1,131 @@ +/** + * Canonical registry route identity for Desktop. + * + * Identity is frozen before dialing, while the complete auth/transport + * envelope still exists. Compatibility callers may reuse the same contract, + * but must never reconstruct a stronger identity from post-dial metadata. + */ + +import { normalizeRemoteBaseUrl, normalizeRemoteHeaders, normalizeSshConfig, normAuthMode } from './connection-config' +import type { ConnectionRegistry, RegistryConnection } from './connection-registry' + +interface SshRouteConfig { + host: string + keyPath?: string + mode: 'ssh' + port?: number + remoteHermesPath?: string + remoteProfile?: string + user?: string +} + +export type StoredRoute = + | { + authMode?: unknown + headers?: Record + kind: 'cloud' | 'remote' + org?: unknown + token?: unknown + url?: unknown + } + | ({ kind: 'ssh' } & Partial) + +function stableValue(value: unknown): string { + if (!value || typeof value !== 'object') { + return JSON.stringify(value ?? null) + } + + if (Array.isArray(value)) { + return `[${value.map(stableValue).join(',')}]` + } + + return `{${Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableValue(item)}`) + .join(',')}}` +} + +function canonicalHeaders(headers: unknown): Record { + return Object.fromEntries( + Object.entries(normalizeRemoteHeaders(headers)) + .map(([name, value]): [string, unknown] => [name.toLowerCase(), value]) + .sort(([left], [right]) => left.localeCompare(right)) + ) +} + +function routeIdentity(route: StoredRoute): null | string { + if (route.kind === 'ssh') { + const ssh = normalizeSshConfig({ ...route, mode: 'ssh' }) + + if (!ssh) { + return null + } + + return stableValue({ + host: ssh.host.trim().toLowerCase(), + keyPath: ssh.keyPath || '', + kind: 'ssh', + port: ssh.port || 22, + remoteHermesPath: ssh.remoteHermesPath || '', + remoteProfile: ssh.remoteProfile || '', + user: (ssh.user || '').trim().toLowerCase() + }) + } + + try { + const authMode = normAuthMode(route.authMode) + + return stableValue({ + authMode, + headers: canonicalHeaders(route.headers), + kind: route.kind, + org: route.kind === 'cloud' ? String(route.org || '').trim() : '', + token: authMode === 'token' ? (route.token ?? null) : null, + url: normalizeRemoteBaseUrl(route.url) + }) + } catch { + return null + } +} + +function registryRoute(connection: RegistryConnection): null | StoredRoute { + if (connection.kind === 'local') { + return null + } + + return connection as StoredRoute +} + +/** + * Match the complete pre-dial route identity used by #88922. + * + * `primary` accepts only the configured primary when its full envelope is + * equal. `unique` accepts exactly one full-envelope match. Zero and multiple + * matches deliberately remain unresolved. + */ +export function matchingConnectionId( + registry: ConnectionRegistry, + route: StoredRoute, + strategy: 'primary' | 'unique' +): undefined | string { + const identity = routeIdentity(route) + + if (!identity) { + return undefined + } + + if (strategy === 'primary') { + const primary = registry.connections.find(connection => connection.id === registry.primary) + const candidate = primary && registryRoute(primary) + + return candidate && routeIdentity(candidate) === identity ? primary.id : undefined + } + + const matches = registry.connections.filter(connection => { + const candidate = registryRoute(connection) + + return candidate ? routeIdentity(candidate) === identity : false + }) + + return matches.length === 1 ? matches[0].id : undefined +} diff --git a/apps/desktop/electron/desktop-remote-route.ts b/apps/desktop/electron/desktop-remote-route.ts index 16f44d05ef26..c7c8a5cbc687 100644 --- a/apps/desktop/electron/desktop-remote-route.ts +++ b/apps/desktop/electron/desktop-remote-route.ts @@ -1,20 +1,13 @@ -/** - * Pure route planner for Desktop's legacy v1 connection config. It preserves - * v2 registry provenance before URL normalization, OAuth, or SSH tunnelling - * discard dial details. Environment routes deliberately remain unregistered. - */ - import { connectionScopeKey, modeIsRemoteLike, - normalizeRemoteBaseUrl, - normalizeRemoteHeaders, normalizeSshConfig, normAuthMode, profileRemoteOverride, profileSshOverride } from './connection-config' -import type { ConnectionRegistry, RegistryConnection } from './connection-registry' +import type { ConnectionRegistry } from './connection-registry' +import { matchingConnectionId, type StoredRoute } from './connection-route-identity' type RouteSource = 'env' | 'profile' | 'settings' @@ -54,110 +47,6 @@ export interface DesktopRemoteRouteInput { registry: ConnectionRegistry } -type StoredRoute = - | { - authMode?: unknown - headers?: Record - kind: 'cloud' | 'remote' - org?: unknown - token?: unknown - url?: unknown - } - | ({ kind: 'ssh' } & Partial) - -function stableValue(value: unknown): string { - if (!value || typeof value !== 'object') { - return JSON.stringify(value ?? null) - } - - if (Array.isArray(value)) { - return `[${value.map(stableValue).join(',')}]` - } - - return `{${Object.entries(value) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, item]) => `${JSON.stringify(key)}:${stableValue(item)}`) - .join(',')}}` -} - -function canonicalHeaders(headers: unknown): Record { - return Object.fromEntries( - Object.entries(normalizeRemoteHeaders(headers)) - .map(([name, value]): [string, unknown] => [name.toLowerCase(), value]) - .sort(([left], [right]) => left.localeCompare(right)) - ) -} - -function routeIdentity(route: StoredRoute): null | string { - if (route.kind === 'ssh') { - const ssh = normalizeSshConfig({ ...route, mode: 'ssh' }) - - if (!ssh) { - return null - } - - return stableValue({ - host: ssh.host, - keyPath: ssh.keyPath || '', - kind: 'ssh', - port: ssh.port || 22, - remoteHermesPath: ssh.remoteHermesPath || '', - remoteProfile: ssh.remoteProfile || '', - user: ssh.user || '' - }) - } - - try { - const authMode = normAuthMode(route.authMode) - - return stableValue({ - authMode, - headers: canonicalHeaders(route.headers), - kind: route.kind, - org: route.kind === 'cloud' ? String(route.org || '').trim() : '', - token: authMode === 'token' ? (route.token ?? null) : null, - url: normalizeRemoteBaseUrl(route.url) - }) - } catch { - return null - } -} - -function registryRoute(connection: RegistryConnection): null | StoredRoute { - if (connection.kind === 'local') { - return null - } - - return connection as StoredRoute -} - -function matchingConnectionId( - registry: ConnectionRegistry, - route: StoredRoute, - strategy: 'primary' | 'unique' -): undefined | string { - const identity = routeIdentity(route) - - if (!identity) { - return undefined - } - - if (strategy === 'primary') { - const primary = registry.connections.find(connection => connection.id === registry.primary) - const candidate = primary && registryRoute(primary) - - return candidate && routeIdentity(candidate) === identity ? primary.id : undefined - } - - const matches = registry.connections.filter(connection => { - const candidate = registryRoute(connection) - - return candidate ? routeIdentity(candidate) === identity : false - }) - - return matches.length === 1 ? matches[0].id : undefined -} - function withConnectionId(route: T, connectionId?: string): T & { connectionId?: string } { return connectionId ? { ...route, connectionId } : route } diff --git a/apps/desktop/electron/gateway-file-download-transport.test.ts b/apps/desktop/electron/gateway-file-download-transport.test.ts index 8ade7ec2a6b9..631d3aee21ce 100644 --- a/apps/desktop/electron/gateway-file-download-transport.test.ts +++ b/apps/desktop/electron/gateway-file-download-transport.test.ts @@ -54,18 +54,3 @@ test('finalizeGatewayDownload prompts a save dialog then streams the response', // HTTP errors carry their status so a 404 can trigger the fallback. assert.match(fn, /error\.statusCode = statusCode/) }) - -test('saveGatewayFile falls back to the data-url route only on 404', () => { - const fn = extract('async function saveGatewayFile', '\nasync function saveGatewayFileViaDataUrl') - - assert.match(fn, /\/api\/fs\/download\?path=/) - assert.match(fn, /isNotFoundError\(error\)/) - assert.match(fn, /saveGatewayFileViaDataUrl\(/) -}) - -test('data-url fallback reads the capped route and decodes it', () => { - const fn = extract('async function saveGatewayFileViaDataUrl', '// Mint a single-use WS ticket') - - assert.match(fn, /\/api\/fs\/read-data-url\?path=/) - assert.match(fn, /parseDataUrlToBuffer\(/) -}) diff --git a/apps/desktop/electron/gateway-file-download.test.ts b/apps/desktop/electron/gateway-file-download.test.ts index a9d054b1fa6c..fcadd46e8be4 100644 --- a/apps/desktop/electron/gateway-file-download.test.ts +++ b/apps/desktop/electron/gateway-file-download.test.ts @@ -3,12 +3,15 @@ import { EventEmitter } from 'node:events' import { test } from 'vitest' +import { pathForRegistryBackendRequest } from './connection-config' import { filenameFromContentDisposition, gatewayFilePath, + gatewayFileRequestPaths, isNotFoundError, parseDataUrlToBuffer, - pumpStreamToFile + pumpStreamToFile, + resolveGatewayFileBackend } from './gateway-file-download' // A Readable-like response driven manually in tests. @@ -175,6 +178,17 @@ test('gatewayFilePath normalizes bare paths and file:// URLs', () => { assert.equal(gatewayFilePath(null), '') }) +test('gatewayFileRequestPaths keeps streaming and fallback requests on the same registered backend', () => { + const paths = gatewayFileRequestPaths('/srv/output/image one.png', requestPath => + pathForRegistryBackendRequest(requestPath, 'research', { sharedRemote: true }) + ) + + assert.deepEqual(paths, { + dataUrl: '/api/fs/read-data-url?path=%2Fsrv%2Foutput%2Fimage+one.png&profile=research', + download: '/api/fs/download?path=%2Fsrv%2Foutput%2Fimage+one.png&profile=research' + }) +}) + test('isNotFoundError matches only HTTP 404', () => { const notFound: any = new Error('404: missing') @@ -188,3 +202,55 @@ test('isNotFoundError matches only HTTP 404', () => { assert.equal(isNotFoundError(new Error('plain')), false) assert.equal(isNotFoundError(null), false) }) + +test('resolveGatewayFileBackend pins registered files to their owning connection', async () => { + const calls: string[] = [] + + const route = await resolveGatewayFileBackend( + { connectionId: ' work-ssh ', profile: ' default ' }, + { + ensureLegacy: async profile => { + calls.push(`legacy:${profile}`) + + return { baseUrl: 'http://local.invalid' } + }, + ensureRegistry: async (connectionId, profile) => { + calls.push(`registry:${connectionId}:${profile}`) + + return { baseUrl: 'http://ssh.invalid' } + } + } + ) + + assert.deepEqual(calls, ['registry:work-ssh:default']) + assert.deepEqual(route, { + connection: { baseUrl: 'http://ssh.invalid' }, + connectionId: 'work-ssh', + profile: 'default' + }) +}) + +test('resolveGatewayFileBackend preserves the legacy route when no connection owns the file', async () => { + const calls: string[] = [] + + const route = await resolveGatewayFileBackend( + { profile: 'coder' }, + { + ensureLegacy: async profile => { + calls.push(`legacy:${profile}`) + + return { baseUrl: 'http://local.invalid' } + }, + ensureRegistry: async connectionId => { + calls.push(`registry:${connectionId}`) + + return { baseUrl: 'http://remote.invalid' } + } + } + ) + + assert.deepEqual(calls, ['legacy:coder']) + assert.equal(route.connectionId, null) + assert.equal(route.profile, 'coder') + assert.deepEqual(route.connection, { baseUrl: 'http://local.invalid' }) +}) diff --git a/apps/desktop/electron/gateway-file-download.ts b/apps/desktop/electron/gateway-file-download.ts index acdca9723c27..40fa98ced335 100644 --- a/apps/desktop/electron/gateway-file-download.ts +++ b/apps/desktop/electron/gateway-file-download.ts @@ -35,6 +35,50 @@ export interface PumpDeps { unlink: (destPath: string) => Promise } +export interface GatewayFileBackendDeps { + ensureLegacy: (profile: null | string) => Promise + ensureRegistry: (connectionId: string, profile: null | string) => Promise +} + +export interface GatewayFileBackendRoute { + connection: T + connectionId: null | string + profile: null | string +} +export interface GatewayFileRequestPaths { + dataUrl: string + download: string +} + +export function gatewayFileRequestPaths( + filePath: string, + scopePath: (requestPath: string) => string +): GatewayFileRequestPaths { + const encodedPath = encodeURIComponent(filePath) + + return { + dataUrl: scopePath(`/api/fs/read-data-url?path=${encodedPath}`), + download: scopePath(`/api/fs/download?path=${encodedPath}`) + } +} + +/** + * Resolve the backend that owns a renderer-requested gateway file. Registered + * connections must never fall through to the legacy profile pool: that pool + * can point at another machine with another authentication credential. + */ +export async function resolveGatewayFileBackend( + payload: { connectionId?: unknown; profile?: unknown }, + deps: GatewayFileBackendDeps +): Promise> { + const connectionId = String(payload.connectionId ?? '').trim() || null + const profile = String(payload.profile ?? '').trim() || null + + const connection = connectionId ? await deps.ensureRegistry(connectionId, profile) : await deps.ensureLegacy(profile) + + return { connection, connectionId, profile } +} + // Stream `res` into `destPath`, honoring backpressure. On any read/write error // the write stream is torn down and the (partial) destination file is removed // before the returned promise rejects, so a failed download never leaves a diff --git a/apps/desktop/electron/hardening.test.ts b/apps/desktop/electron/hardening.test.ts index 04e9c4a6213c..e6ffdf51752c 100644 --- a/apps/desktop/electron/hardening.test.ts +++ b/apps/desktop/electron/hardening.test.ts @@ -974,7 +974,7 @@ test('connection-config save and apply IPC handlers route payloads through coerc const handlerBody = source.slice(handlerStart, handlerStart + 400) assert.match( handlerBody, - /coerceDesktopConnectionConfig\(payload\)/, + /coerceDesktopConnectionConfig\(payload(?:, previousConfig)?\)/, `${channel} must coerce its payload (the propagation seam) before persisting` ) } diff --git a/apps/desktop/electron/hardening.ts b/apps/desktop/electron/hardening.ts index f8c77f31868e..4ea4bc125e2e 100644 --- a/apps/desktop/electron/hardening.ts +++ b/apps/desktop/electron/hardening.ts @@ -13,7 +13,7 @@ import { DATA_URL_READ_MIN_MAX_MB } from '../../shared/src/data-url-read-max' -const DEFAULT_FETCH_TIMEOUT_MS = 15_000 +const DEFAULT_FETCH_TIMEOUT_MS = 30_000 // Remote file.attach sends one base64 JSON-RPC frame. Cap the dedicated attach // reader so the payload still fits uvicorn's raised ws_max_size (384 MiB) // after base64 + framing. Preview stays on the Settings-configurable path. diff --git a/apps/desktop/electron/hud-game-overlay.test.ts b/apps/desktop/electron/hud-game-overlay.test.ts new file mode 100644 index 000000000000..262c99e23afb --- /dev/null +++ b/apps/desktop/electron/hud-game-overlay.test.ts @@ -0,0 +1,307 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { + coversDisplay, + detectFullscreenApp, + findFullscreenAppAnywhere, + gameOverlayStateFor, + INACTIVE_GAME_OVERLAY, + isShellWindow, + startHudGameOverlayWatch +} from './hud-game-overlay' +import type { GameOverlayState } from './hud-game-overlay' +import type { EnumeratedWindow } from './window-below' + +const DISPLAY = { x: 0, y: 0, width: 2560, height: 1440 } +const SELF_PID = 4242 + +const win = (over: Partial): EnumeratedWindow => ({ + app: 'App', + bounds: { x: 0, y: 0, width: 100, height: 100 }, + id: 1, + pid: 1000, + title: '', + ...over +}) + +const fullscreenGame = win({ app: 'Balatro', bounds: { ...DISPLAY }, id: 7, pid: 2000 }) +const hudWindow = win({ app: 'Hermes', bounds: { x: 970, y: 1048, width: 620, height: 320 }, id: 2, pid: SELF_PID }) +const desktopShell = win({ app: 'Windows Explorer', bounds: { ...DISPLAY }, id: 3, pid: 900 }) + +// ─── coversDisplay ─────────────────────────────────────────────────────── + +test('display-sized bounds cover the display', () => { + assert.equal(coversDisplay({ ...DISPLAY }, DISPLAY), true) +}) + +test('DPI rounding and the 1px-oversize trick stay within the epsilon', () => { + assert.equal(coversDisplay({ x: -1, y: -1, width: 2562, height: 1442 }, DISPLAY), true) + assert.equal(coversDisplay({ x: 1, y: 1, width: 2558, height: 1438 }, DISPLAY), true) +}) + +test('a maximized window stopping at the taskbar does not cover', () => { + assert.equal(coversDisplay({ x: 0, y: 0, width: 2560, height: 1392 }, DISPLAY), false) +}) + +test('a fullscreen window on ANOTHER display does not cover this one', () => { + assert.equal(coversDisplay({ x: 2560, y: 0, width: 2560, height: 1440 }, DISPLAY), false) +}) + +// ─── isShellWindow ─────────────────────────────────────────────────────── + +test('desktop shells are recognized, real apps are not', () => { + for (const app of ['Windows Explorer', 'explorer.exe', 'Program Manager', 'Finder', 'Dock', 'gnome-shell']) { + assert.equal(isShellWindow(app), true, app) + } + + for (const app of ['Balatro', 'Explorer of the Deep', 'Firefox', '']) { + assert.equal(isShellWindow(app), false, app) + } +}) + +// ─── detectFullscreenApp ───────────────────────────────────────────────── + +test('fullscreen game under the HUD is detected', () => { + const found = detectFullscreenApp([hudWindow, fullscreenGame, desktopShell], SELF_PID, DISPLAY) + + assert.equal(found?.app, 'Balatro') +}) + +test('own windows never count, wherever they sit in the z-order', () => { + const selfFullscreen = win({ app: 'Hermes', bounds: { ...DISPLAY }, pid: SELF_PID }) + + assert.equal(detectFullscreenApp([selfFullscreen, desktopShell], SELF_PID, DISPLAY), null) +}) + +test('the desktop shell reporting display bounds is not a game', () => { + assert.equal(detectFullscreenApp([hudWindow, desktopShell], SELF_PID, DISPLAY), null) +}) + +test('a windowed app in FRONT of the game vetoes the overlay', () => { + const editor = win({ app: 'VS Code', bounds: { x: 200, y: 100, width: 1400, height: 900 }, id: 9, pid: 3000 }) + + assert.equal(detectFullscreenApp([hudWindow, editor, fullscreenGame], SELF_PID, DISPLAY), null) +}) + +test('zero-area rows (minimized) neither decide nor veto', () => { + const minimized = win({ app: 'Spotify', bounds: { x: 0, y: 0, width: 0, height: 0 }, id: 11, pid: 3100 }) + + assert.equal(detectFullscreenApp([minimized, fullscreenGame], SELF_PID, DISPLAY)?.app, 'Balatro') +}) + +test('a window intersecting only another display does not veto', () => { + const otherDisplayWin = win({ app: 'Slack', bounds: { x: 3000, y: 0, width: 800, height: 600 }, id: 12, pid: 3200 }) + + assert.equal(detectFullscreenApp([otherDisplayWin, fullscreenGame], SELF_PID, DISPLAY)?.app, 'Balatro') +}) + +test('empty desktop: nothing to detect', () => { + assert.equal(detectFullscreenApp([hudWindow], SELF_PID, DISPLAY), null) +}) + +test('gameOverlayStateFor maps detection to the pushed state shape', () => { + assert.deepEqual(gameOverlayStateFor([fullscreenGame], SELF_PID, DISPLAY), { active: true, app: 'Balatro' }) + assert.deepEqual(gameOverlayStateFor([hudWindow], SELF_PID, DISPLAY), INACTIVE_GAME_OVERLAY) +}) + +// ─── hysteresis: enter strictly, stay while the game is still there ────── + +test('a windowed app on top vetoes ENTERING but not STAYING', () => { + // Clicking the HUD to type de-foregrounds the game and floats every other + // open window above it. Entering must respect that veto; staying must not, + // or the treatment drops the moment the user engages with it. + const editor = win({ app: 'VS Code', bounds: { x: 200, y: 100, width: 1400, height: 900 }, id: 9, pid: 3000 }) + const stack = [editor, fullscreenGame] + + assert.deepEqual(gameOverlayStateFor(stack, SELF_PID, DISPLAY, false), INACTIVE_GAME_OVERLAY) + assert.deepEqual(gameOverlayStateFor(stack, SELF_PID, DISPLAY, true), { active: true, app: 'Balatro' }) +}) + +test('the game actually closing ends overlay mode even when it was active', () => { + assert.deepEqual(gameOverlayStateFor([hudWindow, desktopShell], SELF_PID, DISPLAY, true), INACTIVE_GAME_OVERLAY) +}) + +test('findFullscreenAppAnywhere ignores z-order but keeps every other guard', () => { + const selfFullscreen = win({ app: 'Hermes', bounds: { ...DISPLAY }, pid: SELF_PID }) + + assert.equal(findFullscreenAppAnywhere([selfFullscreen], SELF_PID, DISPLAY), null) + assert.equal(findFullscreenAppAnywhere([desktopShell], SELF_PID, DISPLAY), null) + assert.equal(findFullscreenAppAnywhere([hudWindow, fullscreenGame], SELF_PID, DISPLAY)?.app, 'Balatro') +}) + +// ─── startHudGameOverlayWatch ──────────────────────────────────────────── + +interface FakeClock { + fire: () => Promise + cleared: () => boolean + setIntervalFn: typeof setInterval + clearIntervalFn: typeof clearInterval +} + +const fakeClock = (): FakeClock => { + let handler: (() => void) | null = null + let cleared = false + + return { + fire: async () => { + handler?.() + // The tick is async; let its microtasks drain. + await Promise.resolve() + await Promise.resolve() + }, + cleared: () => cleared, + setIntervalFn: ((fn: () => void) => { + handler = fn + + return 1 as unknown as ReturnType + }) as typeof setInterval, + clearIntervalFn: (() => { + cleared = true + }) as typeof clearInterval + } +} + +const flush = async () => { + await Promise.resolve() + await Promise.resolve() +} + +test('watch pushes on change only, never on a repeat answer', async () => { + const clock = fakeClock() + const pushed: Array<{ active: boolean; app: string }> = [] + let windows: EnumeratedWindow[] = [hudWindow] + + const dispose = startHudGameOverlayWatch({ + enumerate: () => Promise.resolve(windows), + displayBounds: () => DISPLAY, + selfPid: SELF_PID, + send: state => pushed.push(state), + setIntervalFn: clock.setIntervalFn, + clearIntervalFn: clock.clearIntervalFn + }) + + await flush() // the immediate first tick + assert.deepEqual(pushed, [{ active: false, app: '' }]) + + await clock.fire() // same answer — no push + assert.equal(pushed.length, 1) + + windows = [fullscreenGame] + await clock.fire() + assert.deepEqual(pushed[1], { active: true, app: 'Balatro' }) + + windows = [hudWindow] + await clock.fire() + assert.deepEqual(pushed[2], { active: false, app: '' }) + + dispose() + assert.equal(clock.cleared(), true) +}) + +test('watch settles inactive and stops after repeated enumeration failures', async () => { + const clock = fakeClock() + const pushed: Array<{ active: boolean }> = [] + + startHudGameOverlayWatch({ + enumerate: () => Promise.resolve(null), + displayBounds: () => DISPLAY, + selfPid: SELF_PID, + send: state => pushed.push(state), + setIntervalFn: clock.setIntervalFn, + clearIntervalFn: clock.clearIntervalFn + }) + + await flush() // failure 1 — no conclusion yet + assert.equal(pushed.length, 0) + assert.equal(clock.cleared(), false) + + await clock.fire() // failure 2 — settle and stop + assert.deepEqual(pushed, [{ active: false, app: '' }]) + assert.equal(clock.cleared(), true) +}) + +test('a single transient failure does not give up the watch', async () => { + const clock = fakeClock() + const pushed: Array<{ active: boolean }> = [] + let fail = true + + startHudGameOverlayWatch({ + enumerate: () => (fail ? Promise.resolve(null) : Promise.resolve([fullscreenGame])), + displayBounds: () => DISPLAY, + selfPid: SELF_PID, + send: state => pushed.push(state), + setIntervalFn: clock.setIntervalFn, + clearIntervalFn: clock.clearIntervalFn + }) + + await flush() // failure 1 + fail = false + await clock.fire() // recovers + + assert.deepEqual(pushed, [{ active: true, app: 'Balatro' }]) + assert.equal(clock.cleared(), false) + + // …and the failure counter reset: one more null is a transient again. + fail = true + await clock.fire() + assert.equal(clock.cleared(), false) +}) + +test('disposed watch stops pushing even with a tick in flight', async () => { + const clock = fakeClock() + const pushed: unknown[] = [] + + let release: (v: EnumeratedWindow[]) => void = () => {} + + const dispose = startHudGameOverlayWatch({ + enumerate: () => + new Promise(resolve => { + release = resolve + }), + displayBounds: () => DISPLAY, + selfPid: SELF_PID, + send: state => pushed.push(state), + setIntervalFn: clock.setIntervalFn, + clearIntervalFn: clock.clearIntervalFn + }) + + dispose() + release([fullscreenGame]) + await flush() + + assert.equal(pushed.length, 0) +}) + +test('the watch carries its own last answer into the next tick', async () => { + // The hysteresis is only correct if `wasActive` comes from what was last + // PUBLISHED; a watch that always passed false would re-veto every tick. + const clock = fakeClock() + const pushed: GameOverlayState[] = [] + const editor = win({ app: 'VS Code', bounds: { x: 200, y: 100, width: 1400, height: 900 }, id: 9, pid: 3000 }) + let windows: EnumeratedWindow[] = [fullscreenGame] + + startHudGameOverlayWatch({ + enumerate: () => Promise.resolve(windows), + displayBounds: () => DISPLAY, + selfPid: SELF_PID, + send: state => pushed.push(state), + setIntervalFn: clock.setIntervalFn, + clearIntervalFn: clock.clearIntervalFn + }) + + await flush() + assert.deepEqual(pushed, [{ active: true, app: 'Balatro' }]) + + // The user clicks the HUD: the editor is now above the game. Still active, so + // nothing new is published. + windows = [editor, fullscreenGame] + await clock.fire() + assert.equal(pushed.length, 1) + + // The game closes for real. + windows = [editor] + await clock.fire() + assert.deepEqual(pushed[1], INACTIVE_GAME_OVERLAY) +}) diff --git a/apps/desktop/electron/hud-game-overlay.ts b/apps/desktop/electron/hud-game-overlay.ts new file mode 100644 index 000000000000..a5ddf8809d13 --- /dev/null +++ b/apps/desktop/electron/hud-game-overlay.ts @@ -0,0 +1,261 @@ +// hud-game-overlay.ts — is the HUD floating over a fullscreen app (a game)? +// +// Discord's in-game overlay behavior: while a fullscreen app owns the screen +// the HUD steps back to a low-opacity, glanceable state, and steps forward +// again when the user engages it or a reply lands. The DECISION lives here as +// pure functions over the same front-to-back enumeration `window-below.ts` +// uses; main polls while the HUD is open and pushes changes to the HUD +// renderer, which owns the visual treatment (see `data-hud-game` in +// styles.css). +// +// "Fullscreen app" means: the frontmost other-process window on the HUD's +// display covers that display edge-to-edge. Walking the z-order front-to-back +// mirrors how the screen actually reads — if something windowed sits on top of +// the game, the game is not what the HUD is floating over. +// +// A note on what this can and cannot float over, so nobody debugs the wrong +// layer: an always-on-top window covers BORDERLESS fullscreen (the default in +// most modern games) and macOS fullscreen Spaces. True exclusive-fullscreen +// bypasses the compositor entirely — nothing short of injecting into the +// game's render pipeline (what Discord's native overlay does) draws over it. +// Detection still works there; the HUD is simply behind until the user +// alt-tabs, which Windows answers by flipping the game to composited output. + +import type { EnumeratedWindow } from './window-below' + +export interface GameOverlayState { + active: boolean + /** The fullscreen app's name while active, '' otherwise — the renderer may + * surface it ("over Balatro") and the diff key needs it either way. */ + app: string +} + +export const INACTIVE_GAME_OVERLAY: GameOverlayState = { active: false, app: '' } + +interface Bounds { + x: number + y: number + width: number + height: number +} + +/** Allowance for DPI rounding and the 1px oversize some engines use to dodge + * the OS's own "looks fullscreen" heuristics. */ +const COVER_EPSILON_PX = 2 + +/** + * Desktop-shell windows that legitimately report display-sized bounds and must + * never read as a game: the Windows desktop (Progman/WorkerW both belong to + * Explorer), the macOS Dock/desktop layers. Matched on the OWNER name — titles + * are localized, unavailable without permissions on macOS, and empty for most + * of these anyway. + */ +const SHELL_APPS = [ + /^windows explorer$/i, + /^explorer(\.exe)?$/i, + /^program manager$/i, + /^finder$/i, + /^dock$/i, + /^window ?server$/i, + /^windowmanager$/i, + /^gnome-shell$/i, + /^plasmashell$/i +] + +export const isShellWindow = (app: string): boolean => SHELL_APPS.some(pattern => pattern.test(app.trim())) + +/** Whether `bounds` covers `display` edge-to-edge (within the DPI epsilon). + * Work-area coverage is deliberately not enough: a maximized window stops at + * the taskbar/menu bar, a fullscreen one does not — that IS the distinction. */ +export const coversDisplay = (bounds: Bounds, display: Bounds, epsilon: number = COVER_EPSILON_PX): boolean => + bounds.x <= display.x + epsilon && + bounds.y <= display.y + epsilon && + bounds.x + bounds.width >= display.x + display.width - epsilon && + bounds.y + bounds.height >= display.y + display.height - epsilon + +const intersects = (a: Bounds, b: Bounds): boolean => + a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height + +/** + * The fullscreen app the HUD is floating over on `display`, or null. + * + * Front-to-back: skip every window of our own process (all Hermes windows + * share main's pid) and the desktop shell's display-sized layers, then let the + * FIRST window that intersects the display decide — covering it means a + * fullscreen app, anything less means ordinary windows are on top and the + * overlay treatment would just make the HUD illegible over a busy desktop. + * Zero-area rows (minimized windows report those on some platforms) never + * decide either way. + */ +export function detectFullscreenApp( + windows: EnumeratedWindow[], + selfPid: number, + display: Bounds +): EnumeratedWindow | null { + for (const win of windows) { + if (win.pid === selfPid || isShellWindow(win.app)) { + continue + } + + if (win.bounds.width <= 0 || win.bounds.height <= 0 || !intersects(win.bounds, display)) { + continue + } + + return coversDisplay(win.bounds, display) ? win : null + } + + return null +} + +/** + * The display-covering app anywhere in the stack, ignoring z-order. + * + * The STAY half of the hysteresis below. Front-to-back order answers "is a + * game what I'm looking at" for ENTERING overlay mode, but it cannot answer + * "am I still over the game" once the user clicks the HUD to type: the game + * stops being foreground, and whatever else they had open (a terminal, an + * editor, a browser) is suddenly above it and vetoes. The game did not go + * anywhere, so neither should the treatment. + */ +export function findFullscreenAppAnywhere( + windows: EnumeratedWindow[], + selfPid: number, + display: Bounds +): EnumeratedWindow | null { + return ( + windows.find( + win => + win.pid !== selfPid && + !isShellWindow(win.app) && + win.bounds.width > 0 && + win.bounds.height > 0 && + coversDisplay(win.bounds, display) + ) ?? null + ) +} + +export const gameOverlayStateFor = ( + windows: EnumeratedWindow[], + selfPid: number, + display: Bounds, + wasActive = false +): GameOverlayState => { + // Hysteresis. ENTERING needs the game to be what the user is actually + // looking at (front-to-back, windowed apps on top veto it). STAYING only + // needs the game to still be there: clicking the HUD to type pushes the game + // out of foreground and floats every other open window above it, which would + // otherwise drop the treatment at exactly the moment the user is reading it. + const fullscreen = wasActive + ? findFullscreenAppAnywhere(windows, selfPid, display) + : detectFullscreenApp(windows, selfPid, display) + + return fullscreen ? { active: true, app: fullscreen.app } : INACTIVE_GAME_OVERLAY +} + +export interface HudGameOverlayWatchDeps { + /** Front-to-back window enumeration; null when the platform cannot answer + * (Wayland, missing native module). Same contract as window-below's. */ + enumerate: () => Promise + /** Bounds of the display the HUD currently sits on. */ + displayBounds: () => Bounds + selfPid: number + /** Push a CHANGED state to the HUD renderer. */ + send: (state: GameOverlayState) => void + intervalMs?: number + /** Injectable timers so tests never wait on a real clock. */ + setIntervalFn?: typeof setInterval + clearIntervalFn?: typeof clearInterval +} + +/** Consecutive failed enumerations before the watch concludes the platform + * cannot answer and stops burning a subprocess/native call per tick. Two, not + * one: a single null can be a transient failure mid-session. */ +const FAILURES_BEFORE_GIVING_UP = 2 + +/** + * Poll for fullscreen-app changes while the HUD is open. Returns the disposer; + * idempotent, and the caller must invoke it when the HUD closes. + * + * Polling, not events: no OS surfaces a cross-process "a fullscreen app + * appeared" signal to an unprivileged window, and every consumer of this class + * of information (Discord, Steam, GeForce overlays) watches for it. The + * interval is slow enough to be free next to the HUD's own cursor feed. + */ +export function startHudGameOverlayWatch({ + enumerate, + displayBounds, + selfPid, + send, + intervalMs = 1500, + setIntervalFn = setInterval, + clearIntervalFn = clearInterval +}: HudGameOverlayWatchDeps): () => void { + let last: GameOverlayState | null = null + let failures = 0 + let inFlight = false + let disposed = false + + const publish = (state: GameOverlayState) => { + if (last === null || last.active !== state.active || last.app !== state.app) { + last = state + send(state) + } + } + + const tick = async () => { + // Enumeration is async and slower than the interval on a bad day (X11 + // shells out); overlapping ticks would answer out of order. + if (inFlight || disposed) { + return + } + + inFlight = true + + try { + const windows = await enumerate() + + if (disposed) { + return + } + + if (windows === null) { + failures += 1 + + // The platform cannot answer (and said so twice): settle on inactive + // and stop asking. + if (failures >= FAILURES_BEFORE_GIVING_UP) { + publish(INACTIVE_GAME_OVERLAY) + dispose() + } + + return + } + + failures = 0 + publish(gameOverlayStateFor(windows, selfPid, displayBounds(), last?.active ?? false)) + } catch { + // A throwing enumerator counts the same as a null answer. + failures += 1 + + if (failures >= FAILURES_BEFORE_GIVING_UP && !disposed) { + publish(INACTIVE_GAME_OVERLAY) + dispose() + } + } finally { + inFlight = false + } + } + + const timer = setIntervalFn(() => void tick(), intervalMs) + + function dispose() { + if (!disposed) { + disposed = true + clearIntervalFn(timer) + } + } + + void tick() + + return dispose +} diff --git a/apps/desktop/electron/hud-geometry.test.ts b/apps/desktop/electron/hud-geometry.test.ts new file mode 100644 index 000000000000..28a8d9c05a14 --- /dev/null +++ b/apps/desktop/electron/hud-geometry.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { applyHudResetBounds, defaultHudBounds, normalizeHudResizeBounds } from './hud-geometry' + +test('defaultHudBounds restores the standard centered bottom layout', () => { + assert.deepEqual(defaultHudBounds({ x: 0, y: 25, width: 1440, height: 875 }), { + x: 410, + y: 508, + width: 620, + height: 320 + }) +}) + +test('defaultHudBounds fits the default layout to a small work area', () => { + assert.deepEqual(defaultHudBounds({ x: -800, y: 0, width: 480, height: 240 }), { + x: -800, + y: 0, + width: 480, + height: 240 + }) +}) + +test('defaultHudBounds keeps the spawn fallback when no display is available', () => { + assert.deepEqual(defaultHudBounds(), { x: undefined, y: undefined, width: 620, height: 320 }) +}) + +test('applyHudResetBounds restores the resize lock and reports native failure', () => { + let resizable = false + + const win = { + isDestroyed: () => false, + isResizable: () => resizable, + setResizable: (value: boolean) => { + resizable = value + }, + setBounds: () => { + throw new Error('window disappeared') + } + } + + assert.equal(applyHudResetBounds(win, { x: 0, y: 0, width: 620, height: 320 }), false) + assert.equal(resizable, false) +}) + +test('applyHudResetBounds flips resizable on while the size changes', () => { + let resizable = false + const applied: Array<{ height: number; width: number }> = [] + + const win = { + isDestroyed: () => false, + isResizable: () => resizable, + setResizable: (value: boolean) => { + resizable = value + }, + setBounds: (bounds: { height: number; width: number }) => { + assert.equal(resizable, true) + applied.push({ width: bounds.width, height: bounds.height }) + } + } + + assert.equal(applyHudResetBounds(win, { x: 10, y: 20, width: 620, height: 320 }), true) + assert.equal(resizable, false) + assert.deepEqual(applied, [{ width: 620, height: 320 }]) +}) + +test('normalizeHudResizeBounds rounds finite geometry and clamps the HUD minimums', () => { + assert.deepEqual(normalizeHudResizeBounds({ x: 10.4, y: -20.6, width: 100, height: 80 }), { + x: 10, + y: -21, + width: 380, + height: 160 + }) +}) + +test('normalizeHudResizeBounds rejects incomplete or non-finite native geometry', () => { + assert.equal(normalizeHudResizeBounds(null), null) + assert.equal(normalizeHudResizeBounds({ x: 0, y: 0, width: Number.NaN, height: 320 }), null) + assert.equal(normalizeHudResizeBounds({ x: 0, y: 0, width: 620 }), null) +}) diff --git a/apps/desktop/electron/hud-geometry.ts b/apps/desktop/electron/hud-geometry.ts new file mode 100644 index 000000000000..cdeea4cb87fc --- /dev/null +++ b/apps/desktop/electron/hud-geometry.ts @@ -0,0 +1,105 @@ +export const HUD_WIDTH = 620 +export const HUD_HEIGHT = 320 +export const HUD_BOTTOM_MARGIN = 72 + +export interface HudWorkArea { + height: number + width: number + x: number + y: number +} + +/** Display-aware default bounds used to spawn and recover the HUD layout. */ +export function defaultHudBounds(area?: HudWorkArea): { + height: number + width: number + x?: number + y?: number +} { + if (!area) { + return { width: HUD_WIDTH, height: HUD_HEIGHT, x: undefined, y: undefined } + } + + const width = Math.min(HUD_WIDTH, area.width) + const height = Math.min(HUD_HEIGHT, area.height) + + return { + width, + height, + x: Math.round(area.x + (area.width - width) / 2), + y: Math.round(Math.max(area.y, area.y + area.height - height - HUD_BOTTOM_MARGIN)) + } +} + +export interface HudBoundsWindow { + isDestroyed(): boolean + isResizable(): boolean + setBounds(bounds: { height: number; width: number; x?: number; y?: number }): void + setResizable(resizable: boolean): void +} + +export interface HudResizeBounds { + height: number + width: number + x: number + y: number +} + +/** Validate renderer-provided resize geometry before it reaches native APIs. */ +export function normalizeHudResizeBounds(value: unknown): HudResizeBounds | null { + if (!value || typeof value !== 'object') { + return null + } + + const candidate = value as Partial> + const x = Number(candidate.x) + const y = Number(candidate.y) + const width = Number(candidate.width) + const height = Number(candidate.height) + + if (![x, y, width, height].every(Number.isFinite)) { + return null + } + + return { + x: Math.round(x), + y: Math.round(y), + width: Math.max(380, Math.round(width)), + height: Math.max(160, Math.round(height)) + } +} + +/** + * Apply recovery bounds. The HUD is created non-resizable (a transparent + * frameless window must not expose a system resize hot-zone), which on + * Windows/Linux also blocks programmatic setBounds sizing — flip it on + * briefly, same as the corner-resize IPC. + * + * On native Wayland the compositor ignores the position half of setBounds + * (clients cannot place themselves). Size still applies, which is what + * unsticks the tall/narrow persisted geometry from #87055. + */ +export function applyHudResetBounds( + win: HudBoundsWindow, + bounds: { height: number; width: number; x?: number; y?: number } +): boolean { + try { + const wasResizable = win.isResizable() + + if (!wasResizable) { + win.setResizable(true) + } + + try { + win.setBounds(bounds) + } finally { + if (!wasResizable && !win.isDestroyed()) { + win.setResizable(false) + } + } + + return true + } catch { + return false + } +} diff --git a/apps/desktop/electron/hud-hyprland.test.ts b/apps/desktop/electron/hud-hyprland.test.ts new file mode 100644 index 000000000000..07feb0f61580 --- /dev/null +++ b/apps/desktop/electron/hud-hyprland.test.ts @@ -0,0 +1,255 @@ +/** + * Unit tests for the Hyprland HUD overlay promote. The socket itself needs a + * live compositor; what is covered here is the grammar (classic vs Lua), the + * client lookup, and which dispatchers fire for a tiled vs already-floating bar. + */ + +import assert from 'node:assert/strict' + +import { afterEach, test } from 'vitest' + +import { + classifyHyprlandDispatchReply, + hudOverlayCommands, + parseHudHyprlandClient, + promoteHudOnHyprland, + resetHyprlandDispatchSyntax +} from './hud-hyprland' + +const TITLE = 'Hermes HUD' +const ADDRESS = '0x55d1' +const SOCKET_ENV = { HYPRLAND_INSTANCE_SIGNATURE: 'abc123', XDG_RUNTIME_DIR: '/run/user/1000' } + +const LUA_REJECT = + "error: [string \"return hl.dispatch(setfloating address:0x55d1)\"]:1: ')' expected near 'address'\n\n → Note: dispatch in lua is a shorthand for hl.dispatch(...), your syntax might need to be updated." + +afterEach(() => { + resetHyprlandDispatchSyntax() +}) + +const hudClient = (over: Record = {}) => ({ + address: ADDRESS, + floating: false, + pinned: false, + title: TITLE, + ...over +}) + +test('classifies Hyprland dispatch replies the way 0.54 and 0.56 actually write them', () => { + assert.equal(classifyHyprlandDispatchReply('ok'), 'ok') + assert.equal(classifyHyprlandDispatchReply('ok\n'), 'ok') + assert.equal(classifyHyprlandDispatchReply('Invalid dispatcher'), 'wrong-syntax') + assert.equal(classifyHyprlandDispatchReply(LUA_REJECT), 'wrong-syntax') + assert.equal(classifyHyprlandDispatchReply('No such window found'), 'failed') + assert.equal(classifyHyprlandDispatchReply('warning: =[C]:-1: hl.focus: window not found'), 'failed') +}) + +test('finds the HUD by exact title among other windows of the same pid', () => { + const found = parseHudHyprlandClient( + JSON.stringify([ + { address: '0x111', pid: 42, title: 'Hermes', floating: true, pinned: false }, + hudClient({ pid: 42 }) + ]), + TITLE + ) + + assert.deepEqual(found, { address: ADDRESS, floating: false, pinned: false }) +}) + +test('prefixes a bare hex address so the selector is well-formed', () => { + const found = parseHudHyprlandClient(JSON.stringify([hudClient({ address: '55d1' })]), TITLE) + + assert.equal(found?.address, ADDRESS) +}) + +test('ignores a payload that is not the client list or has the wrong title', () => { + assert.equal(parseHudHyprlandClient('not json', TITLE), null) + assert.equal(parseHudHyprlandClient('{}', TITLE), null) + assert.equal(parseHudHyprlandClient(JSON.stringify([hudClient({ title: 'other' })]), TITLE), null) +}) + +test('legacy float is setfloating (idempotent), not togglefloating', () => { + const commands = hudOverlayCommands(ADDRESS, 'float') + + assert.equal(commands.legacy, 'dispatch setfloating address:0x55d1') + assert.equal(commands.lua, 'dispatch hl.dsp.window.float({ action = "enable", window = "address:0x55d1" })') +}) + +test('lua pin uses enable so a second promote does not unpin', () => { + const commands = hudOverlayCommands(ADDRESS, 'pin') + + assert.equal(commands.legacy, 'dispatch pin address:0x55d1') + assert.equal(commands.lua, 'dispatch hl.dsp.window.pin({ action = "enable", window = "address:0x55d1" })') +}) + +test('skips compositors that are not Hyprland', async () => { + const calls: string[] = [] + + const promoted = await promoteHudOnHyprland({ + title: TITLE, + env: {}, + uid: 1000, + request: async (_socket, command) => { + calls.push(command) + + return 'ok' + } + }) + + assert.equal(promoted, false) + assert.deepEqual(calls, []) +}) + +test('floats then pins a tiled HUD on a classic hyprlang session', async () => { + const calls: string[] = [] + + const promoted = await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 1, + delayMs: 0, + request: async (_socket, command) => { + calls.push(command) + + if (command === 'j/clients') { + return JSON.stringify([hudClient()]) + } + + return 'ok' + } + }) + + assert.equal(promoted, true) + assert.deepEqual(calls, ['j/clients', 'dispatch setfloating address:0x55d1', 'dispatch pin address:0x55d1']) +}) + +test('falls back to Lua dispatch when the classic session grammar is rejected', async () => { + const calls: string[] = [] + + const promoted = await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 1, + delayMs: 0, + request: async (_socket, command) => { + calls.push(command) + + if (command === 'j/clients') { + return JSON.stringify([hudClient()]) + } + + if (command.startsWith('dispatch setfloating') || command.startsWith('dispatch pin ')) { + return LUA_REJECT + } + + return 'ok' + } + }) + + assert.equal(promoted, true) + assert.deepEqual(calls, [ + 'j/clients', + 'dispatch setfloating address:0x55d1', + 'dispatch hl.dsp.window.float({ action = "enable", window = "address:0x55d1" })', + 'dispatch hl.dsp.window.pin({ action = "enable", window = "address:0x55d1" })' + ]) +}) + +test('caches Lua syntax so the next promote does not probe classic first', async () => { + const request = async (_socket: string, command: string) => { + if (command === 'j/clients') { + return JSON.stringify([hudClient()]) + } + + if (command.startsWith('dispatch setfloating') || command.startsWith('dispatch pin ')) { + return LUA_REJECT + } + + return 'ok' + } + + await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 1, + delayMs: 0, + request + }) + + const calls: string[] = [] + + await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 1, + delayMs: 0, + request: async (_socket, command) => { + calls.push(command) + + return request(_socket, command) + } + }) + + assert.deepEqual(calls, [ + 'j/clients', + 'dispatch hl.dsp.window.float({ action = "enable", window = "address:0x55d1" })', + 'dispatch hl.dsp.window.pin({ action = "enable", window = "address:0x55d1" })' + ]) +}) + +test('does not pin a window that is already pinned, and does not float one that already is', async () => { + const calls: string[] = [] + + const promoted = await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 1, + delayMs: 0, + request: async (_socket, command) => { + calls.push(command) + + if (command === 'j/clients') { + return JSON.stringify([hudClient({ floating: true, pinned: true })]) + } + + return 'ok' + } + }) + + assert.equal(promoted, true) + assert.deepEqual(calls, ['j/clients']) +}) + +test('retries until Hyprland lists the HUD', async () => { + let lookups = 0 + + const promoted = await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 3, + delayMs: 0, + sleep: async () => undefined, + request: async (_socket, command) => { + if (command === 'j/clients') { + lookups += 1 + + if (lookups < 3) { + return JSON.stringify([{ address: '0x111', title: 'Hermes', floating: true }]) + } + + return JSON.stringify([hudClient({ floating: true, pinned: true })]) + } + + return 'ok' + } + }) + + assert.equal(promoted, true) + assert.equal(lookups, 3) +}) diff --git a/apps/desktop/electron/hud-hyprland.ts b/apps/desktop/electron/hud-hyprland.ts new file mode 100644 index 000000000000..afd47668425c --- /dev/null +++ b/apps/desktop/electron/hud-hyprland.ts @@ -0,0 +1,214 @@ +/** + * Hyprland (Omarchy, etc.) tiles a new Electron toplevel by default. + * `alwaysOnTop` is compositor-owned on native Wayland, and `xdg_toplevel.move` + * — the native HUD drag path — is ignored on a tiled window. The bar then + * sits in the layout instead of over the user's work, which is the whole HUD. + * + * We already speak Hyprland's command socket for `read_window_below`. After + * the HUD maps, ask that same socket to float and pin this title. Pin is the + * always-on-top analogue (all workspaces, above tiled clients). + * + * Hyprland 0.55+ on a Lua config evaluates `dispatch` as Lua, so the classic + * `setfloating` / `pin` strings fail there and the `hl.dsp.*` forms fail on a + * hyprlang session. Which grammar a session speaks is only discoverable by + * trying; we probe once and cache it. + */ + +import { hyprlandRequest, hyprlandSocketPath } from './hyprland' + +export type HyprlandDispatchReply = 'ok' | 'wrong-syntax' | 'failed' + +export type HudHyprlandClient = { + address: string + floating: boolean + pinned: boolean +} + +type HyprlandDispatchSyntax = 'legacy' | 'lua' + +export type HyprlandRequestFn = (socketPath: string, command: string) => Promise + +const DEFAULT_ATTEMPTS = 8 +const DEFAULT_DELAY_MS = 50 + +let cachedSyntax: null | HyprlandDispatchSyntax = null + +export function resetHyprlandDispatchSyntax(): void { + cachedSyntax = null +} + +/** + * Hyprland's textual reply to a `dispatch`. "ok" applied. A Lua parse error or + * "Invalid dispatcher" means we used the other grammar. Anything else is the + * right grammar aimed at a missing window — retry the lookup, not the syntax. + * + * Reply strings are what Hyprland 0.54 (hyprlang) and 0.56 (Lua config) actually + * write; they are the probe, not a guess. + */ +export function classifyHyprlandDispatchReply(reply: string): HyprlandDispatchReply { + const text = reply.trim() + + if (text === 'ok') { + return 'ok' + } + + if (text.startsWith('Invalid dispatcher') || text.startsWith('error:')) { + return 'wrong-syntax' + } + + return 'failed' +} + +function windowAddress(raw: string): string { + return raw.startsWith('0x') || raw.startsWith('0X') ? raw : `0x${raw}` +} + +/** First mapped client whose title is exactly the HUD's. */ +export function parseHudHyprlandClient(payload: string, title: string): HudHyprlandClient | null { + let raw: unknown + + try { + raw = JSON.parse(payload) + } catch { + return null + } + + if (!Array.isArray(raw)) { + return null + } + + for (const entry of raw) { + if (!entry || typeof entry !== 'object') { + continue + } + + const client = entry as { address?: unknown; floating?: unknown; pinned?: unknown; title?: unknown } + + if (client.title !== title || typeof client.address !== 'string' || client.address.length === 0) { + continue + } + + return { + address: windowAddress(client.address), + floating: client.floating === true, + pinned: client.pinned === true + } + } + + return null +} + +export function hudOverlayCommands(address: string, action: 'float' | 'pin'): { legacy: string; lua: string } { + const selector = `address:${address}` + + if (action === 'float') { + return { + legacy: `dispatch setfloating ${selector}`, + lua: `dispatch hl.dsp.window.float({ action = "enable", window = "${selector}" })` + } + } + + return { + legacy: `dispatch pin ${selector}`, + lua: `dispatch hl.dsp.window.pin({ action = "enable", window = "${selector}" })` + } +} + +async function dispatchWithFallback( + request: HyprlandRequestFn, + socketPath: string, + pair: { legacy: string; lua: string } +): Promise { + const order: HyprlandDispatchSyntax[] = cachedSyntax === 'lua' ? ['lua', 'legacy'] : ['legacy', 'lua'] + + for (const syntax of order) { + const command = syntax === 'legacy' ? pair.legacy : pair.lua + const reply = await request(socketPath, command) + + if (reply == null) { + return 'failed' + } + + const kind = classifyHyprlandDispatchReply(reply) + + if (kind === 'wrong-syntax') { + continue + } + + cachedSyntax = syntax + + return kind + } + + return 'wrong-syntax' +} + +async function defaultSleep(ms: number): Promise { + await new Promise(resolve => setTimeout(resolve, ms)) +} + +/** + * Float and pin the HUD on Hyprland. No-op on every other compositor. + * Fire-and-forget from the HUD reveal path; retries because `j/clients` can + * lag the first frame. + */ +export async function promoteHudOnHyprland(options: { + title: string + attempts?: number + delayMs?: number + env?: NodeJS.ProcessEnv + request?: HyprlandRequestFn + sleep?: (ms: number) => Promise + uid?: number +}): Promise { + const env = options.env ?? process.env + const uid = options.uid ?? process.getuid?.() ?? 0 + const socketPath = hyprlandSocketPath(env, uid) + + if (!socketPath) { + return false + } + + const request = options.request ?? hyprlandRequest + const sleep = options.sleep ?? defaultSleep + const attempts = options.attempts ?? DEFAULT_ATTEMPTS + const delayMs = options.delayMs ?? DEFAULT_DELAY_MS + + for (let attempt = 0; attempt < attempts; attempt++) { + if (attempt > 0 && delayMs > 0) { + await sleep(delayMs) + } + + const payload = await request(socketPath, 'j/clients') + + if (!payload) { + continue + } + + const client = parseHudHyprlandClient(payload, options.title) + + if (!client) { + continue + } + + if (!client.floating) { + const floated = await dispatchWithFallback(request, socketPath, hudOverlayCommands(client.address, 'float')) + + if (floated !== 'ok') { + continue + } + } + + if (!client.pinned) { + const pinned = await dispatchWithFallback(request, socketPath, hudOverlayCommands(client.address, 'pin')) + + if (pinned !== 'ok') { + continue + } + } + + return true + } + + return false +} diff --git a/apps/desktop/electron/hud-ipc.ts b/apps/desktop/electron/hud-ipc.ts index 3eff9c14889c..dc9066b23968 100644 --- a/apps/desktop/electron/hud-ipc.ts +++ b/apps/desktop/electron/hud-ipc.ts @@ -4,30 +4,70 @@ // latch when handing the session back to the app window. import { type BrowserWindow, ipcMain } from 'electron' +import { normalizeHudResizeBounds } from './hud-geometry' +import { hudWindowingView, resolveHudWindowing } from './hud-windowing' import { hudFrostFor, type TranslucencyState } from './translucency' +function hudWindowing() { + return resolveHudWindowing(process.platform, process.env, process.argv) +} + export interface HudIpcDeps { isMac: boolean - isWindows: boolean - glassSupported: boolean /** Main's authoritative translucency state (Settings → Appearance). */ getTranslucencyState: () => TranslucencyState getHudWindow: () => BrowserWindow | null openHudWindow: (sessionId: null | string, profile: null | string) => void closeHudWindow: () => void + resetHudLayout: () => boolean setHudSessionId: (sessionId: null | string) => void } export function registerHudIpc({ isMac, - isWindows, - glassSupported, getTranslucencyState, getHudWindow, openHudWindow, closeHudWindow, + resetHudLayout, setHudSessionId }: HudIpcDeps) { + // The renderer needs this before first paint so X11 never installs the + // Chromium drag region that steals modifier-drag gestures from the WM. + // Main answers because it owns the actual Ozone backend selection. + ipcMain.on('hermes:hud:native-drag', event => { + event.returnValue = hudWindowing().move === 'native-drag' + }) + + ipcMain.on('hermes:hud:windowing', event => { + event.returnValue = hudWindowingView(hudWindowing()) + }) + + // X11/KWin window transfer: a renderer-driven grab is temporarily sticky so + // the user can keep Ctrl+primary-button held while invoking KDE's desktop + // switch shortcut. Clearing sticky on release makes Chromium assign the + // window to `_NET_CURRENT_DESKTOP`, exactly like releasing a native titlebar + // drag on the destination desktop. Native Wayland owns its move loop and + // Windows/macOS stay out of this Linux-specific bridge. + ipcMain.on('hermes:hud:workspace-transfer', (event, transferring) => { + const hudWindow = getHudWindow() + + if ( + !hudWindow || + hudWindow.isDestroyed() || + event.sender !== hudWindow.webContents || + !hudWindowing().workspaceTransfer + ) { + return + } + + try { + hudWindow.setVisibleOnAllWorkspaces(Boolean(transferring)) + } catch { + // Workspace APIs are window-manager capabilities — best effort. + } + }) + // Whether the band currently covers the window below the bar. The renderer // is the only party that can know this (it measures the transcript), and it // is half of the frost decision — the other half is the user's setting, @@ -44,6 +84,10 @@ export function registerHudIpc({ // margins for that reason, and it only turns on while the band is showing // (idle HUD mode must be the bar and nothing else). // + // macOS ONLY. Windows' equivalent (setBackgroundMaterial → the DWM backdrop) + // is mutually exclusive with window transparency, so it is not called at all + // here — see the note at the bottom of this function. + // // Diffed before issuing: `setVibrancy` carries a 150ms animation that restarts // if re-issued, so a repeated call would keep the material from ever settling // (the same churn the chat windows' native-diff contract exists to prevent). @@ -77,9 +121,14 @@ export function registerHudIpc({ hudWindow.setVibrancy(frost.vibrancy) } - if (isWindows && glassSupported && typeof hudWindow.setBackgroundMaterial === 'function') { - hudWindow.setBackgroundMaterial(frost.backgroundMaterial) - } + // Windows: never touch setBackgroundMaterial on the HUD. Live-verified on + // Win11 (Electron 40.10.2, RTX 4090): ANY setBackgroundMaterial call on a + // transparent window — including 'none', which is what the idle HUD asks + // for — permanently kills per-pixel alpha, and every transparent pixel + // composites as opaque white. Neither 'auto' nor a follow-up + // setBackgroundColor('#00000000') restores it. The DWM backdrop and window + // transparency are mutually exclusive, so the Windows HUD keeps the CSS + // tint the sheet already paints and skips the native frost entirely. } ipcMain.handle('hermes:hud:open', async (_event, request) => { @@ -106,9 +155,19 @@ export function registerHudIpc({ ipcMain.on('hermes:hud:ignore-mouse', (_event, ignore) => { const hudWindow = getHudWindow() - if (hudWindow && !hudWindow.isDestroyed()) { - hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) + if (!hudWindow || hudWindow.isDestroyed()) { + return + } + + // On X11 ignore-mouse is a one-way door: setIgnoreMouseEvents(false) + // cannot restore the input region afterwards. Veto the request there so + // the HUD stays a normal solid window. Native Wayland and macOS/Windows + // keep the per-element path. + if (Boolean(ignore) && !hudWindowing().ignoreMouse) { + return } + + hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) }) ipcMain.on('hermes:hud:move-by', (event, delta) => { @@ -129,6 +188,10 @@ export function registerHudIpc({ const [x, y] = hudWindow.getPosition() + if (!hudWindowing().clientPlacement) { + return + } + // setBounds — NOT setPosition: on Windows, a transparent frameless window // silently grows ~1px per setPosition call (worse at >100% DPI). The renderer // snapshots outerWidth/outerHeight when the composer drag arms and re-pins @@ -141,7 +204,7 @@ export function registerHudIpc({ }) }) - // Resize from the HUD's corner handle. The window is created non-resizable + // Resize from the HUD's edge/corner handles. The window is created non-resizable // (see spawnHudWindow — a transparent frameless window must not expose a // system resize hot-zone, or dragging grows it), which on Windows/Linux also // blocks programmatic setBounds sizing — so briefly flip resizable on while @@ -153,21 +216,41 @@ export function registerHudIpc({ return } + const nextBounds = normalizeHudResizeBounds(bounds) + + if (!nextBounds) { + return + } + const win = hudWindow - const width = Math.max(380, Math.round(Number(bounds.width))) - const height = Math.max(160, Math.round(Number(bounds.height))) + const { width, height } = nextBounds const [curW, curH] = win.getSize() const resizing = width !== curW || height !== curH - - if (resizing && !win.isResizable()) { - win.setResizable(true) + const restoreResizeLock = resizing && !win.isResizable() + + try { + if (restoreResizeLock) { + win.setResizable(true) + } + + win.setBounds(nextBounds) + } catch { + // The window may disappear between validation and the native call. + } finally { + if (restoreResizeLock && !win.isDestroyed()) { + win.setResizable(false) + } } + }) - win.setBounds({ x: Math.round(Number(bounds.x)), y: Math.round(Number(bounds.y)), width, height }) + ipcMain.handle('hermes:hud:reset-layout', event => { + const hudWindow = getHudWindow() - if (resizing) { - win.setResizable(false) + if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents) { + return { ok: false } } + + return { ok: resetHudLayout() } }) // The HUD renderer reporting which session it is on, so the close broadcast diff --git a/apps/desktop/electron/hud-overlay.test.ts b/apps/desktop/electron/hud-overlay.test.ts new file mode 100644 index 000000000000..351a2a741a39 --- /dev/null +++ b/apps/desktop/electron/hud-overlay.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { applyHudElectronOverlay } from './hud-overlay' + +test('macOS uses the floating panel level and all-spaces visibility', () => { + const calls: string[] = [] + + const win = { + setAlwaysOnTop(flag: boolean, level?: string) { + calls.push(`alwaysOnTop:${flag}:${level}`) + }, + setVisibleOnAllWorkspaces(visible: boolean, options?: { visibleOnFullScreen?: boolean }) { + calls.push(`allWorkspaces:${visible}:${options?.visibleOnFullScreen === true}`) + } + } + + applyHudElectronOverlay(win, 'darwin') + + assert.deepEqual(calls, ['alwaysOnTop:true:floating', 'allWorkspaces:true:true']) +}) + +test('Linux and Windows only set the screen-saver always-on-top level', () => { + for (const platform of ['linux', 'win32']) { + const calls: string[] = [] + + const win = { + setAlwaysOnTop(flag: boolean, level?: string) { + calls.push(`alwaysOnTop:${flag}:${level}`) + }, + setVisibleOnAllWorkspaces() { + calls.push('allWorkspaces') + } + } + + applyHudElectronOverlay(win, platform) + + assert.deepEqual(calls, ['alwaysOnTop:true:screen-saver']) + } +}) diff --git a/apps/desktop/electron/hud-overlay.ts b/apps/desktop/electron/hud-overlay.ts new file mode 100644 index 000000000000..b43fad61bdf4 --- /dev/null +++ b/apps/desktop/electron/hud-overlay.ts @@ -0,0 +1,41 @@ +/** + * HUD overlay adapters. + * + * Electron `alwaysOnTop` is the generic ask. Some compositors ignore it + * (Hyprland tiles the toplevel; COSMIC drops z-order). Each adapter speaks + * that compositor's dialect; `promoteHudOverlay` is the one call site. + */ + +import { promoteHudOnHyprland } from './hud-hyprland' + +export interface HudElectronOverlayWindow { + setAlwaysOnTop(flag: boolean, level?: string): void + setVisibleOnAllWorkspaces?( + visible: boolean, + options?: { skipTransformProcessType?: boolean; visibleOnFullScreen?: boolean } + ): void +} + +/** Chrome Electron itself can honour. Compositor IPC is `promoteHudOverlay`. */ +export function applyHudElectronOverlay(win: HudElectronOverlayWindow, platform: string): void { + win.setAlwaysOnTop(true, platform === 'darwin' ? 'floating' : 'screen-saver') + + if (platform !== 'darwin') { + return + } + + try { + win.setVisibleOnAllWorkspaces?.(true, { visibleOnFullScreen: true, skipTransformProcessType: true }) + } catch { + // Not supported everywhere — best effort. + } +} + +/** + * Ask the running compositor to treat the HUD as an overlay. Hyprland is the + * first adapter (float + pin). Sway/niri hang off this same function later. + * Returns true when an adapter applied; false is "nothing to do / not that WM". + */ +export async function promoteHudOverlay(options: { title: string }): Promise { + return promoteHudOnHyprland(options) +} diff --git a/apps/desktop/electron/hud-windowing.test.ts b/apps/desktop/electron/hud-windowing.test.ts new file mode 100644 index 000000000000..1e4d9b99f800 --- /dev/null +++ b/apps/desktop/electron/hud-windowing.test.ts @@ -0,0 +1,122 @@ +/** + * Unit tests for the HUD windowing profile. Ozone parsing and the capability + * matrix live here: one profile, every operation reads the same flags. + */ + +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { hudWindowingView, linuxOzoneBackend, resolveHudWindowing } from './hud-windowing' + +const X11_SESSION = { DISPLAY: ':0', XDG_SESSION_TYPE: 'x11' } +const WAYLAND_SESSION = { WAYLAND_DISPLAY: 'wayland-0', XDG_SESSION_TYPE: 'wayland' } +const XWAYLAND_SESSION = { DISPLAY: ':0', WAYLAND_DISPLAY: 'wayland-0', XDG_SESSION_TYPE: 'wayland' } + +const input = (platform: string, env: NodeJS.ProcessEnv, argv: readonly string[] = []) => + resolveHudWindowing(platform, env, argv).input + +test('macOS and Windows share the click-through + renderer-drag profile', () => { + for (const platform of ['darwin', 'win32'] as const) { + const windowing = resolveHudWindowing(platform, WAYLAND_SESSION, ['--ozone-platform=wayland']) + + assert.equal(windowing.backend, platform === 'darwin' ? 'cocoa' : 'win32') + assert.equal(windowing.input, 'click-through') + assert.equal(windowing.move, 'renderer') + assert.equal(windowing.clientPlacement, true) + assert.equal(windowing.controlDrag, false) + assert.equal(windowing.workspaceTransfer, false) + assert.equal(windowing.cursorFeed, false) + assert.equal(windowing.ignoreMouse, true) + } +}) + +test('Linux X11 is solid, renderer-drag, and can place the window', () => { + const windowing = resolveHudWindowing('linux', X11_SESSION, []) + + assert.equal(windowing.backend, 'x11') + assert.equal(windowing.input, 'solid') + assert.equal(windowing.move, 'renderer') + assert.equal(windowing.clientPlacement, true) + assert.equal(windowing.controlDrag, true) + assert.equal(windowing.workspaceTransfer, true) + assert.equal(windowing.cursorFeed, false) + assert.equal(windowing.ignoreMouse, false) +}) + +test('native Wayland cannot place the window and needs compositor drag + cursor feed', () => { + const windowing = resolveHudWindowing('linux', WAYLAND_SESSION, []) + + assert.equal(windowing.backend, 'wayland') + assert.equal(windowing.input, 'click-through') + assert.equal(windowing.move, 'native-drag') + assert.equal(windowing.clientPlacement, false) + assert.equal(windowing.controlDrag, false) + assert.equal(windowing.workspaceTransfer, false) + assert.equal(windowing.cursorFeed, true) + assert.equal(windowing.ignoreMouse, true) +}) + +test('a Wayland session with DISPLAY set is still a native Wayland client under Electron 20+', () => { + assert.equal(input('linux', XWAYLAND_SESSION), 'click-through') +}) + +test('asking for a native Wayland surface keeps the click-through path', () => { + for (const argv of [['--ozone-platform=wayland'], ['--ozone-platform-hint=wayland']]) { + assert.equal(input('linux', WAYLAND_SESSION, argv), 'click-through') + } + + assert.equal(input('linux', { ...WAYLAND_SESSION, ELECTRON_OZONE_PLATFORM_HINT: 'wayland' }), 'click-through') +}) + +test('an auto hint follows the session', () => { + assert.equal(input('linux', WAYLAND_SESSION, ['--ozone-platform-hint=auto']), 'click-through') + assert.equal(input('linux', X11_SESSION, ['--ozone-platform-hint=auto']), 'solid') + assert.equal(input('linux', XWAYLAND_SESSION, ['--ozone-platform-hint=auto']), 'click-through') +}) + +test('asking for X11 on a Wayland session takes the solid / renderer-drag profile', () => { + const windowing = resolveHudWindowing('linux', WAYLAND_SESSION, ['--ozone-platform=x11']) + + assert.equal(windowing.backend, 'x11') + assert.equal(windowing.input, 'solid') + assert.equal(windowing.move, 'renderer') + assert.equal(windowing.clientPlacement, true) + assert.equal(input('linux', { ...WAYLAND_SESSION, ELECTRON_OZONE_PLATFORM_HINT: 'x11' }), 'solid') +}) + +test('the explicit switch beats the hint, and the last switch wins', () => { + assert.equal(input('linux', WAYLAND_SESSION, ['--ozone-platform-hint=auto', '--ozone-platform=x11']), 'solid') + assert.equal(input('linux', X11_SESSION, ['--ozone-platform=x11', '--ozone-platform=wayland']), 'click-through') +}) + +test('a backend nobody recognises follows the session, not a silent X11 default', () => { + assert.equal(input('linux', X11_SESSION, ['--ozone-platform=headless']), 'solid') + assert.equal(input('linux', WAYLAND_SESSION, ['--ozone-platform=headless']), 'click-through') + assert.equal(input('linux', {}, []), 'solid') +}) + +test('linuxOzoneBackend is the session/argv normalizer', () => { + assert.equal(linuxOzoneBackend(X11_SESSION, []), 'x11') + assert.equal(linuxOzoneBackend(WAYLAND_SESSION, []), 'wayland') + assert.equal(linuxOzoneBackend(WAYLAND_SESSION, ['--ozone-platform=x11']), 'x11') + assert.equal(linuxOzoneBackend(X11_SESSION, ['--ozone-platform-hint=auto']), 'x11') +}) + +test('the renderer view is a boolean slice of the profile', () => { + const wayland = hudWindowingView(resolveHudWindowing('linux', WAYLAND_SESSION, [])) + const x11 = hudWindowingView(resolveHudWindowing('linux', X11_SESSION, [])) + + assert.deepEqual(wayland, { + clientPlacement: false, + controlDrag: false, + nativeDrag: true, + workspaceTransfer: false + }) + assert.deepEqual(x11, { + clientPlacement: true, + controlDrag: true, + nativeDrag: false, + workspaceTransfer: true + }) +}) diff --git a/apps/desktop/electron/hud-windowing.ts b/apps/desktop/electron/hud-windowing.ts new file mode 100644 index 000000000000..8a95b0712e2d --- /dev/null +++ b/apps/desktop/electron/hud-windowing.ts @@ -0,0 +1,133 @@ +/** + * HUD windowing profile — one object for every operation that used to + * special-case OS / Ozone / compositor. + * + * The Ozone backend (not the login session) is the normalizer: Electron 20+ + * prefers a native Wayland surface on a Wayland session, so `setBounds` + * position is a no-op there and ignore-mouse restore works. X11 is the + * inverse. macOS and Windows keep the click-through + renderer-drag design. + * + * Capabilities are derived once. Callers ask the profile, they do not + * re-derive "linux && click-through". + */ + +export type HudOzoneBackend = 'wayland' | 'x11' +export type HudWindowingBackend = 'cocoa' | 'win32' | HudOzoneBackend +export type HudInput = 'click-through' | 'solid' +export type HudMove = 'native-drag' | 'renderer' + +export interface HudWindowing { + backend: HudWindowingBackend + /** Whether the window may ignore the mouse (restore is known to work). */ + ignoreMouse: boolean + input: HudInput + move: HudMove + /** Client can set global x/y. Native Wayland cannot. */ + clientPlacement: boolean + /** Immediate Ctrl+primary-button grab (X11 renderer drag). */ + controlDrag: boolean + /** Temporary all-workspaces during a renderer grab (X11/KWin). */ + workspaceTransfer: boolean + /** Main must poll the cursor; `{ forward: true }` is unavailable. */ + cursorFeed: boolean +} + +/** Slice the renderer is allowed to see. */ +export interface HudWindowingView { + clientPlacement: boolean + controlDrag: boolean + nativeDrag: boolean + workspaceTransfer: boolean +} + +function requestedOzonePlatform(env: NodeJS.ProcessEnv, argv: readonly string[]): null | string { + let explicit: null | string = null + let hint: null | string = null + + for (const arg of argv) { + const match = /^--ozone-platform(-hint)?=(.+)$/.exec(arg) + + if (!match) { + continue + } + + if (match[1]) { + hint = match[2].toLowerCase() + } else { + explicit = match[2].toLowerCase() + } + } + + return explicit ?? hint ?? env.ELECTRON_OZONE_PLATFORM_HINT?.toLowerCase() ?? null +} + +function sessionIsWayland(env: NodeJS.ProcessEnv): boolean { + return env.XDG_SESSION_TYPE === 'wayland' || (Boolean(env.WAYLAND_DISPLAY) && !env.DISPLAY) +} + +/** + * The Ozone platform Electron will actually use on Linux. + * + * Unset / `auto` follows the session. An explicit `--ozone-platform=x11` + * (or `desktop.ozone_platform_hint: x11`) is the COSMIC always-on-top escape + * hatch and lands on the solid / renderer-drag path. + */ +export function linuxOzoneBackend(env: NodeJS.ProcessEnv, argv: readonly string[]): HudOzoneBackend { + const requested = requestedOzonePlatform(env, argv) + + if (requested === 'x11') { + return 'x11' + } + + if (requested === 'wayland') { + return 'wayland' + } + + return sessionIsWayland(env) ? 'wayland' : 'x11' +} + +function desktopWindowing(backend: 'cocoa' | 'win32'): HudWindowing { + return { + backend, + ignoreMouse: true, + input: 'click-through', + move: 'renderer', + clientPlacement: true, + controlDrag: false, + workspaceTransfer: false, + cursorFeed: false + } +} + +export function resolveHudWindowing(platform: string, env: NodeJS.ProcessEnv, argv: readonly string[]): HudWindowing { + if (platform === 'darwin') { + return desktopWindowing('cocoa') + } + + if (platform === 'win32') { + return desktopWindowing('win32') + } + + const backend = linuxOzoneBackend(env, argv) + const wayland = backend === 'wayland' + + return { + backend, + ignoreMouse: wayland, + input: wayland ? 'click-through' : 'solid', + move: wayland ? 'native-drag' : 'renderer', + clientPlacement: !wayland, + controlDrag: !wayland, + workspaceTransfer: !wayland, + cursorFeed: wayland + } +} + +export function hudWindowingView(windowing: HudWindowing): HudWindowingView { + return { + clientPlacement: windowing.clientPlacement, + controlDrag: windowing.controlDrag, + nativeDrag: windowing.move === 'native-drag', + workspaceTransfer: windowing.workspaceTransfer + } +} diff --git a/apps/desktop/electron/hyprland.ts b/apps/desktop/electron/hyprland.ts index 6aa50881e96e..92353a12bbd1 100644 --- a/apps/desktop/electron/hyprland.ts +++ b/apps/desktop/electron/hyprland.ts @@ -122,7 +122,7 @@ export function parseHyprlandClients(payload: string, selfPid: number): Enumerat } /** One request on the command socket, always closed, never left hanging. */ -function request(socketPath: string, command: string): Promise { +export function hyprlandRequest(socketPath: string, command: string): Promise { return new Promise(resolve => { let body = '' let settled = false @@ -165,7 +165,7 @@ export async function readHyprlandWindows( return null } - const payload = await request(socketPath, 'j/clients') + const payload = await hyprlandRequest(socketPath, 'j/clients') if (!payload) { return null diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index e3087bdcb9d8..0846a0b6ce3e 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1,4 +1,4 @@ -import { execFile, execFileSync, spawn } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' import crypto from 'node:crypto' import fs from 'node:fs' import http from 'node:http' @@ -31,11 +31,27 @@ import { } from 'electron' import { classifyActiveRuntime } from './active-runtime-state' +import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport' import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child' +import { + type BackendOutputTail, + claimDecision, + createBackendOutputTail, + execText, + isPidOnlyStartMarker, + pidOnlyStartMarker, + probeStartMarker, + processStartMarker +} from './backend-claim' import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' import { createBackendConnectionState } from './backend-connection-state' import { buildDesktopBackendEnv, hermesManagedNodePathEntries, normalizeHermesHomeRoot } from './backend-env' -import { isReauthRequiredError, makeNousCloudBackendDownError, waitForHermesReady } from './backend-health' +import { + isReauthRequiredError, + makeNousCloudBackendDownError, + makeUnsignedOauthError, + waitForHermesReady +} from './backend-health' import { backendCommandMatches, createBackendOwnership, createBackendShutdownCoordinator } from './backend-ownership' import { canImportHermesCli, @@ -60,6 +76,13 @@ import { } from './bootstrap-platform' import { decideBootstrapRepair } from './bootstrap-repair-guard' import { runBootstrap } from './bootstrap-runner' +import { + BROWSER_WINDOW_HEIGHT, + BROWSER_WINDOW_MIN_HEIGHT, + BROWSER_WINDOW_MIN_WIDTH, + BROWSER_WINDOW_WIDTH, + buildBrowserWindowUrl +} from './browser-windows' import { detectBundleSkew } from './bundle-skew' import { applyConnectionChange } from './connection-apply' import { @@ -81,11 +104,12 @@ import { normalizeRemoteHeaders, normalizeSshConfig, normAuthMode, + pathForRegistryBackendRequest, pathWithGlobalRemoteProfile, - pathWithProfileScope, profileHasRemoteConnection, profileRemoteOverride, profileSshOverride, + type RegistryBackendRequestScope, remoteRequestMatchesBaseUrl, resolveAuthMode, resolveProfileApiRequest, @@ -94,8 +118,9 @@ import { resolveTestWsUrl, savedProfileSsh, tokenPreview, - translateSelfProfileQuery + withTransientRetries } from './connection-config' +import { applyConnectionConfigAtomically } from './connection-config-apply' import { backendScopeKey, backendScopePrefix, @@ -105,6 +130,8 @@ import { migrateV1ToRegistry, normalizeConnectionInput, normalizeRegistry, + reconcileAppliedGlobalConnection, + reconcileRegistryDrift, rememberSshEnumeration, removeConnection, resolvedConnectionId, @@ -154,9 +181,11 @@ import { registerFsIpc } from './fs-ipc' import { filenameFromContentDisposition, gatewayFilePath, + gatewayFileRequestPaths, isNotFoundError, parseDataUrlToBuffer, - pumpStreamToFile + pumpStreamToFile, + resolveGatewayFileBackend } from './gateway-file-download' import { probeGatewayWebSocket } from './gateway-ws-probe' import { registerGitIpc } from './git-ipc' @@ -181,10 +210,14 @@ import { writeSecretFileAtomic } from './hardening' import { cursorPointInWindow } from './hud-cursor' +import { startHudGameOverlayWatch } from './hud-game-overlay' +import { applyHudResetBounds, defaultHudBounds } from './hud-geometry' import { registerHudIpc } from './hud-ipc' +import { applyHudElectronOverlay, promoteHudOverlay } from './hud-overlay' import { snapHudBounds } from './hud-snap' import { createHudSnapShortcut } from './hud-snap-shortcut' import { buildHudWindowUrl } from './hud-url' +import { resolveHudWindowing } from './hud-windowing' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' import { createMediaProtocolHandler, MEDIA_PROTOCOL } from './media-protocol' @@ -206,11 +239,7 @@ import { import { runNativeLogin } from './native-oauth-login' import { loadNativeTokenSet, type NativeTokenStoreIo, persistNativeTokenSet } from './native-token-store' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' -import { - createParentStartMarkerResolver, - electronProcessStartMarker, - parentWatchdogEnv -} from './parent-process-identity' +import { createParentStartMarkerResolver, parentWatchdogEnv } from './parent-process-identity' import { registerPetOverlayIpc } from './pet-overlay-ipc' import { buildRegistryProfileRoutes, @@ -232,6 +261,7 @@ import { rehomePrimaryConnection } from './primary-connection-rehome' import { assertLocalProfileCanStart, decideProfileDeleteAction, + dispatchConnectionScopedProfileDelete, localProfilePoolKeys, ProfileDeletionGate, profileNameFromDeleteRequest, @@ -243,6 +273,7 @@ import { fetchPrimaryProfileSessions, fetchRegistrySessionRows, fetchRemoteProfileSessions, + findRemoteOwnerProfileForSession, mergeProfileSessionWindow, type RegistrySessionSource, spliceRegistrySessionRows @@ -258,6 +289,13 @@ import { } from './remote-liveness' import { missingRendererAssets } from './renderer-bundle' import { attachRendererConsoleCapture, formatRendererBoundaryReport } from './renderer-log' +import { + classifyStoredSecret, + readSecretStoragePolicy, + SECRET_STORAGE_POLICY_FILE, + type SecretStoragePolicy, + writeSecretStoragePolicy +} from './secret-storage-policy' import { buildInstanceWindowUrl, buildSessionWindowUrl, @@ -316,7 +354,7 @@ import { } from './venv-blocker-scan' import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' import { createWakeIndicatorWindowController } from './wake-indicator-window' -import { readWindowBelow } from './window-below' +import { enumerateWindowsFrontToBack, enumerationFailed, readWindowBelow } from './window-below' import { installWindowRendererLifecycle } from './window-renderer-lifecycle' import { createWindowRevealController } from './window-reveal' import { @@ -1279,7 +1317,8 @@ function registerMediaProtocol() { return resolvedPath }, - resolveRemoteConnection: profile => ensureBackend(profile) + resolveRemoteConnection: ({ connectionId, profile }) => + connectionId ? ensureRegistryBackend(connectionId, profile) : ensureBackend(profile) }) protocol.handle(MEDIA_PROTOCOL, handler) @@ -2479,6 +2518,35 @@ function getVenvPython(venvRoot) { return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python')) } +// Map a selected interpreter back to the venv that OWNS it (the directory +// above bin/ or Scripts/), but only when that venv lives inside `root`. +// Returns null for system pythons — they own no site-packages we should mount. +// +// This exists because findPythonForRoot() probes `.venv` before `venv`, and a +// checkout can legitimately have BOTH (dev tooling venv + the CLI install +// venv, possibly on different Python versions). The interpreter and the +// site-packages placed on PYTHONPATH must come from the SAME venv: pairing a +// .venv 3.12 python with venv/lib/python3.11/site-packages makes the backend +// die on its first native import (pydantic_core) before the gateway binds — +// the renderer then reports "Gateway offline" on every profile. +function venvRootForPython(python: string, root: string) { + const parent = path.dirname(python) + const binName = path.basename(parent).toLowerCase() + + if (binName !== 'bin' && binName !== 'scripts') { + return null + } + + const candidate = path.dirname(parent) + const relative = path.relative(root, candidate) + + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + return null + } + + return candidate +} + // Windows console-window flashes are governed by the *parent's* console, not by // each child spawn. A GUI-subsystem parent (pythonw.exe) has no console, so every // console-subsystem child it spawns (git, gh, cmd, ...) must allocate its own — @@ -3113,70 +3181,9 @@ function writeBackendOwnership(contents) { } } -function execText(command, args, { timeout = 3000 } = {}) { - return new Promise((resolve, reject) => { - execFile(command, args, hiddenWindowsChildOptions({ encoding: 'utf8', timeout }), (error, stdout) => { - if (error) { - reject(error) - } else { - resolve(String(stdout || '').trim()) - } - }) - }) -} - -async function processStartMarker(pid) { - if (process.platform === 'linux') { - const stat = await fs.promises.readFile(`/proc/${pid}/stat`, 'utf8') - - const fields = stat - .slice(stat.lastIndexOf(')') + 1) - .trim() - .split(/\s+/) - - if (!/^\d+$/.test(fields[19] || '')) { - throw new Error(`Invalid /proc start marker for PID ${pid}`) - } - - return `linux:${fields[19]}` - } - - if (IS_WINDOWS) { - const electronMarker = - pid === process.pid ? electronProcessStartMarker(pid, process.pid, process.getCreationTime?.()) : null - - if (electronMarker) { - return electronMarker - } - - const ticks = await execText( - 'powershell.exe', - [ - '-NoProfile', - '-NonInteractive', - '-Command', - `$p = Get-Process -Id ${pid} -ErrorAction Stop; $p.StartTime.ToUniversalTime().Ticks` - ], - // PowerShell 5.1 cold starts routinely exceed the default 3s execText - // budget (2.4-8s observed in #87169); give the marker probe headroom. - { timeout: 30_000 } - ) - - if (!/^\d+$/.test(ticks)) { - throw new Error(`Invalid Windows start marker for PID ${pid}`) - } - - return `win:${ticks}` - } - - const started = await execText('ps', ['-p', String(pid), '-o', 'lstart=']) - - if (!started) { - throw new Error(`Missing process start marker for PID ${pid}`) - } - - return `ps:${started}` -} +// execText and processStartMarker moved to backend-claim.ts (#93608) so the +// claim/probe policy is testable — including on Windows CI with real +// PowerShell — without booting Electron. main.ts calls through the module. async function backendCommandForPid(pid) { try { @@ -3198,6 +3205,22 @@ async function backendCommandForPid(pid) { } async function processIdentityMatches(identity) { + // Degraded PID-only identity (#93608): the start-marker probe failed while + // the child was verifiably alive, so only PID liveness can be checked here. + // backendIdentityMatches layers the command-line check on top before + // anything destructive relies on the answer. + if (isPidOnlyStartMarker(identity.startMarker)) { + try { + process.kill(identity.pid, 0) + + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code + + return code === 'ESRCH' || code === 'ENOENT' ? false : code === 'EPERM' ? true : undefined + } + } + try { return (await processStartMarker(identity.pid)) === identity.startMarker } catch (error) { @@ -3320,14 +3343,42 @@ const desktopParentStartMarker = createParentStartMarkerResolver({ } }) -async function claimBackendChild(child, command, profile, nonce) { +async function claimBackendChild(child, command, profile, nonce, outputTail: BackendOutputTail | null = null) { + // Probe/claim policy lives in backend-claim.ts (#93608): a marker probe + // that fails against a LIVE child degrades to PID-only identity — matching + // createParentStartMarkerResolver — instead of killing a healthy backend + // over a flaky Get-Process (PS 5.1 cold starts, #87169). Only a child that + // actually died keeps the fail-closed throw, now carrying its stderr tail. + const probe = await probeStartMarker(child.pid) + const decision = claimDecision(child.exitCode === null && !child.killed, probe) + + if (decision.action === 'fail') { + stopBackendChild(child) + await waitForBackendExit(child) + throw new Error( + `Hermes backend (PID ${child.pid}) died before its identity could be recorded: ${decision.reason}${outputTail?.describe() ?? ''}` + ) + } + + let startMarker + + if (decision.action === 'degrade') { + startMarker = pidOnlyStartMarker(child.pid) + rememberLog( + `WARNING: process start marker probe failed for live Hermes backend PID ${child.pid}; ` + + `claiming with PID-only identity instead of stopping it: ${decision.reason}` + ) + } else { + startMarker = decision.startMarker + } + try { const identity = await backendOwnership.claim({ command, nonce, pid: child.pid, profile, - startMarker: await processStartMarker(child.pid), + startMarker, // Record the spawning Electron so reapOrphans can tell an orphaned // backend (parent gone) from one owned by a live instance — a live // parent's backend is never reaped (#87295). @@ -3341,7 +3392,9 @@ async function claimBackendChild(child, command, profile, nonce) { } catch (error) { stopBackendChild(child) await waitForBackendExit(child) - throw new Error(`Could not persist ownership for the Hermes backend: ${error.message}`) + throw new Error( + `Could not persist ownership for the Hermes backend: ${error.message}${outputTail?.describe() ?? ''}` + ) } } @@ -4232,8 +4285,21 @@ function resolveWebDist() { } function resolveRendererIndex() { - const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')] - const present = candidates.filter(fileExists) + const asarIndex = path.join(APP_ROOT, 'dist', 'index.html') + const webDistIndex = path.join(resolveWebDist(), 'index.html') + + // A packaged build ships dist/ twice: inside app.asar AND — because + // asarUnpack lists dist/** — beside it in app.asar.unpacked. Prefer the + // unpacked tree, matching the resolveWebDist()/unpackedPathFor precedent: + // it is the copy the embedded dashboard serves and the copy a repair + // rewrites, while pointing the window at the asar-internal index.html is + // exactly how lazy chunks end up fetched from a path that cannot serve + // them (#93479). Every window loader shares this resolver (main, overlay, + // quick), so the ordering fix covers all of them. Dev is unchanged: + // unpackedPathFor is a no-op outside an asar, so both candidates collapse + // to APP_ROOT/dist and the original order is preserved. + const candidates = IS_PACKAGED ? [webDistIndex, asarIndex] : [asarIndex, webDistIndex] + const present = [...new Set(candidates)].filter(fileExists) // index.html and the hashed chunks it names are one generation. An update // that replaces only one of the two shipped copies (app.asar vs @@ -4400,7 +4466,12 @@ function createPythonBackend(root, label, backendArgs, options: any = {}) { return null } - const venvRoot = path.join(root, 'venv') + // The venv whose interpreter we selected is the venv whose site-packages + // belong on PYTHONPATH — findPythonForRoot may have picked `.venv` over + // `venv`, and mixing the two crashes the backend on its first native + // import (see venvRootForPython). Fall back to root/venv only for a + // system python, where the historical layout is the best guess. + const venvRoot = venvRootForPython(python, root) ?? path.join(root, 'venv') const venvPython = getVenvPython(venvRoot) const command = IS_WINDOWS && fileExists(venvPython) ? venvPython : python @@ -4811,99 +4882,113 @@ function multipartBody(upload) { } function fetchJson(url, token, options: any = {}) { - return new Promise((resolve, reject) => { - const { body, contentType } = options.upload - ? multipartBody(options.upload) - : { - body: options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)), - contentType: 'application/json' - } - - const parsed = new URL(url) - const client = parsed.protocol === 'https:' ? https : http - const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) + // Retry policy lives in api-transport.ts: idempotent verbs retry on any + // transient transport error; POST/PUT/DELETE only when the request provably + // never reached the server (see shouldRetryRequest) — never double-submit. + return withRetry( + (requestState: any) => + new Promise((resolve, reject) => { + const { body, contentType } = options.upload + ? multipartBody(options.upload) + : { + body: options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)), + contentType: 'application/json' + } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) + const parsed = new URL(url) + const client = parsed.protocol === 'https:' ? https : http + const agent = jsonAgentFor(parsed.protocol) + const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) - return - } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) - const req = client.request( - parsed, - { - method: options.method || 'GET', - headers: { - ...headersForRemoteRequest(url), - ...(options.headers || {}), - 'Content-Type': contentType, - 'X-Hermes-Session-Token': token, - // RFC 8252 native flow authenticates the gated gateway with a bearer - // token instead of the loopback session-token header. When - // ``options.bearer`` is set we send Authorization: Bearer ; - // the gateway's OAuth gate verifies it via the provider stack with - // no cookie involved. - ...(options.bearer ? { Authorization: `Bearer ${options.bearer}` } : {}), - ...(body ? { 'Content-Length': String(body.length) } : {}) + return } - }, - res => { - const chunks = [] - res.on('error', reject) - res.on('data', chunk => chunks.push(chunk)) - res.on('end', () => { - const text = Buffer.concat(chunks).toString('utf8') - - if ((res.statusCode || 500) >= 400) { - reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) - - return - } - - if (!text) { - resolve(null) - - return - } - // A 2xx response whose body is HTML means the request fell through - // to the SPA index.html (e.g. an unregistered /api path). JSON.parse - // would throw an opaque `Unexpected token '<'` here, so surface a - // clear diagnostic with the offending URL instead. - const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) - const contentType = String(res.headers['content-type'] || '') - - if (looksHtml || contentType.includes('text/html')) { - reject( - new Error( - `Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` + - 'The endpoint is likely missing on the Hermes backend.' - ) - ) - - return + const req = client.request( + parsed, + { + agent, + method: options.method || 'GET', + headers: { + ...headersForRemoteRequest(url), + ...(options.headers || {}), + 'Content-Type': contentType, + 'X-Hermes-Session-Token': token, + // RFC 8252 native flow authenticates the gated gateway with a bearer + // token instead of the loopback session-token header. When + // ``options.bearer`` is set we send Authorization: Bearer ; + // the gateway's OAuth gate verifies it via the provider stack with + // no cookie involved. + ...(options.bearer ? { Authorization: `Bearer ${options.bearer}` } : {}), + ...(body ? { 'Content-Length': String(body.length) } : {}) + } + }, + res => { + const chunks = [] + res.on('error', reject) + res.on('data', chunk => chunks.push(chunk)) + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8') + + if ((res.statusCode || 500) >= 400) { + reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) + + return + } + + if (!text) { + resolve(null) + + return + } + + // A 2xx response whose body is HTML means the request fell through + // to the SPA index.html (e.g. an unregistered /api path). JSON.parse + // would throw an opaque `Unexpected token '<'` here, so surface a + // clear diagnostic with the offending URL instead. + const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) + const contentType = String(res.headers['content-type'] || '') + + if (looksHtml || contentType.includes('text/html')) { + reject( + new Error( + `Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` + + 'The endpoint is likely missing on the Hermes backend.' + ) + ) + + return + } + + try { + resolve(JSON.parse(text)) + } catch { + reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`)) + } + }) } + ) - try { - resolve(JSON.parse(text)) - } catch { - reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`)) - } + req.on('error', reject) + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) - } - ) - req.on('error', reject) - req.setTimeout(timeoutMs, () => { - req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) - }) + // From here the request goes on the wire: a later transport error can no + // longer prove the server didn't process it, so non-idempotent verbs must + // not be retried past this point. + requestState.bodySent = true - if (body) { - req.write(body) - } + if (body) { + req.write(body) + } - req.end() - }) + req.end() + }), + { method: options.method || 'GET' } + ) } // Token-auth download that streams the response body straight to a @@ -4930,11 +5015,13 @@ function downloadViaTokenToFile(url, token, ctx, options: any = {}) { } const client = parsed.protocol === 'https:' ? https : http + const agent = downloadAgentFor(parsed.protocol) const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) const req = client.request( parsed, { + agent, method: 'GET', headers: options.bearer ? { Authorization: `Bearer ${options.bearer}` } : { 'X-Hermes-Session-Token': token } }, @@ -4969,90 +5056,99 @@ function fetchPublicJson(url, options: any = {}) { // NO ``X-Hermes-Session-Token`` header — used by the auth-mode probe before // any credentials exist, and any time we must not leak a token to an // endpoint that doesn't need one. - return new Promise((resolve, reject) => { - const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) - let parsed - - try { - parsed = new URL(url) - } catch (error) { - reject(new Error(`Invalid URL: ${error.message}`)) - - return - } - - const client = parsed.protocol === 'https:' ? https : http - const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) + return withRetry( + (requestState: any) => + new Promise((resolve, reject) => { + const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) + let parsed - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) - - return - } + try { + parsed = new URL(url) + } catch (error) { + reject(new Error(`Invalid URL: ${error.message}`)) - const req = client.request( - parsed, - { - method: options.method || 'GET', - headers: { - ...headersForRemoteRequest(url), - ...(options.headers || {}), - 'Content-Type': 'application/json', - ...(body ? { 'Content-Length': String(body.length) } : {}) + return } - }, - res => { - const chunks = [] - res.on('data', chunk => chunks.push(chunk)) - res.on('end', () => { - const text = Buffer.concat(chunks).toString('utf8') - - if ((res.statusCode || 500) >= 400) { - reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) - - return - } - if (!text) { - resolve(null) + const client = parsed.protocol === 'https:' ? https : http + const agent = jsonAgentFor(parsed.protocol) + const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) - return - } - - const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) - const contentType = String(res.headers['content-type'] || '') + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) - if (looksHtml || contentType.includes('text/html')) { - reject( - new Error( - `Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` + - 'The endpoint is likely missing on the Hermes backend.' - ) - ) + return + } - return + const req = client.request( + parsed, + { + agent, + method: options.method || 'GET', + headers: { + ...headersForRemoteRequest(url), + ...(options.headers || {}), + 'Content-Type': 'application/json', + ...(body ? { 'Content-Length': String(body.length) } : {}) + } + }, + res => { + const chunks = [] + res.on('data', chunk => chunks.push(chunk)) + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8') + + if ((res.statusCode || 500) >= 400) { + reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) + + return + } + + if (!text) { + resolve(null) + + return + } + + const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) + const contentType = String(res.headers['content-type'] || '') + + if (looksHtml || contentType.includes('text/html')) { + reject( + new Error( + `Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` + + 'The endpoint is likely missing on the Hermes backend.' + ) + ) + + return + } + + try { + resolve(JSON.parse(text)) + } catch { + reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`)) + } + }) } + ) - try { - resolve(JSON.parse(text)) - } catch { - reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`)) - } + req.on('error', reject) + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) - } - ) - req.on('error', reject) - req.setTimeout(timeoutMs, () => { - req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) - }) + // Past this point the request is on the wire — see fetchJson. + requestState.bodySent = true - if (body) { - req.write(body) - } + if (body) { + req.write(body) + } - req.end() - }) + req.end() + }), + { method: options.method || 'GET' } + ) } function mimeTypeForPath(filePath) { @@ -6475,6 +6571,7 @@ function installPreviewShortcut(window) { import { applyZoomLevel, DEFAULT_ZOOM_LEVEL, + installZoomReassertOnNavigation, installZoomReassertOnWindowEvents, percentToZoomLevel, ZOOM_STEP, @@ -6516,6 +6613,19 @@ function restorePersistedZoomLevel(window) { const saved = readZoomState() if (saved != null) { + // Drift-guard: skip when this window already shows the persisted level. + // Blindly re-applying on every resize/move would race the compositor's + // surface reconfigure during a Wayland resize storm (Cosmic tiled mode + // fires one whenever a new session window opens — #84818) and keep the + // renderer notification stream churning for no gain. The settle-verify + // chain in installZoomReassertOnWindowEvents re-applies only when the + // window actually drifted from the persisted level. + const current = window.webContents?.getZoomLevel?.() + + if (current != null && Math.abs(current - saved) < 1e-9) { + return + } + applyZoomLevel(window.webContents, saved) return @@ -7453,33 +7563,63 @@ function readGatewayErrorText(res): Promise { }) } -async function gatedFileAuth(connection) { +interface GatewayFileConnection extends RegistryBackendRequestScope { + authMode?: 'oauth' | 'token' + baseUrl: string + token?: null | string +} + +interface GatewayFileSaveContext { + fallbackName: string + suggested: string +} + +interface GatewayFileSavePayload { + connectionId?: unknown + path?: unknown + profile?: unknown + suggestedName?: unknown +} + +async function gatedFileAuth(connection: GatewayFileConnection) { const nativeAt = connection.authMode === 'oauth' ? await ensureNativeAccessToken(connection.baseUrl).catch(() => null) : null return resolveGatedDownloadAuth(connection.authMode, nativeAt, connection.token) } -async function saveGatewayFile(payload: any = {}) { +function gatewayFileRequestPath( + connection: GatewayFileConnection, + connectionId: null | string, + profile: null | string, + requestPath: string +) { + return connectionId + ? pathForRegistryBackendRequest(requestPath, profile, connection) + : pathWithGlobalRemoteProfile(requestPath, profile, profileRouteOptions(profile)) +} + +async function saveGatewayFile(payload: GatewayFileSavePayload = {}) { const filePath = gatewayFilePath(payload.path) if (!filePath) { throw new Error('Missing gateway file path') } - const profile = payload.profile || null - const connection = await ensureBackend(profile) + const { connection, connectionId, profile } = await resolveGatewayFileBackend(payload, { + ensureLegacy: ensureBackend, + ensureRegistry: ensureRegistryBackend + }) + const suggested = String(payload.suggestedName || '').trim() const fallbackName = path.basename(filePath) || suggested || 'download' const ctx = { suggested, fallbackName } - const requestPath = pathWithGlobalRemoteProfile( - `/api/fs/download?path=${encodeURIComponent(filePath)}`, - profile, - profileRouteOptions(profile) + const requestPaths = gatewayFileRequestPaths(filePath, requestPath => + gatewayFileRequestPath(connection, connectionId, profile, requestPath) ) - const url = `${connection.baseUrl}${requestPath}` + const url = `${connection.baseUrl}${requestPaths.download}` try { const auth = await gatedFileAuth(connection) @@ -7498,7 +7638,7 @@ async function saveGatewayFile(payload: any = {}) { // /api/fs/download 404s here; fall back (ONLY on 404) to the older capped // data-URL route so downloads keep working against older backends. if (isNotFoundError(error)) { - return await saveGatewayFileViaDataUrl(connection, profile, filePath, ctx) + return await saveGatewayFileViaDataUrl(connection, requestPaths.dataUrl, ctx) } throw error @@ -7509,16 +7649,14 @@ async function saveGatewayFile(payload: any = {}) { // `/api/fs/read-data-url` route, decode it, and save. Bounded by the gateway's // data-URL cap, so it only serves smaller files — enough to keep older gateways // working until they gain the streaming route. -async function saveGatewayFileViaDataUrl(connection, profile, filePath, ctx: any = {}) { - const requestPath = pathWithGlobalRemoteProfile( - `/api/fs/read-data-url?path=${encodeURIComponent(filePath)}`, - profile, - profileRouteOptions(profile) - ) - +async function saveGatewayFileViaDataUrl( + connection: GatewayFileConnection, + requestPath: string, + ctx: GatewayFileSaveContext +) { const url = `${connection.baseUrl}${requestPath}` const auth = await gatedFileAuth(connection) - let json: any + let json: unknown if (auth.kind === 'bearer') { json = await fetchJson(url, null, { bearer: auth.token }) @@ -7528,7 +7666,8 @@ async function saveGatewayFileViaDataUrl(connection, profile, filePath, ctx: any json = await fetchJson(url, auth.token) } - const dataUrl = json?.dataUrl + const dataUrl = + json && typeof json === 'object' && 'dataUrl' in json && typeof json.dataUrl === 'string' ? json.dataUrl : '' if (!dataUrl) { throw new Error('Gateway returned no file data') @@ -7556,15 +7695,34 @@ async function saveGatewayFileViaDataUrl(connection, profile, filePath, ctx: any // falling back to the OAuth cookie partition otherwise. // Throws (with statusCode 401) if the session cookie is missing/expired — // callers treat that as "needs re-login". +// Transient transport blips (brief host unreachable, 5xx, timeouts) are retried +// a few times before failing — those 1-3s flaps were promoting into the +// full-screen "couldn't start" lockout on reconnect. async function mintGatewayWsTicket(baseUrl, headers = {}) { - // Native flow: mint the ticket with the bearer token, no cookie involved. - const nativeAt = await ensureNativeAccessToken(baseUrl).catch(() => null) + return withTransientRetries(async () => { + // Native flow: mint the ticket with the bearer token, no cookie involved. + const nativeAt = await ensureNativeAccessToken(baseUrl).catch(() => null) + + if (nativeAt) { + const body = (await fetchJson(`${baseUrl}/api/auth/ws-ticket`, null, { + method: 'POST', + timeoutMs: 8_000, + bearer: nativeAt, + headers + })) as any + + const ticket = body?.ticket + + if (!ticket || typeof ticket !== 'string') { + throw new Error('Gateway did not return a WS ticket.') + } + + return ticket + } - if (nativeAt) { - const body = (await fetchJson(`${baseUrl}/api/auth/ws-ticket`, null, { + const body = (await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, { method: 'POST', timeoutMs: 8_000, - bearer: nativeAt, headers })) as any @@ -7575,21 +7733,7 @@ async function mintGatewayWsTicket(baseUrl, headers = {}) { } return ticket - } - - const body = (await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, { - method: 'POST', - timeoutMs: 8_000, - headers - })) as any - - const ticket = body?.ticket - - if (!ticket || typeof ticket !== 'string') { - throw new Error('Gateway did not return a WS ticket.') - } - - return ticket + }) } // Build a fresh WS URL for the *current* connection. Critical for reconnects: @@ -8155,62 +8299,311 @@ async function cloudAgentSilentSignIn(dashboardUrl) { return { baseUrl, connected: await hasOauthSessionCookie(baseUrl) } } -function encryptDesktopSecret(value, options = {}) { - return encryptDesktopSecretStrict(value, safeStorage, options) +// --------------------------------------------------------------------------- +// Opt-in keychain encryption (secret-storage-policy.ts owns the decision). +// Default OFF: no safeStorage call is ever made, so a broken/locked macOS +// login keychain can never throw its password dialog on launch. Settings → +// Gateway exposes the toggle; flipping it re-encrypts (or decrypts) the +// stored secrets in place. +// --------------------------------------------------------------------------- +const SECRET_STORAGE_POLICY_PATH = path.join(app.getPath('userData'), SECRET_STORAGE_POLICY_FILE) + +const _secretStoragePolicyIo = { + readText: () => fs.readFileSync(SECRET_STORAGE_POLICY_PATH, 'utf8'), + writeText: (text: string) => writeSecretFileAtomic(SECRET_STORAGE_POLICY_PATH, text, { encoding: 'utf8' }) } -function decryptDesktopSecret(secret) { - if (!secret || typeof secret !== 'object') { - return '' +let _secretStoragePolicy: SecretStoragePolicy | null = null + +function secretStoragePolicy(): SecretStoragePolicy { + if (!_secretStoragePolicy) { + _secretStoragePolicy = readSecretStoragePolicy(_secretStoragePolicyIo) } - const value = String(secret.value || '') + return _secretStoragePolicy +} - if (!value) { - return '' - } +function setSecretStoragePolicy(next: SecretStoragePolicy) { + _secretStoragePolicy = { on: next.on === true, migrated: next.migrated === true } + writeSecretStoragePolicy(_secretStoragePolicy, _secretStoragePolicyIo) +} - if (secret.encoding === SAFE_STORAGE_ENCODING) { - try { - return safeStorage.decryptString(Buffer.from(value, 'base64')) - } catch { - return '' - } +/** + * Keychain availability as the renderer should see it. With encryption + * opted out this must NOT probe safeStorage — isEncryptionAvailable() is + * itself a keychain touch that raises the macOS dialog this feature exists + * to avoid. We report `true` so no plain-text warning banners fire: storing + * plaintext is the user's chosen (default) mode, not a degraded state. + */ +function probeSecureTokenStorage(): boolean { + if (!secretStoragePolicy().on) { + return true } - // Any other encoding (a hand-edited config, or one written by a pre-release - // build) is returned verbatim on purpose: this fallback is what lets such a - // config connect at all. Not a plaintext-writing path — nothing in this file - // persists a token this way. - return value + try { + return Boolean(safeStorage.isEncryptionAvailable()) + } catch { + return false + } } -function decryptRemoteHeaders(headers) { - const normalized = normalizeRemoteHeaders(headers) - const out = {} +/** + * Rewrite every stored desktop secret (v1 connection.json token/headers + + * per-profile overrides, v2 registry connections, native OAuth token store) + * through `reencode`. Returns true when any store was rewritten. Shared by + * the one-shot legacy migration and the Settings encryption toggle. + */ +function rewriteAllStoredSecrets(shouldRewrite: (secret: any) => boolean, reencode: (secret: any) => any): boolean { + let touched = false - for (const [name, secret] of Object.entries(normalized)) { - const value = decryptDesktopSecret(secret) + const rewriteBlock = (block: any) => { + if (!block || typeof block !== 'object') { + return block + } - if (value) { - out[name] = value + const next = { ...block, ...(block.token ? { token: reencode(block.token) } : {}) } + + if (block.headers && typeof block.headers === 'object') { + next.headers = Object.fromEntries(Object.entries(block.headers).map(([k, v]) => [k, reencode(v)])) } + + return next } - return out -} + const blockNeedsRewrite = (o: any) => + shouldRewrite(o?.token) || + Object.values(o?.headers && typeof o.headers === 'object' ? o.headers : {}).some(shouldRewrite) -/** - * Turn an editor payload of remote gateway headers into stored secret - * envelopes. The payload map is authoritative (a name missing from it is - * cleared); per-name values are: - * - non-empty string → new plaintext value, encrypted like a token - * - null → keep the currently stored envelope for that name - * (the editor shows a set-but-hidden secret) - * - envelope object → stored verbatim (hand-edited import path) - * Name filtering (forbidden/managed headers) happens in - * normalizeRemoteHeaders at the registry/config layer. - */ + // v1 connection.json. + const config = readDesktopConnectionConfig() + + if (blockNeedsRewrite(config.remote) || Object.values(config.profiles || {}).some(blockNeedsRewrite)) { + touched = true + writeDesktopConnectionConfig({ + ...config, + remote: rewriteBlock(config.remote), + profiles: Object.fromEntries(Object.entries(config.profiles || {}).map(([k, v]) => [k, rewriteBlock(v)])) + }) + } + + // v2 connections.json registry. + const registry = readDesktopConnectionsRegistry() + + if (registry.connections?.some(blockNeedsRewrite)) { + touched = true + writeDesktopConnectionsRegistry({ ...registry, connections: registry.connections.map(rewriteBlock) }) + } + + // Native OAuth token store: baseUrl → blob. + const io = _nativeTokenStoreIo() + + try { + const store = JSON.parse(io.readStoreText()) + + if (store && typeof store === 'object' && !Array.isArray(store)) { + const entries = Object.entries(store) + + if (entries.some(([, v]) => shouldRewrite(v))) { + touched = true + io.writeStoreText(JSON.stringify(Object.fromEntries(entries.map(([k, v]) => [k, reencode(v)])))) + } + } + } catch { + // Missing/corrupt native token store: nothing to rewrite. + } + + return touched +} + +/** + * One-shot legacy migration: builds before the opt-in policy wrote every + * secret as a safeStorage blob. With encryption now defaulting OFF, decrypt + * each stored blob once and rewrite it as plain so no future launch touches + * the keychain. Marked `migrated` whether or not every blob decrypts — a + * broken keychain costs at most ONE prompt (this pass), never one per + * launch; blobs that would not decrypt are left in place and simply read as + * absent from then on (classifyStoredSecret → 'drop'), so opting encryption + * back ON later can still recover them on a healthy keychain. + * + * Runs before createWindow() so every later read sees the final encodings. + */ +function migrateLegacyEncryptedSecretsOnce() { + const policy = secretStoragePolicy() + + if (policy.on || policy.migrated) { + return + } + + const needsMigration = (secret: any) => classifyStoredSecret(secret, policy) === 'migrate' + + const reencode = (secret: any) => { + if (!needsMigration(secret)) { + return secret + } + + const plaintext = decryptDesktopSecret(secret) + + // Undecryptable now (locked/absent keychain): keep the blob for a + // potential future opt-in, but post-migration reads treat it as unset. + return plaintext ? { encoding: 'plain', value: plaintext } : secret + } + + let touchedKeychain = false + + try { + touchedKeychain = rewriteAllStoredSecrets(needsMigration, reencode) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + + rememberLog(`[secret-storage] legacy migration pass failed: ${detail}`) + } + + setSecretStoragePolicy({ on: false, migrated: true }) + + if (touchedKeychain) { + rememberLog('[secret-storage] migrated legacy keychain-encrypted secrets to opt-out storage (one-shot pass)') + } +} + +/** + * Settings → Gateway toggle: flip keychain-backed encryption and re-encode + * every stored secret to match. Turning ON encrypts plain blobs through + * strict safeStorage (throws loudly when the keychain is unusable — the + * toggle stays off and the renderer shows the error). Turning OFF decrypts + * back to plain; this is user-initiated, so a keychain prompt here is + * expected and acceptable. + */ +function applySecretStorageEncryption(on: boolean) { + const enable = on === true + + if (secretStoragePolicy().on === enable) { + return { on: enable } + } + + if (enable) { + const needsEncrypt = (secret: any) => secret?.encoding === 'plain' && Boolean(secret.value) + + // Probe FIRST so an unusable keychain fails before any store is touched. + if ( + !(() => { + try { + return Boolean(safeStorage.isEncryptionAvailable()) + } catch { + return false + } + })() + ) { + throw new Error( + 'OS keychain encryption is unavailable on this machine, so stored gateway secrets cannot be encrypted.' + ) + } + + setSecretStoragePolicy({ on: true, migrated: true }) + + try { + rewriteAllStoredSecrets(needsEncrypt, secret => + needsEncrypt(secret) ? encryptDesktopSecretStrict(String(secret.value), safeStorage) : secret + ) + } catch (error) { + // Encryption failed midway: revert the policy so reads keep working + // against whatever encodings are on disk (mixed stores read fine — + // decryptDesktopSecret handles both encodings under either policy). + setSecretStoragePolicy({ on: false, migrated: true }) + throw error + } + + return { on: true } + } + + // Turning OFF: decrypt everything back to plain while the keychain is + // still readable, then flip the policy. + const needsDecrypt = (secret: any) => secret?.encoding === SAFE_STORAGE_ENCODING + + rewriteAllStoredSecrets(needsDecrypt, (secret: any) => { + if (!needsDecrypt(secret)) { + return secret + } + + const plaintext = decryptDesktopSecret(secret) + + return plaintext ? { encoding: 'plain', value: plaintext } : secret + }) + + setSecretStoragePolicy({ on: false, migrated: true }) + + return { on: false } +} + +function encryptDesktopSecret(value, options = {}) { + if (!secretStoragePolicy().on) { + const raw = String(value || '') + + return raw ? { encoding: 'plain', value: raw } : null + } + + return encryptDesktopSecretStrict(value, safeStorage, options) +} + +function decryptDesktopSecret(secret) { + if (!secret || typeof secret !== 'object') { + return '' + } + + const value = String(secret.value || '') + + if (!value) { + return '' + } + + if (secret.encoding === SAFE_STORAGE_ENCODING) { + // Legacy blob under an opted-out policy: once the one-shot migration pass + // has run, never touch safeStorage again — a dead keychain would otherwise + // prompt on every read. Before that pass, decryption is allowed so the + // migration itself (and this launch's reads) can recover the value. + if (classifyStoredSecret(secret, secretStoragePolicy()) === 'drop') { + return '' + } + + try { + return safeStorage.decryptString(Buffer.from(value, 'base64')) + } catch { + return '' + } + } + + // Any other encoding (a hand-edited config, or one written by a pre-release + // build) is returned verbatim on purpose: this fallback is what lets such a + // config connect at all. Not a plaintext-writing path — nothing in this file + // persists a token this way. + return value +} + +function decryptRemoteHeaders(headers) { + const normalized = normalizeRemoteHeaders(headers) + const out = {} + + for (const [name, secret] of Object.entries(normalized)) { + const value = decryptDesktopSecret(secret) + + if (value) { + out[name] = value + } + } + + return out +} + +/** + * Turn an editor payload of remote gateway headers into stored secret + * envelopes. The payload map is authoritative (a name missing from it is + * cleared); per-name values are: + * - non-empty string → new plaintext value, encrypted like a token + * - null → keep the currently stored envelope for that name + * (the editor shows a set-but-hidden secret) + * - envelope object → stored verbatim (hand-edited import path) + * Name filtering (forbidden/managed headers) happens in + * normalizeRemoteHeaders at the registry/config layer. + */ function encryptIncomingRemoteHeaders(raw, existing, options: { allowPlainText?: boolean } = {}) { const out = {} const stored = normalizeRemoteHeaders(existing) @@ -8484,6 +8877,11 @@ function writeDesktopConnectionConfig(config) { * connections.json does not exist yet). Same mtime-cache + tighten-mode * discipline as readDesktopConnectionConfig; a corrupt registry degrades to * local-only via normalizeRegistry rather than throwing at boot. + * + * An EXISTING registry is additionally reconciled against v1 when the two have + * drifted — see reconcileRegistryDrift. The one-shot migration cannot cover a + * user who registered nothing and then pointed Settings -> Gateway at a remote, + * and until that heals, every launch re-homes them onto a local backend. */ function readDesktopConnectionsRegistry() { let mtime = null @@ -8530,6 +8928,28 @@ function readDesktopConnectionsRegistry() { registry = normalizeRegistry(null) } + // Heal v1 -> v2 drift: the v1 global route names a remote this registry has + // never heard of, so the live descriptor resolves to no connectionId and the + // launch pick sends the window somewhere else. Persist so the repair is a + // one-time event rather than a recomputation on every read; a failed write + // still returns the healed registry for this session. + const reconciled = reconcileRegistryDrift(registry, readDesktopConnectionConfig()) + + if (reconciled.changed) { + registry = reconciled.registry + + try { + writeDesktopConnectionsRegistry(registry) + + return connectionRegistryCache + } catch { + connectionRegistryCache = registry + connectionRegistryCacheMtime = null + + return registry + } + } + connectionRegistryCache = registry connectionRegistryCacheMtime = mtime @@ -8571,15 +8991,10 @@ function sanitizeRegistryConnection(entry) { } function sanitizeConnectionsRegistry(registry = readDesktopConnectionsRegistry()) { - // Same keyring probe the v1 sanitize exposes: lets the Connections panel + // Same keyring signal the v1 sanitize exposes: lets the Connections panel // offer the plain-text opt-in on keyring-less Linux instead of failing. - let secureTokenStorage = false - - try { - secureTokenStorage = Boolean(safeStorage.isEncryptionAvailable()) - } catch { - secureTokenStorage = false - } + // Policy-aware: never touches safeStorage while encryption is opted out. + const secureTokenStorage = probeSecureTokenStorage() return { version: registry.version, @@ -8703,15 +9118,10 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon // Whether the OS keyring (safeStorage) can encrypt the saved token. When // false the renderer knows to offer the plain-text opt-in in Settings → - // Gateway. safeStorage.isEncryptionAvailable can throw on some platforms, so - // treat any failure as "not available". - let secureTokenStorage = false - - try { - secureTokenStorage = Boolean(safeStorage.isEncryptionAvailable()) - } catch { - secureTokenStorage = false - } + // Gateway. With keychain encryption opted out (the default) this reports + // true WITHOUT touching safeStorage — probing is itself a keychain touch + // that raises the macOS password dialog (see probeSecureTokenStorage). + const secureTokenStorage = probeSecureTokenStorage() // Whether the currently saved token is stored in plain text (the keyring-less // opt-in path). The env override supplies its token from the environment, not @@ -8967,13 +9377,7 @@ async function buildRemoteConnection( !oauthSessionIsLive(hasNativeSession(baseUrl), await hasLiveOauthSession(baseUrl)) && oauthGuardMayHardFail(await gatewayAuthProviders(baseUrl, remoteHeaders)) ) { - const err = new Error( - 'Remote Hermes gateway uses OAuth, but you are not signed in. ' + - 'Open Settings → Gateway and click "Sign in", or switch back to Local.' - ) as any - - err.needsOauthLogin = true - throw err + throw makeUnsignedOauthError() } let ticket @@ -9676,10 +10080,11 @@ async function testDesktopConnectionConfig(input: any = {}) { const wantRemote = modeIsRemoteLike(block?.mode) || (!key && modeIsRemoteLike(config.mode)) || (modeIsRemoteLike(input.mode) && block) - // ``/api/status`` is public on every gateway (no creds needed), so a - // reachability test works for local, token, and oauth modes alike — we only - // need a base URL. For a remote config we normalize the URL from the input; - // for local we fall back to the resolved/started backend. + // Test ``/api/status`` through the connection's real auth path. Self-hosted + // gateways may protect it, and an anonymous success/failure would not prove + // that the OAuth cookie/native bearer or configured token is reusable. For + // a remote config we normalize the URL from the input; for local we fall + // back to the resolved/started backend. let baseUrl let token = null let authMode = 'token' @@ -9701,7 +10106,7 @@ async function testDesktopConnectionConfig(input: any = {}) { testHeaders = remote.headers || {} } - const status = (await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000, headers: testHeaders })) as any + const status = (await fetchConnectionStatus(baseUrl, authMode, token, testHeaders)) as any // The HTTP status check above proves the backend is reachable, but the chat // surface only works once the renderer's live WebSocket to ``/api/ws`` @@ -9735,6 +10140,28 @@ async function testDesktopConnectionConfig(input: any = {}) { } } +async function fetchConnectionStatus(baseUrl, authMode, token, headers = {}) { + const url = `${baseUrl}/api/status` + + if (authMode === 'oauth') { + // Native PKCE bearer first, OAuth session cookies second — the same two + // credentials real traffic uses, in the same order. A refresh failure is + // NOT a silent downgrade to an anonymous probe: the cookie path is still + // an authenticated request, and if neither credential works the probe + // fails, which is the correct answer for a gateway we cannot reach with + // the credentials we hold. + const nativeAt = await ensureNativeAccessToken(baseUrl).catch(() => null) + + if (nativeAt) { + return fetchJson(url, null, { timeoutMs: 8_000, bearer: nativeAt, headers }) + } + + return fetchJsonViaOauthSession(url, { timeoutMs: 8_000, headers }) + } + + return fetchJson(url, token, { timeoutMs: 8_000, headers }) +} + function resetBootProgressForReconnect() { updateBootProgress( { @@ -10380,7 +10807,13 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po entry.process = child entry.token = token - await claimBackendChild(child, `${backend.command} ${backend.args.join(' ')}`, profile, backendNonce) + // Buffer stdout+stderr from the instant of spawn (#93608): an early crash's + // traceback must survive into the claim error and the before-ready exit + // message instead of a bare exit code. rememberLog attaches later, after + // the claim, and would miss anything printed before it. + const outputTail = createBackendOutputTail() + outputTail.attach(child) + await claimBackendChild(child, `${backend.command} ${backend.args.join(' ')}`, profile, backendNonce, outputTail) child.stdout.on('data', rememberLog) child.stderr.on('data', rememberLog) @@ -10405,13 +10838,18 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po if (!ready) { rejectStart?.( - new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`) + new Error( + `Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).${outputTail.describe()}` + ) ) } }) // Discover the ephemeral port the child bound to - const port = await Promise.race([waitForDashboardPortAnnouncement(child, { readyFile }), startFailed]) + const port = await Promise.race([ + waitForDashboardPortAnnouncement(child, { describeOutputTail: () => outputTail.describe(), readyFile }), + startFailed + ]) if (readyFile) { fs.unlink(readyFile, () => {}) @@ -10756,7 +11194,19 @@ async function startHermes() { }) ) - await claimBackendChild(hermesProcess, `${backend.command} ${backend.args.join(' ')}`, profile, backendNonce) + // Buffer stdout+stderr from the instant of spawn (#93608): an early + // crash's traceback must survive into the claim error and the + // before-ready exit message shown by the boot UI. rememberLog attaches + // later, after the claim, and would miss anything printed before it. + const primaryOutputTail = createBackendOutputTail() + primaryOutputTail.attach(hermesProcess) + await claimBackendChild( + hermesProcess, + `${backend.command} ${backend.args.join(' ')}`, + profile, + backendNonce, + primaryOutputTail + ) const processOwner = backendConnectionState.attachProcess(connectionAttempt, hermesProcess) if (!processOwner) { @@ -10815,7 +11265,7 @@ async function startHermes() { sendBackendExit({ code, signal }) if (!backendReady) { - const message = `Hermes backend exited before it became ready (${signal || code}).` + const message = `Hermes backend exited before it became ready (${signal || code}).${primaryOutputTail.describe()}` updateBootProgress( { error: message, @@ -10837,7 +11287,10 @@ async function startHermes() { // Discover the ephemeral port the child bound to const port = await Promise.race([ - waitForDashboardPortAnnouncement(hermesProcess, { readyFile }), + waitForDashboardPortAnnouncement(hermesProcess, { + describeOutputTail: () => primaryOutputTail.describe(), + readyFile + }), backendStartFailed ]) @@ -11003,13 +11456,15 @@ function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {}) if (zoom) { installZoomShortcuts(win) // Re-apply persisted zoom on show/restore/resize/cross-display move - // (Chromium can drop webContents zoom after these window transitions) and - // on EVERY full load — not once. The crash-recovery path calls - // webContents.reload(), which fires did-finish-load again after a `once` - // listener is spent, so zoom was silently lost on renderer crash - // recovery and any in-place reload/navigation (#46429). - installZoomReassertOnWindowEvents(win, () => restorePersistedZoomLevel(win)) - win.webContents.on('did-finish-load', () => restorePersistedZoomLevel(win)) + // (Chromium can drop webContents zoom after these window transitions), on + // EVERY full load — not once, since crash recovery reloads and would + // outlive a spent `once` listener (#46429) — and after in-page navigation, + // where Chromium applies the target hash route's own per-URL zoom record + // (see installZoomReassertOnNavigation; #48658, #38854, #79863). + const reassertZoom = () => restorePersistedZoomLevel(win) + + installZoomReassertOnWindowEvents(win, reassertZoom) + installZoomReassertOnNavigation(win.webContents, reassertZoom) } installContextMenuBridge(win) @@ -11152,6 +11607,87 @@ function createSessionWindow(sessionId, { watch = false } = {}) { return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch })) } +// Popped-out in-app Browser: same webview + address bar as a docked Browser +// tab, in its own OS window. One window per tab id (re-open focuses); closing +// it tells the other renderers so they can dock the tab again. +const browserWindows = createSessionWindowRegistry() + +function notifyBrowserPopoutClosed(tabId) { + if (typeof tabId !== 'string' || !tabId) { + return + } + + for (const other of BrowserWindow.getAllWindows()) { + if (!other.isDestroyed()) { + other.webContents.send('hermes:browser-popout:closed', tabId) + } + } +} + +function spawnBrowserWindow(tabId) { + const icon = getAppIconPath() + + const win = new BrowserWindow({ + width: BROWSER_WINDOW_WIDTH, + height: BROWSER_WINDOW_HEIGHT, + minWidth: BROWSER_WINDOW_MIN_WIDTH, + minHeight: BROWSER_WINDOW_MIN_HEIGHT, + title: 'Hermes', + titleBarStyle: 'hidden', + titleBarOverlay: getTitleBarOverlayOptions(), + trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, + ...chatWindowSurfaceOptions(), + icon, + show: false, + webPreferences: chatWindowWebPreferences(PRELOAD_PATH) + }) + + translucencyBackedWindows.add(win) + + if (IS_MAC) { + win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) + } + + wireWindowReveal(win) + + win.on('enter-full-screen', () => sendWindowStateChanged(true)) + win.on('leave-full-screen', () => sendWindowStateChanged(false)) + + streamThrottle.register(win) + wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat')) + attachRendererConsoleCapture(win, 'browser-window', rememberLog) + + installWindowRendererLifecycle(win, { + kind: 'browser', + callbacks: { + log: rememberLog, + reload: () => { + win.webContents.reload() + } + }, + reloadWindowMs: RENDERER_RELOAD_WINDOW_MS, + reloadMax: RENDERER_RELOAD_MAX, + recentReloadTimesRef: rendererReloadTimesRef + }) + + win.on('closed', () => notifyBrowserPopoutClosed(tabId)) + + loadWindowUrl( + win, + buildBrowserWindowUrl(tabId, { + devServer: DEV_SERVER, + rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex() + }), + 'Browser window' + ) + + return win +} + +function createBrowserWindow(tabId) { + return browserWindows.openOrFocus(tabId, () => spawnBrowserWindow(tabId)) +} + // Additional full "instance" windows — peers of the primary that render the // COMPLETE app (sidebar, routing, its own draft) against the shared backend, so // a user can run multiple GUI windows at once (⌘⇧N / the "New Window" palette @@ -11444,9 +11980,6 @@ let hudProfile = null // of a game chat frame, and where one belongs. Defaults only: once the user // moves or resizes the HUD, hud-state.json wins (same pattern as the main // window's window-state.json). -const HUD_WIDTH = 620 -const HUD_HEIGHT = 320 -const HUD_BOTTOM_MARGIN = 72 const HUD_STATE_PATH = path.join(app.getPath('userData'), 'hud-state.json') function readHudState() { @@ -11481,6 +12014,26 @@ function persistHudState() { } } +function resetHudWindowLayout(): boolean { + if (!hudWindow || hudWindow.isDestroyed()) { + return false + } + + const win = hudWindow + const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) + const bounds = defaultHudBounds(display?.workArea) + + if (!applyHudResetBounds(win, bounds)) { + rememberLog('[hud-state] reset layout failed while applying native bounds') + + return false + } + + persistHudState() + + return true +} + const schedulePersistHudState = debounce(persistHudState, 250) // How often Linux gets told where the cursor is. Fast enough that the bar is @@ -11492,8 +12045,12 @@ const HUD_CURSOR_POLL_MS = 60 // Snap-to-pointer — global ⌘⇧G while the HUD is open (tap, not hold). const HUD_SNAP_ANCHOR_Y = 48 +function hudWindowing() { + return resolveHudWindowing(process.platform, process.env, process.argv) +} + function applyHudSnapToPointer() { - if (!hudWindow || hudWindow.isDestroyed()) { + if (!hudWindow || hudWindow.isDestroyed() || !hudWindowing().clientPlacement) { return } @@ -11513,6 +12070,8 @@ function applyHudSnapToPointer() { // setBounds — NOT setPosition alone: on Windows, a transparent frameless // window silently grows ~1px per setPosition call (see move-by handler). + // On native Wayland the compositor ignores the position half; the snap + // shortcut is therefore a documented no-op there. hudWindow.setBounds({ x: origin.x, y: origin.y, @@ -11545,7 +12104,17 @@ function registerHudSnapShortcut() { * the main process. */ function startHudCursorFeed(win: BrowserWindow) { - if (process.platform !== 'linux') { + const windowing = hudWindowing() + + if (!windowing.cursorFeed) { + if (!windowing.ignoreMouse) { + try { + win.setIgnoreMouseEvents(false) + } catch { + // best effort + } + } + return } @@ -11574,6 +12143,67 @@ function startHudCursorFeed(win: BrowserWindow) { win.on('closed', () => clearInterval(timer)) } +/** + * Watch for a fullscreen app under the HUD (the Discord-style game overlay + * cue) and feed the answer to its renderer. Pure detection lives in + * hud-game-overlay.ts; enumeration is the same front-to-back walk the + * read_window_below tool uses. The renderer answers with the low-opacity + * treatment (`data-hud-game`), so main stays out of the styling business. + */ +function startHudGameOverlayFeed(win: BrowserWindow) { + const titlesAvailable = IS_MAC ? systemPreferences.getMediaAccessStatus?.('screen') === 'granted' : true + + let last = { active: false, app: '' } + + const push = (state: { active: boolean; app: string }) => { + if (!win.isDestroyed()) { + win.webContents.send('hermes:hud:game-overlay', state) + } + } + + // Replay the latest state to every load of this window. The watch pushes only + // on CHANGE and its first tick fires the moment the window is created — well + // before the renderer has mounted its listener — so a HUD opened over a game + // that is already fullscreen would hear the one and only message before it + // could receive it, then sit at "no game" forever while main was certain it + // had reported one. (Same reason quick entry caches its last state push.) + // did-finish-load also covers HMR full reloads during development. + win.webContents.on('did-finish-load', () => push(last)) + + // The watch gives up after two failed enumerations and never says so, which + // is how a HUD that cannot see the screen at all — no game cue, and + // read_window_below failing beside it — leaves nothing in the log to explain + // itself. Report the reason once; the null keeps the watch's contract. + let reported = false + + const enumerate = async () => { + const windows = await enumerateWindowsFrontToBack(process.pid, titlesAvailable) + + if (!enumerationFailed(windows)) { + return windows + } + + if (!reported) { + reported = true + console.warn(`[hermes] HUD cannot enumerate windows: ${windows.reason}`) + } + + return null + } + + const dispose = startHudGameOverlayWatch({ + enumerate, + displayBounds: () => screen.getDisplayMatching(win.getBounds()).bounds, + selfPid: process.pid, + send: state => { + last = state + push(state) + } + }) + + win.on('closed', dispose) +} + function hudBounds() { // Remembered spot first — validated against the LIVE displays so a HUD // parked on an unplugged monitor comes back on-screen instead of lost. @@ -11599,19 +12229,7 @@ function hudBounds() { const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) const area = display?.workArea - if (!area) { - return { width: HUD_WIDTH, height: HUD_HEIGHT, x: undefined, y: undefined } - } - - const width = Math.min(HUD_WIDTH, area.width) - const height = Math.min(HUD_HEIGHT, area.height) - - return { - width, - height, - x: Math.round(area.x + (area.width - width) / 2), - y: Math.round(Math.max(area.y, area.y + area.height - height - HUD_BOTTOM_MARGIN)) - } + return defaultHudBounds(area) } function hudUrl(sessionId, profile) { @@ -11654,7 +12272,7 @@ function spawnHudWindow(sessionId, profile) { // interprets pointer capture near the edge as a resize gesture, so the // window grows a few px every drag (worse at >100% DPI scaling). The // composer drag calls setPosition, which must move the window, not resize - // it. Resizing is done by the renderer's corner handle through + // it. Resizing is done by the renderer's edge/corner handles through // `hermes:hud:set-bounds`, which flips resizable on for the call — the // same pattern the pet overlay uses for its wheel-scale. resizable: false, @@ -11685,17 +12303,12 @@ function spawnHudWindow(sessionId, profile) { webPreferences: chatWindowWebPreferences(PRELOAD_PATH) }) - win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver') + applyHudElectronOverlay(win, process.platform) win.setHiddenInMissionControl?.(true) - try { - win.setVisibleOnAllWorkspaces( - true, - IS_MAC ? { visibleOnFullScreen: true, skipTransformProcessType: true } : undefined - ) - } catch { - // Not supported everywhere — best effort. - } + // Linux intentionally starts on ONE virtual desktop. During a renderer + // grab, hermes:hud:workspace-transfer temporarily makes the X11 window + // sticky; releasing it assigns the HUD to KDE's then-current desktop. // Streaming into a window that is ALWAYS blurred (the user is in another // app) is the entire feature, so it gets the same stream-aware unthrottling @@ -11708,6 +12321,7 @@ function spawnHudWindow(sessionId, profile) { bindGeometryPersistence(win, schedulePersistHudState) startHudCursorFeed(win) + startHudGameOverlayFeed(win) wireWindowReveal(win, { show: () => { @@ -11719,6 +12333,10 @@ function spawnHudWindow(sessionId, profile) { if (hudRestoreMainWindow && mainWindow && !mainWindow.isDestroyed()) { mainWindow.hide() } + + // Compositor overlay adapters (Hyprland float+pin today). Electron + // alwaysOnTop is already set; this is the dialect some WMs actually hear. + void promoteHudOverlay({ title: HUD_WINDOW_TITLE }) } }) @@ -12294,8 +12912,8 @@ ipcMain.handle('hermes:connection:revalidate', async () => { connectionPromise, currentConnectionPromise: () => backendConnectionState.getPromise(), log: rememberLog, - probe: fetchPublicJson, - resetConnection: resetHermesConnection, + probe: (connection, path, options) => fetchJsonForBackend(connection, path, options), + resetConnection: () => resetHermesConnection({ soft: true }), tracker: remoteLiveness }), revalidatePool() @@ -12325,7 +12943,7 @@ function revalidatePool() { return revalidatePooledRemoteBackends({ entries: backendPool.entries(), log: rememberLog, - probe: fetchPublicJson, + probe: (connection, path, options) => fetchJsonForBackend(connection, path, options), stopBackend: stopPoolBackend, tracker: remoteLiveness }) @@ -12353,6 +12971,15 @@ ipcMain.handle('hermes:window:openInstance', async () => { return { ok: true } }) +ipcMain.handle('hermes:window:openBrowser', async (_event, tabId) => { + if (typeof tabId !== 'string' || !tabId.trim()) { + return { ok: false, error: 'invalid-tab-id' } + } + + createBrowserWindow(tabId.trim()) + + return { ok: true } +}) // Hand a session to the user's OWN terminal emulator, running the TUI against // it (`hermes --tui --resume `). Not the in-app terminal pane: the point is @@ -12453,12 +13080,11 @@ registerPetOverlayIpc({ // --- HUD mode (chrome-free floating chat) — see hud-ipc.ts. --------------- const hudIpc = registerHudIpc({ isMac: IS_MAC, - isWindows: IS_WINDOWS, - glassSupported: GLASS_SUPPORTED, getTranslucencyState: () => translucencyState, getHudWindow: () => hudWindow, openHudWindow, closeHudWindow, + resetHudLayout: resetHudWindowLayout, setHudSessionId: value => { hudSessionId = value } @@ -12666,6 +13292,12 @@ ipcMain.handle('hermes:ssh-config:resolve', async (_event, host) => { }) ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload)) +// ── Opt-in keychain encryption for stored secrets ─────────────────────────── +// get returns the current policy without touching safeStorage; set flips it +// and re-encodes every stored secret (see applySecretStorageEncryption). +ipcMain.handle('hermes:secret-storage:get', async () => ({ on: secretStoragePolicy().on })) +ipcMain.handle('hermes:secret-storage:set', async (_event: any, on: any) => applySecretStorageEncryption(on === true)) + // ── v2 connection registry IPC (multi-source) ─────────────────────────────── // Storage-level CRUD for named agent sources. Routing/pooling consumption of // the registry lands separately; these handlers only manage the persisted @@ -12768,7 +13400,7 @@ ipcMain.handle('hermes:connections:test', async (_event, id) => { } } - const status = (await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000, headers: testHeaders })) as any + const status = (await fetchConnectionStatus(baseUrl, authMode, token, testHeaders)) as any // The Test button is the cheapest moment to (re)learn this backend's stable // identity for the same-backend roster collapse + Settings hint. @@ -13281,32 +13913,47 @@ ipcMain.handle('hermes:connection-config:save', async (_event, payload) => { return sanitizeDesktopConnectionConfig(config, payload?.profile) }) ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => { - const config = coerceDesktopConnectionConfig(payload) - writeDesktopConnectionConfig(config) + const previousConfig = readDesktopConnectionConfig() + const previousRegistry = readDesktopConnectionsRegistry() + const config = coerceDesktopConnectionConfig(payload, previousConfig) const key = connectionScopeKey(payload?.profile) const scope = key || '' - - await applyConnectionChange({ - cancelAndWait: value => sshBootstrapCoordinator.cancelAndWait(value), - isPrimary: !key || key === primaryProfileKey(), - rehomePrimary: () => - rehomePrimaryConnection({ - clearLocalBootstrapFailure: () => { - // A remote connection bypasses local runtime/bootstrap failures. Clear - // the local-install latch so unsupported/failure escape paths can re-home. - bootstrapFailure = null - }, - mode: config.mode, - notifyConnectionApplied: sendConnectionApplied, - resumeFirstRunRemote: abandonFirstRunSetupChoiceForRemoteApply, - teardownPrimaryBackend: teardownPrimaryBackendAndWait - }), - scope, - sendApplied: sendConnectionApplied, - stopPool: stopPoolBackend, - teardownPrimary: () => teardownPrimaryBackendAndWait({ soft: true }), - teardownSsh: value => teardownSshConnection(value || null) + const nextRegistry = key ? previousRegistry : reconcileAppliedGlobalConnection(previousRegistry, config) + + await applyConnectionConfigAtomically({ + previousConfig, + previousRegistry, + nextConfig: config, + nextRegistry, + // Exercise the same authenticated REST + real WebSocket legs before either + // config file changes. A rejected OAuth session or blocked /api/ws leaves + // the previous primary/current connection intact. + preflight: !key && modeIsRemoteLike(config.mode) ? () => testDesktopConnectionConfig(payload) : undefined, + writeConfig: writeDesktopConnectionConfig, + writeRegistry: writeDesktopConnectionsRegistry, + apply: () => + applyConnectionChange({ + cancelAndWait: value => sshBootstrapCoordinator.cancelAndWait(value), + isPrimary: !key || key === primaryProfileKey(), + rehomePrimary: () => + rehomePrimaryConnection({ + clearLocalBootstrapFailure: () => { + // A remote connection bypasses local runtime/bootstrap failures. Clear + // the local-install latch so unsupported/failure escape paths can re-home. + bootstrapFailure = null + }, + mode: config.mode, + notifyConnectionApplied: sendConnectionApplied, + resumeFirstRunRemote: abandonFirstRunSetupChoiceForRemoteApply, + teardownPrimaryBackend: teardownPrimaryBackendAndWait + }), + scope, + sendApplied: sendConnectionApplied, + stopPool: stopPoolBackend, + teardownPrimary: () => teardownPrimaryBackendAndWait({ soft: true }), + teardownSsh: value => teardownSshConnection(value || null) + }) }) return sanitizeDesktopConnectionConfig(config, payload?.profile) @@ -13443,10 +14090,21 @@ async function interceptSessionRequestForRemote(request) { // - global remote mode: ONE backend serves every profile via ?profile=, so // route there and KEEP the profile param so it opens the right state.db. if (/^\/api\/sessions\/[^/]+(\/messages)?$/.test(pathname)) { - const profile = (searchParams.get('profile') || request.profile || '').trim() + let profile = (searchParams.get('profile') || request.profile || '').trim() if (!profile) { - return undefined + // No explicit owner hint (#85834). The list endpoints above already know + // which remote profile owns each row (remoteSessionList tags s.profile), + // but a caller without a hint used to fall straight through to the LOCAL + // backend and 404 on its state.db even though the session lives on a + // remote. Consult the same remote lists to find the owner; only fall + // through when the id is genuinely unknown remotely. + const sessionId = decodeURIComponent(pathname.split('/')[3] || '') + profile = (await remoteOwnerProfileForSession(sessionId)) || '' + + if (!profile) { + return undefined + } } // Preserve every non-profile query param (limit/offset/order pagination — @@ -13505,6 +14163,39 @@ async function remoteSessionList(profile, searchParams) { return { ...(data as any), sessions: rowsOf(data) } } +// #85834: find which remote profile owns a session id when the caller gave no +// profile hint (pure lookup lives in profile-session-routing.ts). Results are +// memoized briefly so a burst of hint-less reads (transcript + messages) +// costs one sweep across the configured remotes. +const remoteOwnerBySessionId = new Map() +const REMOTE_OWNER_CACHE_TTL_MS = 30_000 + +async function remoteOwnerProfileForSession(sessionId: string) { + if (!sessionId) { + return null + } + + const remoteProfiles = configuredRemoteProfileNames() + + if (remoteProfiles.length === 0) { + return null + } + + const cached = remoteOwnerBySessionId.get(sessionId) + + if (cached && Date.now() - cached.at < REMOTE_OWNER_CACHE_TTL_MS) { + return cached.profile + } + + const owner = await findRemoteOwnerProfileForSession(sessionId, remoteProfiles, (profile, params) => + remoteSessionList(profile, params) + ).catch(() => null) + + remoteOwnerBySessionId.set(sessionId, { at: Date.now(), profile: owner }) + + return owner +} + // Resolve one /api/profiles/sessions slice with remote profiles spliced in — // the same branch logic as the GET /api/profiles/sessions intercept, but always // returns data (never `undefined`) so a batched caller can compose slices. A @@ -13646,6 +14337,43 @@ async function pooledRegistrySessionSources(): Promise return sources } +async function dispatchRegistryApiRequest( + request, + registryConnectionId, + routeProfile = request?.profile, + requestProfile = request?.profile +) { + const connection: any = await ensureRegistryBackend(registryConnectionId, routeProfile) + + const requestPath = pathForRegistryBackendRequest(request.path, requestProfile, connection) + + return fetchJsonForBackend(connection, requestPath, { + method: request?.method, + body: request?.body, + upload: request?.upload, + timeoutMs: resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) + }) +} + +function registryConnectionKind(connectionId) { + const registry = readDesktopConnectionsRegistry() + const source = registry.connections.find(connection => connection.id === connectionId) + + if (!source) { + throw new Error(`No connection with id "${connectionId}".`) + } + + return source.kind +} + +async function teardownConnectionScopedProfileBackend(connectionId, profile) { + const key = backendScopeKey(connectionId, profile) + await Promise.all([ + poolStopper.stop(key), + sshBootstrapCoordinator.cancelAndWait(key).then(() => teardownSshConnection(key)) + ]) +} + async function handleHermesApiRequest(request) { // Registry-pinned request (request.connectionId): the renderer is working // against a REGISTERED gateway connection, so the data — cron jobs and their @@ -13658,22 +14386,7 @@ async function handleHermesApiRequest(request) { const registryConnectionId = apiRequestRegistryConnectionId(request) if (registryConnectionId) { - const connection: any = await ensureRegistryBackend(registryConnectionId, request?.profile) - - // A shared remote host serves every profile via ?profile=; an SSH-scoped - // backend instead runs AS one remote profile, so an explicit self-profile - // filter must be translated from the desktop routing label into that - // backend namespace (same contract as the v1 profileRouteOptions path). - const requestPath = connection.sharedRemote - ? pathWithProfileScope(request.path, request?.profile) - : translateSelfProfileQuery(request.path, request?.profile, connection.remoteProfile) - - return fetchJsonForBackend(connection, requestPath, { - method: request?.method, - body: request?.body, - upload: request?.upload, - timeoutMs: resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) - }) + return dispatchRegistryApiRequest(request, registryConnectionId) } // Remote-profile session requests would otherwise hit the local primary off @@ -13784,6 +14497,20 @@ ipcMain.handle('hermes:api', async (_event, request) => { // respawn the old-name backend and recreate its HERMES_HOME (#45474). const deletingProfile = profileNameFromDeleteRequest(request) const mutatingProfile = deletingProfile || profileRenameFromRequest(request)?.oldName || null + const registryConnectionId = apiRequestRegistryConnectionId(request) + + if (deletingProfile && registryConnectionId) { + return dispatchConnectionScopedProfileDelete(request, { + acquire: profile => profileDeletionGate.acquire(profile), + connectionKind: connectionId => registryConnectionKind(connectionId), + dispatch: routeProfile => + dispatchRegistryApiRequest(request, registryConnectionId, routeProfile, deletingProfile), + isDefaultProfile: profile => profile === 'default', + isValidProfileName: profile => PROFILE_NAME_RE.test(profile), + prepareLocal: localRequest => prepareProfileDeleteRequest(localRequest).then(() => undefined), + teardownConnection: (connectionId, profile) => teardownConnectionScopedProfileBackend(connectionId, profile) + }) + } if (!mutatingProfile) { return handleHermesApiRequest(request) @@ -13976,6 +14703,27 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => { } }) +// Runtime desktop plugins load their FULL source through this door. +// `hermes:readFileText` is the *preview* read and silently truncates at +// TEXT_PREVIEW_MAX_BYTES (512 KiB) — for a plugin that means evaluating half a +// file. Dedicated generous cap, full read, and a hard EFBIG (via maxBytes) +// instead of truncation when the source exceeds it. +const PLUGIN_SOURCE_MAX_BYTES = 16 * 1024 * 1024 + +ipcMain.handle('hermes:readPluginSource', async (_event: unknown, filePath: unknown) => { + const { resolvedPath, stat } = await resolveReadableFileForIpc(filePath, { + maxBytes: PLUGIN_SOURCE_MAX_BYTES, + purpose: 'Plugin source' + }) + + return { + byteSize: stat.size, + path: resolvedPath, + text: await fs.promises.readFile(resolvedPath, 'utf8'), + truncated: false + } +}) + ipcMain.handle('hermes:selectPaths', async (_event, options: any = {}) => { const properties = options?.directories ? ['openDirectory'] : ['openFile'] @@ -14230,6 +14978,12 @@ app.on('before-quit', () => { } }) +// Close the pooled keep-alive sockets on quit so lingering connections can't +// hold the event loop open or leak FDs past app teardown. +app.on('will-quit', () => { + destroyKeepaliveAgents() +}) + // Answered synchronously so preload can publish the verdict before the // renderer's first script — see the note there on why it cannot decide this // itself. Registered at module scope, which runs long before any window. @@ -15064,6 +15818,13 @@ app.whenReady().then(() => { safeStorageApi: safeStorage }) + // Keychain encryption is opt-in (default OFF). One-shot: rewrite any + // legacy safeStorage-encrypted secrets as plain so no later launch ever + // touches the OS keychain unless the user turns encryption on in + // Settings → Gateway. Must run before createWindow() and the first + // connection resolution. + migrateLegacyEncryptedSecretsOnce() + if (IS_MAC) { Menu.setApplicationMenu(buildApplicationMenu()) } else { diff --git a/apps/desktop/electron/media-protocol.test.ts b/apps/desktop/electron/media-protocol.test.ts index 02d6e7bfa0a9..66d2e8e384e3 100644 --- a/apps/desktop/electron/media-protocol.test.ts +++ b/apps/desktop/electron/media-protocol.test.ts @@ -15,7 +15,7 @@ function dependencies(overrides: Partial = {}) { fetchRemote: vi.fn(async (_url: string, _headers: Headers) => new Response('remote', { status: 206 })), fetchRemoteWithCookies: vi.fn(async (_url: string, _headers: Headers) => new Response('cookie', { status: 206 })), resolveLocalFile: vi.fn(async (filePath: string) => filePath), - resolveRemoteConnection: vi.fn(async (_profile?: string) => ({ + resolveRemoteConnection: vi.fn(async (_scope: { connectionId?: string; profile?: string }) => ({ authMode: 'token' as const, baseUrl: 'https://gateway.test', mode: 'remote' as const, @@ -105,13 +105,13 @@ describe('createMediaProtocolHandler', () => { }) const response = await createMediaProtocolHandler(deps)( - request('hermes-media://remote/%2Froot%2Foutputs%2Frender.mp4?profile=reviewer', { + request('hermes-media://remote/%2Froot%2Foutputs%2Frender.mp4?connectionId=work-ssh&profile=reviewer', { Range: 'bytes=0-1023' }) ) expect(response.status).toBe(206) - expect(deps.resolveRemoteConnection).toHaveBeenCalledWith('reviewer') + expect(deps.resolveRemoteConnection).toHaveBeenCalledWith({ connectionId: 'work-ssh', profile: 'reviewer' }) expect(deps.fetchRemote).toHaveBeenCalledOnce() const [rawUrl, headers] = vi.mocked(deps.fetchRemote).mock.calls[0] const url = new URL(rawUrl) @@ -122,6 +122,28 @@ describe('createMediaProtocolHandler', () => { expect(headers.get('range')).toBe('bytes=0-1023') }) + it('adds profile scope when one registry backend serves multiple profiles', async () => { + const deps = dependencies({ + resolveRemoteConnection: vi.fn(async () => ({ + authMode: 'token' as const, + baseUrl: 'https://gateway.test', + mode: 'remote' as const, + sharedRemote: true, + token: 'secret' + })) + }) + + await createMediaProtocolHandler(deps)( + request('hermes-media://remote/%2Froot%2Foutputs%2Frender.mp4?connectionId=cloud&profile=research') + ) + + const [rawUrl] = vi.mocked(deps.fetchRemote).mock.calls[0] + const url = new URL(rawUrl) + + expect(url.searchParams.get('path')).toBe('/root/outputs/render.mp4') + expect(url.searchParams.get('profile')).toBe('research') + }) + it('preserves explicit HEAD requests through the token-auth remote proxy', async () => { const fetchRemote = vi.fn(async (..._args: unknown[]) => new Response(null, { status: 200 })) diff --git a/apps/desktop/electron/media-protocol.ts b/apps/desktop/electron/media-protocol.ts index 1ec7d8617b3e..2e3cc946159d 100644 --- a/apps/desktop/electron/media-protocol.ts +++ b/apps/desktop/electron/media-protocol.ts @@ -19,16 +19,23 @@ export const MEDIA_PROTOCOL = 'hermes-media' type MediaProtocolMode = 'remote' | 'stream' interface MediaProtocolTarget { + connectionId?: string filePath: string mode: MediaProtocolMode profile?: string } +export interface MediaRemoteScope { + connectionId?: string + profile?: string +} + export interface MediaRemoteConnection { authMode?: 'oauth' | 'token' baseUrl: string mode?: 'local' | 'remote' token?: null | string + sharedRemote?: boolean } type MediaRequestMethod = 'GET' | 'HEAD' @@ -39,7 +46,7 @@ export interface MediaProtocolDependencies { fetchRemote: (url: string, headers: Headers, method: MediaRequestMethod) => Promise fetchRemoteWithCookies: (url: string, headers: Headers, method: MediaRequestMethod) => Promise resolveLocalFile: (filePath: string) => Promise - resolveRemoteConnection: (profile?: string) => Promise + resolveRemoteConnection: (scope: MediaRemoteScope) => Promise } function parseMediaProtocolTarget(rawUrl: string): MediaProtocolTarget { @@ -56,9 +63,10 @@ function parseMediaProtocolTarget(rawUrl: string): MediaProtocolTarget { throw new Error('Missing media path') } + const connectionId = url.searchParams.get('connectionId')?.trim() || undefined const profile = url.searchParams.get('profile')?.trim() || undefined - return { filePath, mode, profile } + return { connectionId, filePath, mode, profile } } export function isStreamableMediaPath(filePath: string): boolean { @@ -81,7 +89,7 @@ export function mediaRequestHeaders(source: Headers): Headers { return forwarded } -export function remoteMediaEndpoint(baseUrl: string, filePath: string): string { +export function remoteMediaEndpoint(baseUrl: string, filePath: string, profile?: string): string { const normalizedBase = baseUrl.replace(/\/+$/, '') const url = new URL(`${normalizedBase}/api/files/stream`) @@ -91,6 +99,10 @@ export function remoteMediaEndpoint(baseUrl: string, filePath: string): string { url.searchParams.set('path', filePath) + if (profile) { + url.searchParams.set('profile', profile) + } + return url.toString() } @@ -133,13 +145,20 @@ export function createMediaProtocolHandler(dependencies: MediaProtocolDependenci } try { - const connection = await dependencies.resolveRemoteConnection(target.profile) + const connection = await dependencies.resolveRemoteConnection({ + connectionId: target.connectionId, + profile: target.profile + }) if (connection.mode !== 'remote') { return new Response('Remote media backend unavailable', { status: 404 }) } - const endpoint = remoteMediaEndpoint(connection.baseUrl, target.filePath) + const endpoint = remoteMediaEndpoint( + connection.baseUrl, + target.filePath, + connection.sharedRemote ? target.profile : undefined + ) if (connection.authMode === 'oauth') { const bearer = await dependencies.ensureRemoteBearer(connection.baseUrl) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 7eac06bd52e9..f406f67cdfcd 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -8,6 +8,8 @@ import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' // "Desktop IPC bridge is unavailable"). No reply means no glass, which degrades // to an ordinary opaque window rather than a page thinned over nothing. const translucencySupport = ipcRenderer.sendSync('hermes:translucency:support') +const hudWindowing = ipcRenderer.sendSync('hermes:hud:windowing') +const hudNativeDrag = hudWindowing?.nativeDrag === true contextBridge.exposeInMainWorld('hermesDesktop', { glassSupported: translucencySupport?.glass === true, @@ -27,6 +29,13 @@ contextBridge.exposeInMainWorld('hermesDesktop', { openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts), openSessionInTerminal: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openInTerminal', sessionId, opts), openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'), + openBrowserWindow: tabId => ipcRenderer.invoke('hermes:window:openBrowser', tabId), + onBrowserPopoutClosed: callback => { + const listener = (_event, tabId) => callback(tabId) + ipcRenderer.on('hermes:browser-popout:closed', listener) + + return () => ipcRenderer.removeListener('hermes:browser-popout:closed', listener) + }, claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key), wakeIndicator: { getState: () => ipcRenderer.invoke('hermes:wake-indicator:get'), @@ -70,11 +79,20 @@ contextBridge.exposeInMainWorld('hermesDesktop', { // sized as a floating bar, so it mounts the real composer. Main owns the // window; `onChanged` keeps every window's toggle truthful. hud: { + nativeDrag: hudNativeDrag, + windowing: { + clientPlacement: hudWindowing?.clientPlacement !== false, + controlDrag: hudWindowing?.controlDrag === true, + nativeDrag: hudNativeDrag, + workspaceTransfer: hudWindowing?.workspaceTransfer === true + }, open: request => ipcRenderer.invoke('hermes:hud:open', request), close: () => ipcRenderer.invoke('hermes:hud:close'), setIgnoreMouse: ignore => ipcRenderer.send('hermes:hud:ignore-mouse', ignore), moveBy: delta => ipcRenderer.send('hermes:hud:move-by', delta), + setWorkspaceTransfer: transferring => ipcRenderer.send('hermes:hud:workspace-transfer', transferring), setBounds: bounds => ipcRenderer.send('hermes:hud:set-bounds', bounds), + resetLayout: () => ipcRenderer.invoke('hermes:hud:reset-layout'), // Whether the band covers the window below the bar. Main pairs it with the // user's translucency setting to decide the native frost (macOS vibrancy / // Windows 11 DWM backdrop) — see hudFrostFor. @@ -103,6 +121,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', { ipcRenderer.on('hermes:hud:cursor', listener) return () => ipcRenderer.removeListener('hermes:hud:cursor', listener) + }, + // Main's game-overlay watch: whether a fullscreen app (a game) is under + // the HUD, so the renderer can step back to the low-opacity overlay + // treatment while one owns the screen. + onGameOverlay: callback => { + const listener = (_event, state) => callback(state) + ipcRenderer.on('hermes:hud:game-overlay', listener) + + return () => ipcRenderer.removeListener('hermes:hud:game-overlay', listener) } }, // Quick Entry: the global-hotkey mini composer window. Main owns the OS @@ -145,6 +172,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', { saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload), testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload), + // Opt-in OS-keychain encryption for stored gateway secrets (default off — + // see secret-storage-policy.ts). get never touches the OS keychain. + getSecretStorageEncryption: () => ipcRenderer.invoke('hermes:secret-storage:get'), + setSecretStorageEncryption: (on: boolean) => ipcRenderer.invoke('hermes:secret-storage:set', on), // v2 multi-connection registry: named agent sources (local / remote / cloud / ssh). connections: { list: () => ipcRenderer.invoke('hermes:connections:list'), @@ -196,6 +227,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { set: maxMb => ipcRenderer.invoke('hermes:data-url-read-max:set', maxMb) }, readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath), + readPluginSource: (filePath: string) => ipcRenderer.invoke('hermes:readPluginSource', filePath), selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options), selectSavePath: options => ipcRenderer.invoke('hermes:selectSavePath', options), writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text), diff --git a/apps/desktop/electron/profile-delete-routing.test.ts b/apps/desktop/electron/profile-delete-routing.test.ts index df8ff09a079f..a63e864bc962 100644 --- a/apps/desktop/electron/profile-delete-routing.test.ts +++ b/apps/desktop/electron/profile-delete-routing.test.ts @@ -5,6 +5,7 @@ import { test } from 'vitest' import { assertLocalProfileCanStart, decideProfileDeleteAction, + dispatchConnectionScopedProfileDelete, localProfilePoolKeys, ProfileDeletionGate, profileNameFromDeleteRequest, @@ -151,3 +152,174 @@ test('localProfilePoolKeys returns every local process scope for one profile', ( test('resolveRouteProfile preserves a primary-backend route from another routing policy', () => { assert.equal(resolveRouteProfile(null, null), null) }) + +test('explicit registered local DELETE holds one gate through teardown and dispatch', async () => { + const events: string[] = [] + const gate = new ProfileDeletionGate() + + const result = await dispatchConnectionScopedProfileDelete( + { connectionId: 'local', method: 'DELETE', path: '/api/profiles/worker', profile: 'worker' }, + { + ...deps, + acquire: profile => { + events.push(`gate:${profile}`) + const release = gate.acquire(profile) + + return () => { + events.push(`release:${profile}`) + release() + } + }, + connectionKind: () => 'local', + dispatch: async routeProfile => { + assert.equal(gate.blocks('worker'), true) + events.push(`dispatch:${routeProfile ?? 'primary'}`) + + return 'deleted' + }, + prepareLocal: async request => { + assert.equal(gate.blocks('worker'), true) + events.push(`prepare:${request.connectionId}:worker`) + }, + teardownConnection: async () => assert.fail('local deletion must use prepareProfileDeleteRequest') + } + ) + + assert.equal(result, 'deleted') + assert.deepEqual(events, ['gate:worker', 'prepare:local:worker', 'dispatch:primary', 'release:worker']) +}) + +test('explicit registered SSH DELETE tears down its source backend before dispatch', async () => { + const events: string[] = [] + + const result = await dispatchConnectionScopedProfileDelete( + { connectionId: 'build-host', method: 'DELETE', path: '/api/profiles/worker', profile: 'worker' }, + { + ...deps, + acquire: profile => { + events.push(`gate:${profile}`) + + return () => events.push(`release:${profile}`) + }, + connectionKind: () => 'ssh', + dispatch: async routeProfile => { + events.push(`dispatch:${routeProfile ?? 'primary'}`) + + return 'deleted' + }, + prepareLocal: async () => assert.fail('SSH deletion must not use local profile teardown'), + teardownConnection: async (connectionId, profile) => { + events.push(`teardown:${connectionId}:${profile}`) + } + } + ) + + assert.equal(result, 'deleted') + assert.deepEqual(events, ['gate:worker', 'teardown:build-host:worker', 'dispatch:primary', 'release:worker']) +}) + +test('non-identity connection DELETE tears down the logical pool and deletes the backend target', async () => { + const events: string[] = [] + + const request = { + connectionId: 'source-a', + method: 'DELETE', + path: '/api/profiles/backend-worker', + profile: 'worker' + } + + await dispatchConnectionScopedProfileDelete(request, { + ...deps, + acquire: profile => { + events.push(`gate:${profile}`) + + return () => events.push(`release:${profile}`) + }, + connectionKind: () => 'ssh', + dispatch: async routeProfile => { + events.push(`delete:${request.path}:${routeProfile ?? 'primary'}`) + + return 'deleted' + }, + prepareLocal: async () => assert.fail('remote deletion must not use local profile teardown'), + teardownConnection: async (connectionId, profile) => { + events.push(`teardown:${connectionId}:${profile}`) + assert.notEqual(profile, 'backend-worker', 'must not stop the backend-target pool') + } + }) + + assert.deepEqual(events, [ + 'gate:backend-worker', + 'teardown:source-a:worker', + 'delete:/api/profiles/backend-worker:primary', + 'release:backend-worker' + ]) +}) + +test('connection-scoped default DELETE rejects before gate, preparation, teardown, or dispatch', async () => { + const events: string[] = [] + + await assert.rejects( + dispatchConnectionScopedProfileDelete( + { connectionId: 'build-host', method: 'DELETE', path: '/api/profiles/default', profile: 'default' }, + { + acquire: profile => { + events.push(`gate:${profile}`) + + return () => events.push(`release:${profile}`) + }, + connectionKind: () => 'ssh', + dispatch: async () => { + events.push('dispatch') + + return 'deleted' + }, + isDefaultProfile: profile => profile === 'default', + isValidProfileName: profile => /^[a-z0-9][a-z0-9_-]{0,63}$/.test(profile), + prepareLocal: async () => { + events.push('prepare') + }, + teardownConnection: async () => { + events.push('teardown') + } + } + ), + /default profile cannot be deleted/i + ) + + assert.deepEqual(events, []) +}) + +test('connection-scoped invalid-name DELETE rejects before gate, preparation, teardown, or dispatch', async () => { + const events: string[] = [] + + await assert.rejects( + dispatchConnectionScopedProfileDelete( + { connectionId: 'local', method: 'DELETE', path: '/api/profiles/bad%20name', profile: 'bad name' }, + { + acquire: profile => { + events.push(`gate:${profile}`) + + return () => events.push(`release:${profile}`) + }, + connectionKind: () => 'local', + dispatch: async () => { + events.push('dispatch') + + return 'deleted' + }, + isDefaultProfile: profile => profile === 'default', + isValidProfileName: profile => /^[a-z0-9][a-z0-9_-]{0,63}$/.test(profile), + prepareLocal: async () => { + events.push('prepare') + }, + teardownConnection: async () => { + events.push('teardown') + } + } + ), + /invalid profile name/i + ) + + assert.deepEqual(events, []) +}) diff --git a/apps/desktop/electron/profile-delete-routing.ts b/apps/desktop/electron/profile-delete-routing.ts index 3338fe2420c8..39bada976a7c 100644 --- a/apps/desktop/electron/profile-delete-routing.ts +++ b/apps/desktop/electron/profile-delete-routing.ts @@ -60,6 +60,65 @@ export interface ProfileDeleteDecisionDeps { primaryProfileKey: () => string } +export interface ConnectionScopedProfileDeleteRequest { + connectionId?: unknown + method?: unknown + path?: unknown + profile?: unknown +} + +export interface ConnectionScopedProfileDeleteDeps { + acquire: (profile: string) => () => void + connectionKind: (connectionId: string) => string + dispatch: (routeProfile: null) => Promise + isDefaultProfile: (profile: string) => boolean + isValidProfileName: (profile: string) => boolean + prepareLocal: (request: ConnectionScopedProfileDeleteRequest) => Promise + teardownConnection: (connectionId: string, profile: string) => Promise +} + +/** + * Run an explicit registry profile DELETE under the same process-wide gate as + * legacy deletion. Teardown and dispatch are injected so the Electron caller + * can stop either local profile pools or one connection-qualified backend. + * Dispatch deliberately receives a null route profile: resolving the deleted + * profile again would recurse into the gate (and could recreate its home). + */ +export async function dispatchConnectionScopedProfileDelete( + request: ConnectionScopedProfileDeleteRequest, + deps: ConnectionScopedProfileDeleteDeps +): Promise { + const targetProfile = profileNameFromDeleteRequest(request) + const logicalProfile = String(request.profile ?? '').trim() || targetProfile || '' + const connectionId = String(request.connectionId ?? '').trim() + + if (!targetProfile || !connectionId) { + throw new Error('Connection-scoped profile deletion requires a connection and profile.') + } + + if (deps.isDefaultProfile(targetProfile)) { + throw new Error('The default profile cannot be deleted.') + } + + if (!deps.isValidProfileName(targetProfile)) { + throw new Error(`Invalid profile name: ${targetProfile}`) + } + + const release = deps.acquire(targetProfile) + + try { + if (deps.connectionKind(connectionId) === 'local') { + await deps.prepareLocal(request) + } else { + await deps.teardownConnection(connectionId, logicalProfile) + } + + return await deps.dispatch(null) + } finally { + release() + } +} + /** * Process-local barrier for profile deletion. Electron IPC handlers run * concurrently, so tearing down a pooled backend is not enough by itself: a diff --git a/apps/desktop/electron/profile-session-routing.test.ts b/apps/desktop/electron/profile-session-routing.test.ts index 1c6b916c199a..8cc530ff9923 100644 --- a/apps/desktop/electron/profile-session-routing.test.ts +++ b/apps/desktop/electron/profile-session-routing.test.ts @@ -7,6 +7,7 @@ import { fetchPrimaryProfileSessions, fetchRegistrySessionRows, fetchRemoteProfileSessions, + findRemoteOwnerProfileForSession, mergeProfileSessionWindow, spliceRegistrySessionRows } from './profile-session-routing' @@ -374,3 +375,43 @@ test('splice: registry rows dedupe by id and extend per-profile totals', () => { assert.equal(totals.default, 2) // untagged registry row counts under default assert.equal(totals.work, 1) // deduped row does not double-count }) + +test('finds the remote owner profile for a hint-less session read (#85834)', async () => { + const owner = await findRemoteOwnerProfileForSession('sess-remote', ['vps-a', 'vps-b'], async profile => { + if (profile === 'vps-b') { + return { sessions: [{ id: 'sess-remote' }] } as never + } + + return { sessions: [{ id: 'other' }] } as never + }) + + assert.equal(owner, 'vps-b') +}) + +test('remote owner lookup matches a compression lineage root id too', async () => { + const owner = await findRemoteOwnerProfileForSession('root-1', ['vps-a'], async () => { + return { sessions: [{ id: 'tip-2', _lineage_root_id: 'root-1' }] } as never + }) + + assert.equal(owner, 'vps-a') +}) + +test('remote owner lookup returns null when no remote lists the id or remotes fail', async () => { + const missing = await findRemoteOwnerProfileForSession('sess-x', ['vps-a'], async () => { + return { sessions: [{ id: 'other' }] } as never + }) + + assert.equal(missing, null) + + const dead = await findRemoteOwnerProfileForSession('sess-x', ['vps-a'], async () => { + throw new Error('remote unavailable') + }) + + assert.equal(dead, null) + + const noRemotes = await findRemoteOwnerProfileForSession('sess-x', [], async () => { + throw new Error('never called') + }) + + assert.equal(noRemotes, null) +}) diff --git a/apps/desktop/electron/profile-session-routing.ts b/apps/desktop/electron/profile-session-routing.ts index e096b7324d2e..c6694010219e 100644 --- a/apps/desktop/electron/profile-session-routing.ts +++ b/apps/desktop/electron/profile-session-routing.ts @@ -374,3 +374,36 @@ export async function fetchRemoteProfileSessions( offset: requestedOffset } } + +/** + * #85834: which remote profile owns `sessionId`, when a /api/sessions/{id} + * caller supplied no profile hint. Reads the same per-remote lists the list + * endpoints splice into the sidebar (each fetch is per-profile, so a hit IS + * the owner). Dead remotes contribute nothing; returns null when no remote + * lists the id — the intercept then falls through to the local backend + * exactly as before. + */ +export async function findRemoteOwnerProfileForSession( + sessionId: string, + remoteProfiles: readonly string[], + listForProfile: (profile: string, searchParams: URLSearchParams) => Promise +): Promise { + if (!sessionId || remoteProfiles.length === 0) { + return null + } + + const params = new URLSearchParams() + params.set('limit', '200') + params.set('offset', '0') + + const matches = await Promise.all( + remoteProfiles.map(async profile => { + const list = await listForProfile(profile, params).catch(() => null) + const rows = Array.isArray(list?.sessions) ? (list.sessions as Array>) : [] + + return rows.some(row => row?.id === sessionId || row?._lineage_root_id === sessionId) ? profile : null + }) + ) + + return matches.find(profile => profile !== null) ?? null +} diff --git a/apps/desktop/electron/remote-liveness.test.ts b/apps/desktop/electron/remote-liveness.test.ts index 950d6fb1f58a..38874bc56012 100644 --- a/apps/desktop/electron/remote-liveness.test.ts +++ b/apps/desktop/electron/remote-liveness.test.ts @@ -179,9 +179,13 @@ describe('revalidateRemoteConnection', () => { const test = harness() await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: false }) - expect(test.probe).toHaveBeenCalledWith('https://gateway.example.com/api/status', { - timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS - }) + expect(test.probe).toHaveBeenCalledWith( + expect.objectContaining({ baseUrl: 'https://gateway.example.com/' }), + '/api/status', + { + timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS + } + ) expect(test.resetConnection).not.toHaveBeenCalled() }) @@ -254,13 +258,29 @@ describe('revalidateRemoteConnection', () => { }) describe('revalidatePooledRemoteBackends', () => { - const harness = (entries: Array<[string, { process?: unknown; remoteBaseUrl?: null | string }]>) => { + interface TestRemoteConnection { + authMode?: string + baseUrl: string + process?: unknown + remoteBaseUrl?: null | string + } + + const harness = ( + rawEntries: Array<[string, { process?: unknown; remoteBaseUrl?: null | string; authMode?: string }]> + ) => { + const entries: Array<[string, TestRemoteConnection & { connectionPromise: Promise }]> = + rawEntries.map(([profile, entry]) => { + const connection = { ...entry, baseUrl: String(entry.remoteBaseUrl || '') } + + return [profile, { ...connection, connectionPromise: Promise.resolve(connection) }] + }) + const unreachable = new Set() const log = vi.fn() const stopBackend = vi.fn() - const probe = vi.fn(async (url: string) => { - if ([...unreachable].some(base => url.startsWith(base))) { + const probe = vi.fn(async (connection: TestRemoteConnection) => { + if ([...unreachable].some(base => connection.remoteBaseUrl?.startsWith(base))) { throw new Error('unreachable') } @@ -291,7 +311,26 @@ describe('revalidatePooledRemoteBackends', () => { await pool.run(new RemoteLivenessTracker()) expect(pool.probe).toHaveBeenCalledTimes(1) - expect(pool.probe).toHaveBeenCalledWith('https://remote.example.com/api/status', { + expect(pool.probe).toHaveBeenCalledWith( + expect.objectContaining({ remoteBaseUrl: 'https://remote.example.com' }), + '/api/status', + { timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS } + ) + expect(pool.stopBackend).not.toHaveBeenCalled() + }) + + it('passes the authenticated OAuth descriptor to the liveness probe', async () => { + const remote = { + process: null, + remoteBaseUrl: 'https://remote.example.com', + authMode: 'oauth' + } + + const pool = harness([['oauth', remote]]) + + await pool.run(new RemoteLivenessTracker()) + + expect(pool.probe).toHaveBeenCalledWith(expect.objectContaining({ authMode: 'oauth' }), '/api/status', { timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS }) expect(pool.stopBackend).not.toHaveBeenCalled() diff --git a/apps/desktop/electron/remote-liveness.ts b/apps/desktop/electron/remote-liveness.ts index 6e29b395c483..52c649bf1d8a 100644 --- a/apps/desktop/electron/remote-liveness.ts +++ b/apps/desktop/electron/remote-liveness.ts @@ -19,7 +19,7 @@ export interface RevalidateRemoteConnectionOptions currentConnectionPromise: () => null | Promise log: (message: string) => void - probe: (url: string, options: { timeoutMs: number }) => Promise + probe: (connection: TConnection, path: string, options: { timeoutMs: number }) => Promise resetConnection: () => void tracker: RemoteLivenessTracker } @@ -117,15 +117,16 @@ export class RemoteLivenessTracker { } } -export interface PooledRemoteEntry { +export interface PooledRemoteEntry { + connectionPromise?: null | Promise process?: unknown remoteBaseUrl?: null | string } -export interface RevalidatePooledRemoteBackendsOptions { - entries: Iterable<[string, PooledRemoteEntry]> +export interface RevalidatePooledRemoteBackendsOptions { + entries: Iterable<[string, PooledRemoteEntry]> log: (message: string) => void - probe: (url: string, options: { timeoutMs: number }) => Promise + probe: (connection: TConnection, path: string, options: { timeoutMs: number }) => Promise stopBackend: (profile: string) => void tracker: RemoteLivenessTracker } @@ -141,13 +142,13 @@ export interface RevalidatePooledRemoteBackendsOptions { * Entries share the primary's failure policy, keyed per base URL, so a profile * pointing at the same host as another does not burn the streak twice as fast. */ -export async function revalidatePooledRemoteBackends({ +export async function revalidatePooledRemoteBackends({ entries, log, probe, stopBackend, tracker -}: RevalidatePooledRemoteBackendsOptions): Promise<{ dropped: string[] }> { +}: RevalidatePooledRemoteBackendsOptions): Promise<{ dropped: string[] }> { const remotes = [...entries].filter(([, entry]) => !entry.process && entry.remoteBaseUrl) const dropped: string[] = [] @@ -156,7 +157,12 @@ export async function revalidatePooledRemoteBackends({ const baseUrl = String(entry.remoteBaseUrl).replace(/\/+$/, '') try { - await probe(`${baseUrl}/api/status`, { timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS }) + if (!entry.connectionPromise) { + throw new Error('Remote backend descriptor is unavailable.') + } + + const connection = await entry.connectionPromise + await probe(connection, '/api/status', { timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS }) tracker.recordSuccess(baseUrl) } catch { const failure = tracker.recordFailure(baseUrl) @@ -212,7 +218,7 @@ export async function revalidateRemoteConnectioni.map(i=>d[i]);', + 'const load=()=>import("./syntax-diff").then(m=>m.default);', + '__vite__mapDeps([0,1])' +].join('\n') + +test('parseLazyChunkRefs reads the __vite__mapDeps filename table, not call sites', () => { + assert.deepEqual(parseLazyChunkRefs(ENTRY_JS), [ + 'assets/syntax-diff-Bo0962zh.js', + 'assets/shiki-block-COiz1pEN.js', + 'assets/mermaid-embed-Cq7Xw2aa.js' + ]) +}) + +test('parseLazyChunkRefs returns [] for chunk-free/nullish js', () => { + assert.deepEqual(parseLazyChunkRefs(''), []) + assert.deepEqual(parseLazyChunkRefs(undefined as unknown as string), []) + assert.deepEqual(parseLazyChunkRefs('console.log("no deps table here")'), []) +}) + +// deps helper for the graph walk: per-file contents, not one shared html. +function graphDepsFor(indexDir: string, files: Record): RendererBundleDeps { + const byPath = new Map(Object.entries(files).map(([rel, content]) => [path.join(indexDir, rel), content])) + + return { + readFileSync: (file: string) => { + const content = byPath.get(file) + + if (content === undefined) { + throw new Error(`ENOENT: ${file}`) + } + + return content + }, + existsSync: (file: string) => byPath.has(file) + } +} + +const GRAPH_INDEX_HTML = [ + '', + '' +].join('\n') + +test('missingRendererAssets: generation with all lazy chunks present is intact', () => { + const deps = graphDepsFor(INDEX_DIR, { + 'index.html': GRAPH_INDEX_HTML, + 'assets/index-a1b2c3.js': ENTRY_JS, + 'assets/syntax-diff-Bo0962zh.js': '// present', + 'assets/shiki-block-COiz1pEN.js': '// present', + 'assets/mermaid-embed-Cq7Xw2aa.js': '// present' + }) + + assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), []) +}) + +test('missingRendererAssets: torn lazy chunk is detected even when every preload exists', () => { + // The exact #93479 shape: index.html's own module list checks out, but the + // syntax-diff chunk behind React.lazy() never landed. Loading this copy + // boots fine and then blanks the workspace on the first diff render. + const deps = graphDepsFor(INDEX_DIR, { + 'index.html': GRAPH_INDEX_HTML, + 'assets/index-a1b2c3.js': ENTRY_JS, + 'assets/shiki-block-COiz1pEN.js': '// present', + 'assets/mermaid-embed-Cq7Xw2aa.js': '// present' + }) + + assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), ['assets/syntax-diff-Bo0962zh.js']) +}) + +test('missingRendererAssets: walks transitive map-deps without looping on cycles', () => { + // A lazy chunk can carry its own deps table (shiki language chunks do). + // The walk must follow it — and a mutual reference must not hang the boot. + const deps = graphDepsFor(INDEX_DIR, { + 'index.html': GRAPH_INDEX_HTML, + 'assets/index-a1b2c3.js': [ + 'const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/level-one-aaa.js"])))=>i.map(i=>d[i]);' + ].join('\n'), + 'assets/level-one-aaa.js': [ + 'const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=[', + '"assets/index-a1b2c3.js","assets/level-two-bbb.js"', + '])))=>i.map(i=>d[i]);' + ].join('\n') + }) + + assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), ['assets/level-two-bbb.js']) +}) + +test('missingRendererAssets: a lazy-chunk-torn copy loses to an intact copy end to end', () => { + // The resolver contract: the same generation check that orders app.asar vs + // app.asar.unpacked must now see lazy-chunk tears too, so a torn candidate + // is skipped instead of shipping a delayed "Failed to fetch dynamically + // imported module" crash. + const files = { + 'index.html': GRAPH_INDEX_HTML, + 'assets/index-a1b2c3.js': ENTRY_JS, + 'assets/shiki-block-COiz1pEN.js': '// present', + 'assets/mermaid-embed-Cq7Xw2aa.js': '// present' + } + + const torn = graphDepsFor(INDEX_DIR, files) + const intact = graphDepsFor(INDEX_DIR, { ...files, 'assets/syntax-diff-Bo0962zh.js': '// present' }) + + assert.notDeepEqual(missingRendererAssets(INDEX_PATH, torn), []) + assert.deepEqual(missingRendererAssets(INDEX_PATH, intact), []) +}) diff --git a/apps/desktop/electron/renderer-bundle.ts b/apps/desktop/electron/renderer-bundle.ts index f977729e8db3..03aed2ce21b1 100644 --- a/apps/desktop/electron/renderer-bundle.ts +++ b/apps/desktop/electron/renderer-bundle.ts @@ -43,13 +43,46 @@ export function parseModuleAssetRefs(html: string): string[] { return refs } +// Vite bakes each chunk's lazy-import filenames into an inline +// `__vite__mapDeps` table — `…(m.f || (m.f = ["assets/syntax-diff-….js", …]))` — +// and call sites reference the table by index (`__vite__mapDeps([0,1])`). +// The quoted filenames in that table are the chunks the generation will +// dynamically import at runtime (syntax-diff-*, shiki-*, mermaid-embed-*, …): +// exactly the files a torn update leaves dangling AFTER index.html's own +// preload list checks out. The index-only call sites contain no quoted +// strings, so this scan matches only the definition's filename table. +const MAP_DEPS_ARRAY = /__vite__mapDeps[^[\]]{0,300}\[([^\]]*)\]/g +const QUOTED_REF = /["']([^"']+)["']/g + +export function parseLazyChunkRefs(js: string): string[] { + const refs = new Set() + + for (const [, body] of String(js ?? '').matchAll(MAP_DEPS_ARRAY)) { + for (const [, ref] of body.matchAll(QUOTED_REF)) { + // Absolute/CDN URLs aren't part of this bundle's generation. + if (!/^[a-z]+:|^\/\//i.test(ref)) { + refs.add(ref.replace(/^\.\//, '').split(/[?#]/)[0]) + } + } + } + + return [...refs] +} + export interface RendererBundleDeps { readFileSync?: (file: string, encoding: 'utf8') => string existsSync?: (file: string) => boolean } /** - * The module files `indexPath` declares but that do not exist beside it. + * The module files `indexPath`'s generation declares but that do not exist + * beside it — both the boot-critical refs named by index.html itself AND the + * lazy chunks those modules will dynamically import later (their inline + * `__vite__mapDeps` tables). A torn update can pass the index-level check — + * index.html's own preloads all present — and still die minutes later on the + * first `React.lazy()` route (#93479: syntax-diff-*, shiki-*, + * mermaid-embed-*). Walking the map-deps graph lets the loader skip that + * candidate up front instead of shipping a delayed crash. * * Empty ⇒ a complete generation (or an index naming nothing checkable — the * caller's own existence gate owns unreadable/missing files). Non-empty ⇒ torn: @@ -67,5 +100,61 @@ export function missingRendererAssets(indexPath: string, deps: RendererBundleDep return [] } - return parseModuleAssetRefs(html).filter(ref => !existsSync(path.join(dir, ref))) + const missing: string[] = [] + const seen = new Set() + const queue = [...parseModuleAssetRefs(html)] + + while (queue.length > 0) { + const ref = queue.shift()! + + if (seen.has(ref)) { + continue + } + + seen.add(ref) + + const file = path.join(dir, ref) + + if (!existsSync(file)) { + missing.push(ref) + + continue + } + + // A present JS chunk may still name lazy imports of its own. Read its + // __vite__mapDeps table and queue those refs relative to the dir that + // owns them (map-deps entries are emitted relative to the chunk's own + // directory in older Vite output, and base-relative in newer output — + // normalize both to index-dir-relative before the existence check). + if (!/\.m?js$/i.test(ref)) { + continue + } + + let js: string + + try { + js = readFileSync(file, 'utf8') + } catch { + // Unreadable-but-present is the existence gate's concern, same contract + // as an unreadable index.html above. + continue + } + + for (const lazyRef of parseLazyChunkRefs(js)) { + const chunkDirRelative = path.join(path.dirname(ref), lazyRef).split(path.sep).join('/') + + // Prefer whichever interpretation lands on a real file; when neither + // does, report the index-dir-relative spelling (matches how the + // boot-critical refs above are reported). + if (existsSync(path.join(dir, lazyRef))) { + queue.push(lazyRef) + } else if (existsSync(path.join(dir, chunkDirRelative))) { + queue.push(chunkDirRelative) + } else { + queue.push(lazyRef) + } + } + } + + return missing } diff --git a/apps/desktop/electron/secret-storage-policy.test.ts b/apps/desktop/electron/secret-storage-policy.test.ts new file mode 100644 index 000000000000..f6a3f5ef388a --- /dev/null +++ b/apps/desktop/electron/secret-storage-policy.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for electron/secret-storage-policy.ts — the "is OS-keychain + * encryption enabled at all?" decision seam. + * + * The behavior this file pins: keychain-backed encryption is OPT-IN + * (default OFF), and once the one-shot legacy migration has run, a + * safeStorage blob under an opted-out policy reads as 'drop' — i.e. the + * caller must treat it as absent WITHOUT touching safeStorage, so a broken + * macOS login keychain can never raise its password dialog on launch. + * + * (Wired into the vitest `electron` project via electron/**\/*.test.ts.) + */ + +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { + classifyStoredSecret, + readSecretStoragePolicy, + type SecretStoragePolicyIo, + writeSecretStoragePolicy +} from './secret-storage-policy' + +function fakeIo(initial: string | null = null): SecretStoragePolicyIo & { fileText: () => string | null } { + let text = initial + + return { + readText: () => { + if (text === null) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + + return text + }, + writeText: (next: string) => { + text = next + }, + fileText: () => text + } +} + +// ── defaults ──────────────────────────────────────────────────────────────── + +test('missing policy file defaults to encryption OFF, not migrated', () => { + const policy = readSecretStoragePolicy(fakeIo()) + + assert.deepEqual(policy, { on: false, migrated: false }) +}) + +test('corrupt or non-object policy file reads as the default', () => { + for (const bad of ['not-json', '[]', '"on"', 'null', '123']) { + assert.deepEqual(readSecretStoragePolicy(fakeIo(bad)), { on: false, migrated: false }) + } +}) + +test('truthy-but-not-true values do NOT enable encryption', () => { + // Strict === true coercion: a hand-edited "on": 1 or "yes" must not turn + // keychain prompts back on. + for (const bad of ['{"on":1}', '{"on":"yes"}', '{"on":"true"}']) { + assert.equal(readSecretStoragePolicy(fakeIo(bad)).on, false) + } +}) + +test('round trip preserves both fields', () => { + const io = fakeIo() + + writeSecretStoragePolicy({ on: true, migrated: true }, io) + assert.deepEqual(readSecretStoragePolicy(io), { on: true, migrated: true }) + + writeSecretStoragePolicy({ on: false, migrated: true }, io) + assert.deepEqual(readSecretStoragePolicy(io), { on: false, migrated: true }) +}) + +// ── classification ────────────────────────────────────────────────────────── + +const SAFE_BLOB = { encoding: 'safeStorage', value: 'AAAA' } +const PLAIN_BLOB = { encoding: 'plain', value: 'tok' } + +test('non-safeStorage blobs are always keep, under every policy', () => { + for (const policy of [ + { on: false, migrated: false }, + { on: false, migrated: true }, + { on: true, migrated: true } + ]) { + assert.equal(classifyStoredSecret(PLAIN_BLOB, policy), 'keep') + assert.equal(classifyStoredSecret(null, policy), 'keep') + assert.equal(classifyStoredSecret(undefined, policy), 'keep') + assert.equal(classifyStoredSecret({} as any, policy), 'keep') + } +}) + +test('safeStorage blob with encryption ON is keep', () => { + assert.equal(classifyStoredSecret(SAFE_BLOB, { on: true, migrated: true }), 'keep') +}) + +test('safeStorage blob, encryption OFF, pre-migration is migrate', () => { + assert.equal(classifyStoredSecret(SAFE_BLOB, { on: false, migrated: false }), 'migrate') +}) + +test('safeStorage blob, encryption OFF, post-migration is drop — never touch the keychain again', () => { + assert.equal(classifyStoredSecret(SAFE_BLOB, { on: false, migrated: true }), 'drop') +}) diff --git a/apps/desktop/electron/secret-storage-policy.ts b/apps/desktop/electron/secret-storage-policy.ts new file mode 100644 index 000000000000..707dbb0d612b --- /dev/null +++ b/apps/desktop/electron/secret-storage-policy.ts @@ -0,0 +1,102 @@ +/** + * secret-storage-policy.ts + * + * Single owner of the "do we use the OS keychain at all?" decision for + * desktop-stored secrets (remote gateway tokens, CF Access headers, native + * OAuth token sets). + * + * Why this exists: Electron safeStorage on macOS parks a per-app key + * ("Hermes Key") in the login keychain. On machines with a locked, missing, + * or corrupted default keychain, ANY safeStorage touch — including + * isEncryptionAvailable() — makes macOS throw a blocking "Keychain Not + * Found" / password dialog on every launch. That is an unacceptable default + * for a chat app, so keychain-backed encryption is OPT-IN: + * + * - Setting OFF (default): secrets are written with encoding 'plain' and + * NO safeStorage API is ever called. decryptDesktopSecret already + * returns non-safeStorage encodings verbatim, so reads need no change. + * - Setting ON: the previous behavior — strict safeStorage encryption, + * loud failure when the keychain is unavailable, per-save plain-text + * confirm dialog as the escape hatch. + * + * Legacy blobs written before the flag existed are safeStorage-encoded on + * disk. With the setting OFF we attempt ONE migration pass (decrypt → + * rewrite as plain). The pass is recorded in the same settings file whether + * or not it succeeds, so a broken keychain costs at most one prompt on the + * first post-update launch — never one per launch. + * + * Kept standalone (no `import 'electron'`) so it unit-tests under the + * electron vitest project, same pattern as native-token-store.ts. main.ts + * injects the file path and fs. + */ + +export interface SecretStoragePolicy { + /** Keychain-backed encryption enabled (explicit user opt-in). */ + on: boolean + /** One-shot legacy-blob migration already attempted. */ + migrated: boolean +} + +export const SECRET_STORAGE_POLICY_FILE = 'secure-token-storage.json' + +export interface SecretStoragePolicyIo { + readText: () => string + writeText: (text: string) => void +} + +/** + * Normalize whatever is on disk into a policy. Anything unreadable, + * unparseable, or hand-mangled is the default: encryption OFF, migration + * not yet attempted. `on` uses strict `=== true` — a truthy-but-not-true + * value must not silently enable keychain prompts (mirrors the + * allowPlainText coercion rule in hardening.ts). + */ +export function readSecretStoragePolicy(io: SecretStoragePolicyIo): SecretStoragePolicy { + try { + const parsed = JSON.parse(io.readText()) + + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return { on: parsed.on === true, migrated: parsed.migrated === true } + } + } catch { + // fall through to default + } + + return { on: false, migrated: false } +} + +export function writeSecretStoragePolicy(policy: SecretStoragePolicy, io: SecretStoragePolicyIo): void { + io.writeText(JSON.stringify({ on: policy.on === true, migrated: policy.migrated === true })) +} + +/** One stored secret blob as it appears on disk. */ +interface StoredSecret { + encoding?: string + value?: string +} + +/** + * Decide what to do with one stored blob under the current policy. + * + * - 'keep' — blob is fine as-is under this policy. + * - 'migrate' — safeStorage blob while encryption is OFF and migration has + * not run: caller should decrypt once and rewrite as plain. + * - 'drop' — safeStorage blob while encryption is OFF and the migration + * pass already ran (i.e. it could not be decrypted last + * time): treat as absent WITHOUT touching safeStorage, so a + * dead keychain never prompts again. + */ +export function classifyStoredSecret( + secret: StoredSecret | null | undefined, + policy: SecretStoragePolicy +): 'keep' | 'migrate' | 'drop' { + if (!secret || typeof secret !== 'object' || secret.encoding !== 'safeStorage') { + return 'keep' + } + + if (policy.on) { + return 'keep' + } + + return policy.migrated ? 'drop' : 'migrate' +} diff --git a/apps/desktop/electron/window-below.test.ts b/apps/desktop/electron/window-below.test.ts index 6a34384518ac..d1e2b684ebc4 100644 --- a/apps/desktop/electron/window-below.test.ts +++ b/apps/desktop/electron/window-below.test.ts @@ -125,4 +125,23 @@ describe('enumerationFailureNote', () => { expect(note.length).toBeGreaterThan(0) } }) + + // The macOS/Windows note used to be one fixed sentence, so a real report came + // back saying only "could not enumerate windows on this system" — the module + // failing to load, the helper failing to spawn, and the OS answering with + // nothing are three different fixes and all three said that. + it('carries the enumerator\u2019s own reason where there is no environmental fork', () => { + for (const platform of ['darwin', 'win32']) { + expect(enumerationFailureNote(platform, {}, 'the helper failed: spawn EACCES')).toMatch(/spawn EACCES/) + } + }) + + // Linux's notes name the fix (change session type, install xprop); the raw + // exception underneath would only bury it. + it('keeps the actionable Linux advice instead of the raw reason', () => { + const note = enumerationFailureNote('linux', { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0' }, 'ENOENT') + + expect(note).toMatch(/xprop/) + expect(note).not.toMatch(/ENOENT/) + }) }) diff --git a/apps/desktop/electron/window-below.ts b/apps/desktop/electron/window-below.ts index d5fa9e23841d..3faa078d5629 100644 --- a/apps/desktop/electron/window-below.ts +++ b/apps/desktop/electron/window-below.ts @@ -12,6 +12,12 @@ // permission; we pass titles through only when that permission is ALREADY // granted and never trigger the prompt for it. +import fs from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +import { app } from 'electron' + import { readHyprlandWindows } from './hyprland' export interface EnumeratedWindow { @@ -51,10 +57,18 @@ export interface WindowBelowUnavailable { * A session with both `WAYLAND_DISPLAY` and `DISPLAY` is Wayland running * XWayland, where `xprop` can still answer — so it is treated as X11 and gets * the tooling advice rather than being told to change session type. + * + * macOS and Windows have no environmental fork like that, so their note is + * whatever the enumerator actually said. That `detail` is the whole point: a + * bare "could not enumerate windows on this system" is what a real report came + * back with (macOS 26, packaged app), and neither the tool result nor the + * desktop log said whether the module failed to load, the helper failed to + * spawn, or the OS answered with nothing — three failures with three different + * fixes, all collapsed into one sentence. */ -export function enumerationFailureNote(platform: string, env: NodeJS.ProcessEnv): string { +export function enumerationFailureNote(platform: string, env: NodeJS.ProcessEnv, detail?: string): string { if (platform !== 'linux') { - return 'Could not enumerate windows on this system.' + return detail ? `Could not enumerate windows: ${detail}` : 'Could not enumerate windows on this system.' } // Hyprland is asked over its own IPC, so reaching here means the socket @@ -122,50 +136,97 @@ type GetWindowsModule = { > } -let getWindowsModule: Promise | null = null +/** Enumeration couldn't run at all, and why. Distinct from an empty list, + * which is a real answer meaning "nothing else is on screen". */ +export interface EnumerationFailure { + reason: string +} + +export const enumerationFailed = (result: EnumerationFailure | T): result is EnumerationFailure => + typeof result === 'object' && result !== null && 'reason' in result + +const describeError = (error: unknown): string => + error instanceof Error ? error.message : String(error ?? 'unknown error') -const loadGetWindows = (): Promise => { +let getWindowsModule: Promise | null = null + +const loadGetWindows = (): Promise => { // get-windows is an optionalDependency: `npm ci` can skip it when its native // install fails, including Linux and Windows ARM64 where 9.3.0 has no // prebuilt. A missing module is therefore a normal state on those targets, // so the lazy import resolves to null instead of rejecting; enumeration then // degrades to the failure note instead of an uncaught error. - getWindowsModule ??= import('get-windows').catch(() => null) + // + // The STAGED copy is tried first, and it is not a dev-only nicety. + // `import('get-windows')` resolves out of node_modules, whose lib/windows.js + // locates its binding through `preGyp.find()` — by HOST platform. When the + // tree was installed on a different OS than Electron is running on (a + // WSL-hosted dev run driving a win32 Electron is the everyday case here), + // pre-gyp picks the host's slot, ignores the win32 binding sitting beside it, + // and upstream's fail-soft path hands back no-op stubs. Enumeration then + // reports "unavailable" on a machine that answers perfectly well, which is + // what silently disabled both read_window_below and the HUD's game overlay. + // scripts/stage-native-deps.mjs writes a staged lib/windows.js that requires + // the binding directly, so it is the more reliable of the two everywhere. + getWindowsModule ??= (async () => { + const staged = path.join(app.getAppPath(), 'dist', 'node_modules', 'get-windows', 'index.js') + let stagedError = 'not staged in this build' + + if (fs.existsSync(staged)) { + try { + return (await import(pathToFileURL(staged).href)) as GetWindowsModule + } catch (error) { + stagedError = describeError(error) + } + } + + try { + return (await import('get-windows')) as GetWindowsModule + } catch (error) { + return { + reason: + 'the get-windows module could not be loaded ' + + `(staged copy: ${stagedError}; node_modules copy: ${describeError(error)})` + } + } + })() return getWindowsModule } /** - * Enumerate windows and serialize the one underneath `selfBounds`. + * Every window `get-windows` can see, front-to-back, or why it could not look. * * `titlesAvailable` is the macOS Screen Recording grant (pass true on other - * platforms, where titles are free). When enumeration itself is unavailable - * (Wayland, missing xprop, addon load failure) this answers with the reason - * rather than nothing, so the agent can tell the user what to fix instead of - * reporting a blank failure. + * platforms, where titles are free). The three ways this can fail — the module + * not loading, the enumerator throwing, the enumerator answering with + * something that isn't a list — each say so, because they have three different + * fixes and the caller has no other way to tell them apart. */ -async function enumerateViaGetWindows(titlesAvailable: boolean): Promise { - let raw +async function enumerateViaGetWindows(titlesAvailable: boolean): Promise { + const getWindows = await loadGetWindows() - try { - const getWindows = await loadGetWindows() + if (enumerationFailed(getWindows)) { + return getWindows + } - if (!getWindows) { - return null - } + let raw - const { openWindows } = getWindows - raw = await openWindows( + try { + raw = await getWindows.openWindows( process.platform === 'darwin' ? { accessibilityPermission: false, screenRecordingPermission: titlesAvailable } : undefined ) - } catch { - return null + } catch (error) { + // On macOS this is the helper binary failing to spawn — a missing or + // non-executable `main`, or the OS refusing to run it — which is invisible + // from the outside and used to surface as the generic note. + return { reason: `the window enumerator failed: ${describeError(error)}` } } if (!Array.isArray(raw)) { - return null + return { reason: 'the window enumerator returned no window list' } } // get-windows documents openWindows() as front-to-back, and macOS/Windows @@ -189,19 +250,32 @@ async function enumerateViaGetWindows(titlesAvailable: boolean): Promise { + return (await readHyprlandWindows(selfPid)) ?? (await enumerateViaGetWindows(titlesAvailable)) +} + export async function readWindowBelow( selfPid: number, selfBounds: EnumeratedWindow['bounds'], titlesAvailable: boolean ): Promise { - // Hyprland first, and only ever on Hyprland — its own IPC sees native Wayland - // windows, which the X11 enumerator below cannot, and it answers null - // everywhere else so the established path stays the default. - const windows = (await readHyprlandWindows(selfPid)) ?? (await enumerateViaGetWindows(titlesAvailable)) + const windows = await enumerateWindowsFrontToBack(selfPid, titlesAvailable) - if (!windows) { + if (enumerationFailed(windows)) { return { - error: enumerationFailureNote(process.platform, process.env), + error: enumerationFailureNote(process.platform, process.env, windows.reason), platform: process.platform } } diff --git a/apps/desktop/electron/zoom.test.ts b/apps/desktop/electron/zoom.test.ts index a6affd0c5e5d..9d973fc970b5 100644 --- a/apps/desktop/electron/zoom.test.ts +++ b/apps/desktop/electron/zoom.test.ts @@ -12,8 +12,12 @@ import { applyZoomLevel, clampZoomLevel, DEFAULT_ZOOM_LEVEL, + installZoomReassertOnNavigation, installZoomReassertOnWindowEvents, + isDebouncedReassertEvent, percentToZoomLevel, + ZOOM_REASSERT_MAX_SETTLE_CHECKS, + ZOOM_REASSERT_SETTLE_DELAY_MS, ZOOM_RESIZE_REASSERT_DELAY_MS, ZOOM_STEP, ZOOM_STORAGE_KEY, @@ -101,7 +105,7 @@ test('installZoomReassertOnWindowEvents wires show, restore, focus, resize, and assert.equal(calls, 5) }) -test('focus event reasserts zoom immediately without debounce (Windows high-DPI alt-tab, #50837)', () => { +test('focus event reasserts zoom immediately without debounce on Windows (high-DPI alt-tab, #50837)', () => { const handlers = new Map() const win = { @@ -120,12 +124,22 @@ test('focus event reasserts zoom immediately without debounce (Windows high-DPI 'win32' ) - // focus should trigger immediate reassert — no timer involved + // focus on Windows triggers immediate reassert — no timer involved handlers.get('focus')() assert.equal(calls, 1) }) -test('installZoomReassertOnWindowEvents debounces Linux resize and move events at the trailing edge', () => { +test('isDebouncedReassertEvent debounces focus only on Linux, not Windows/macOS', () => { + assert.equal(isDebouncedReassertEvent('focus', 'linux'), true) + assert.equal(isDebouncedReassertEvent('focus', 'win32'), false) + assert.equal(isDebouncedReassertEvent('focus', 'darwin'), false) + assert.equal(isDebouncedReassertEvent('resize', 'linux'), true) + assert.equal(isDebouncedReassertEvent('resize', 'win32'), true) + assert.equal(isDebouncedReassertEvent('show', 'linux'), false) + assert.equal(isDebouncedReassertEvent('restore', 'linux'), false) +}) + +test('installZoomReassertOnWindowEvents debounces Linux resize, move, and focus events at the trailing edge', () => { vi.useFakeTimers() try { @@ -158,10 +172,102 @@ test('installZoomReassertOnWindowEvents debounces Linux resize and move events a vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS / 2) assert.equal(calls, 1) + // focus on Linux is also debounced — a session switch fires focus but + // the reassert must not jump the zoom mid-interaction. + handlers.get('focus')() + vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS / 2) + assert.equal(calls, 1) + vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS / 2) + assert.equal(calls, 2) + handlers.get('resize')() destroyed = true vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS) - assert.equal(calls, 1) + assert.equal(calls, 2) + } finally { + vi.useRealTimers() + } +}) + +test('installZoomReassertOnWindowEvents re-verifies Linux zoom after the debounced re-assert so a dropped re-apply is retried', () => { + vi.useFakeTimers() + + try { + const handlers = new Map() + + const win = { + isDestroyed: () => false, + on(event, listener) { + handlers.set(event, listener) + } + } + + let calls = 0 + installZoomReassertOnWindowEvents( + win, + () => { + calls += 1 + }, + 'linux' + ) + + handlers.get('resize')() + vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS) + assert.equal(calls, 1, 'debounced re-assert fires once') + + // Cosmic tiling can drop the just-applied zoom mid-reconfigure; the + // settle-verify chain re-asserts on a bounded schedule until it converges: + // one initial re-assert plus ZOOM_REASSERT_MAX_SETTLE_CHECKS follow-ups. + for (let i = 1; i <= ZOOM_REASSERT_MAX_SETTLE_CHECKS; i++) { + vi.advanceTimersByTime(ZOOM_REASSERT_SETTLE_DELAY_MS) + assert.equal(calls, 1 + i, `settle check ${i} re-asserts`) + } + + vi.advanceTimersByTime(ZOOM_REASSERT_SETTLE_DELAY_MS * 2) + assert.equal(calls, 1 + ZOOM_REASSERT_MAX_SETTLE_CHECKS, 'settle chain is bounded and stops') + } finally { + vi.useRealTimers() + } +}) + +test('installZoomReassertOnWindowEvents resets the settle chain on a fresh re-assert', () => { + vi.useFakeTimers() + + try { + const handlers = new Map() + + const win = { + isDestroyed: () => false, + on(event, listener) { + handlers.set(event, listener) + } + } + + let calls = 0 + installZoomReassertOnWindowEvents( + win, + () => { + calls += 1 + }, + 'linux' + ) + + handlers.get('show')() + vi.advanceTimersByTime(ZOOM_REASSERT_SETTLE_DELAY_MS) + assert.equal(calls, 2, 'initial re-assert plus one settle check') + + // A second transition while the first settle chain is still pending must + // restart the chain from zero instead of exhausting the budget. + handlers.get('restore')() + assert.equal(calls, 3, 'fresh transition re-asserts immediately') + + for (let i = 1; i <= ZOOM_REASSERT_MAX_SETTLE_CHECKS; i++) { + vi.advanceTimersByTime(ZOOM_REASSERT_SETTLE_DELAY_MS) + assert.equal(calls, 3 + i, `restarted chain settle check ${i} re-asserts`) + } + + vi.advanceTimersByTime(ZOOM_REASSERT_SETTLE_DELAY_MS * 2) + assert.equal(calls, 3 + ZOOM_REASSERT_MAX_SETTLE_CHECKS, 'restarted chain is bounded too') } finally { vi.useRealTimers() } @@ -187,6 +293,34 @@ test('installZoomReassertOnWindowEvents skips destroyed windows', () => { assert.equal(calls, 0) }) +test('installZoomReassertOnNavigation covers full loads and main-frame in-page routes', () => { + const handlers = new Map() + let destroyed = false + let calls = 0 + + const webContents = { + isDestroyed: () => destroyed, + on(event, listener) { + handlers.set(event, listener) + } + } + + installZoomReassertOnNavigation(webContents, () => { + calls += 1 + }) + + assert.deepEqual([...handlers.keys()], ['did-finish-load', 'did-navigate-in-page']) + + handlers.get('did-finish-load')() + handlers.get('did-navigate-in-page')({}, 'file:///app/index.html#/new', true) + handlers.get('did-navigate-in-page')({}, 'file:///app/frame.html#anchor', false) + assert.equal(calls, 2) + + destroyed = true + handlers.get('did-navigate-in-page')({}, 'file:///app/index.html#/session/next', true) + assert.equal(calls, 2) +}) + // Zoom-wiring contract: chat windows keep global UI zoom while fixed-size // helper windows opt out. Tested via the extracted config — no source-text regex. test('chat windows opt into zoom', () => { diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts index 4ba948c36240..434ba02b052f 100644 --- a/apps/desktop/electron/zoom.ts +++ b/apps/desktop/electron/zoom.ts @@ -65,18 +65,70 @@ export function applyZoomLevel(webContents, level) { // fallbacks before re-applying the persisted level. export const ZOOM_RESIZE_REASSERT_DELAY_MS = 100 +// Linux settle-verify: a re-assert can land while the compositor is still +// reconfiguring the window's surface (Cosmic tiled mode fires a resize storm +// the moment a new session window opens, and the final event can arrive before +// the new scale is committed), in which case Chromium drops the just-applied +// zoom and the window stays stuck at the wrong scale until the next +// transition. After the debounced re-assert we therefore re-check a few times +// at a settle delay; each re-assert is drift-guarded (see +// restorePersistedZoomLevel), so a window that already matches the persisted +// level costs nothing and the chain is bounded. +export const ZOOM_REASSERT_SETTLE_DELAY_MS = 300 +export const ZOOM_REASSERT_MAX_SETTLE_CHECKS = 3 + export function zoomReassertWindowEvents(platform = process.platform) { return platform === 'linux' ? ['show', 'restore', 'focus', 'resize', 'move'] : ['show', 'restore', 'focus', 'resized', 'moved'] } +// Linux/Wayland fires `focus` on intra-app focus shifts (sidebar clicks, +// Ctrl+Tab session switching, tile activation) — not just the cross-app +// alt-tab the Windows high-DPI immediate-reassert guard (#50837) targets. +// An undebounced reassert on every such focus event re-applies the persisted +// zoom level mid-interaction, producing a visible zoom/DPI jump. Debounce +// `focus` alongside `resize`/`move` on Linux; Windows alt-tab keeps its +// immediate path because `platform` is `win32` there. +const DEBOUNCED_REASSERT_EVENTS = new Set(['resize', 'move']) + +export function isDebouncedReassertEvent(event, platform = process.platform) { + return DEBOUNCED_REASSERT_EVENTS.has(event) || (platform === 'linux' && event === 'focus') +} + export function installZoomReassertOnWindowEvents(win, reassert, platform = process.platform) { if (!win?.on) { return } let resizeTimer + let settleTimer + let settleChecks = 0 + + const scheduleSettleCheck = () => { + if (platform !== 'linux') { + return + } + + settleChecks += 1 + + if (settleChecks > ZOOM_REASSERT_MAX_SETTLE_CHECKS) { + return + } + + settleTimer = setTimeout(() => { + if (!win.isDestroyed?.()) { + reassert() + scheduleSettleCheck() + } + }, ZOOM_REASSERT_SETTLE_DELAY_MS) + } + + const reassertWithSettleCheck = () => { + settleChecks = 0 + reassert() + scheduleSettleCheck() + } for (const event of zoomReassertWindowEvents(platform)) { win.on(event, () => { @@ -84,8 +136,9 @@ export function installZoomReassertOnWindowEvents(win, reassert, platform = proc return } - if (event !== 'resize' && event !== 'move') { - reassert() + if (!isDebouncedReassertEvent(event, platform)) { + clearTimeout(settleTimer) + reassertWithSettleCheck() return } @@ -93,13 +146,53 @@ export function installZoomReassertOnWindowEvents(win, reassert, platform = proc clearTimeout(resizeTimer) resizeTimer = setTimeout(() => { if (!win.isDestroyed?.()) { - reassert() + clearTimeout(settleTimer) + reassertWithSettleCheck() } }, ZOOM_RESIZE_REASSERT_DELAY_MS) }) } } +/** + * Chromium persists zoom PER URL, and a hash route is a distinct URL. Desktop + * is a HashRouter over one `file://index.html`, so every session/settings route + * carries its own `partition.per_host_zoom_levels` record, and an in-page + * navigation applies the target route's record over whatever the window is + * showing. A route the user never zoomed on has no record at all, so it + * resolves to the host default — level 0, i.e. 100%. + * + * In-page navigation fires neither `did-finish-load` nor any window event, so + * nothing re-asserted the persisted level: opening a fresh session dropped the + * window to 100% while the Appearance control kept reading the chosen scale (it + * only learns of changes through `hermes:zoom:changed`, which never fired — + * which is why touching the setting appeared to fix it). #48658, #38854, #79863. + * + * Verified on real Electron 40.10.2 / Chromium 144 (win32): at + * `did-navigate-in-page` the frame already reports the target route's level, so + * restorePersistedZoomLevel's drift-guard sees the drop and re-applies — and + * still no-ops when the route's record already matches. Subframe hash changes + * must not touch the chat window's UI scale. + */ +export function installZoomReassertOnNavigation(webContents, reassert) { + if (!webContents?.on) { + return + } + + const reassertIfAlive = () => { + if (!webContents.isDestroyed?.()) { + reassert() + } + } + + webContents.on('did-finish-load', reassertIfAlive) + webContents.on('did-navigate-in-page', (_event, _url, isMainFrame) => { + if (isMainFrame) { + reassertIfAlive() + } + }) +} + /** * Zoom-wiring decision per window kind. Chat windows (main + session + the HUD) * keep global UI zoom; the pet overlay and the Quick Entry composer opt out diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 73a71ed35adc..c2d43d1bb154 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -158,7 +158,7 @@ "devDependencies": { "@babel/core": "8.0.1", "@electron/rebuild": "4.2.0", - "@playwright/test": "1.58.2", + "@playwright/test": "1.62.1", "@rolldown/plugin-babel": "0.2.3", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", diff --git a/apps/desktop/src/api/client.ts b/apps/desktop/src/api/client.ts index e996ab010946..8a925be2dd4f 100644 --- a/apps/desktop/src/api/client.ts +++ b/apps/desktop/src/api/client.ts @@ -117,7 +117,7 @@ export function hermesApi(request: HermesApiRequest): Promise { // ids mean the local pool and deliberately DROP the ambient connection // tag, so a local-profile pick made while a remote gateway is active still // routes to the local machine. -export type ProfileScope = null | string | { connectionId?: null | string; profile?: null | string } +export type ProfileScope = undefined | null | string | { connectionId?: null | string; profile?: null | string } export function capabilityScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } { if (scope && typeof scope === 'object') { diff --git a/apps/desktop/src/api/profiles.ts b/apps/desktop/src/api/profiles.ts index 52fc09090651..b021639ab99f 100644 --- a/apps/desktop/src/api/profiles.ts +++ b/apps/desktop/src/api/profiles.ts @@ -6,7 +6,7 @@ import type { ProfilesResponse } from '@/types/hermes' -import { hermesApi, STARTUP_REQUEST_TIMEOUT_MS } from './client' +import { capabilityScoped, hermesApi, type ProfileScope, STARTUP_REQUEST_TIMEOUT_MS } from './client' export function getProfiles(): Promise { return hermesApi({ @@ -31,9 +31,22 @@ export function renameProfile(name: string, newName: string): Promise<{ name: st }) } -export function deleteProfile(name: string): Promise<{ ok: boolean; path: string }> { +export function deleteProfile(name: string, scope?: ProfileScope): Promise<{ ok: boolean; path: string }> { + const normalized = name.trim() + const scopedProfile = scope && typeof scope === 'object' ? scope.profile?.trim() : undefined + + if (!normalized) { + return Promise.reject(new Error('Profile name required')) + } + + if (normalized.toLowerCase() === 'default' || scopedProfile?.toLowerCase() === 'default') { + return Promise.reject(new Error('The default profile cannot be deleted.')) + } + return hermesApi<{ ok: boolean; path: string }>({ - path: `/api/profiles/${encodeURIComponent(name)}`, + ...capabilityScoped(scope), + ...(scope && typeof scope === 'object' && scope.connectionId?.trim() === 'local' ? { connectionId: 'local' } : {}), + path: `/api/profiles/${encodeURIComponent(normalized)}`, method: 'DELETE' }) } diff --git a/apps/desktop/src/api/sessions.ts b/apps/desktop/src/api/sessions.ts index e8f17ac22656..f938dc238177 100644 --- a/apps/desktop/src/api/sessions.ts +++ b/apps/desktop/src/api/sessions.ts @@ -8,10 +8,30 @@ import type { SessionSearchResponse } from '@/types/hermes' -import { hermesApi } from './client' +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000 +function sessionScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } { + if (scope === undefined || scope === null) { + return {} + } + + const scoped = capabilityScoped(scope) + + if (typeof scope === 'object' && scope.connectionId?.trim() === 'local') { + return { ...scoped, connectionId: 'local' } + } + + return scoped +} + +function sessionScopeQuery(scope?: ProfileScope): string { + const profile = sessionScoped(scope).profile + + return profile ? `?profile=${encodeURIComponent(profile)}` : '' +} + /** * Trim a page to its window WITHOUT discarding pinned rows. * @@ -39,6 +59,7 @@ export async function listSessions( order: 'created' | 'recent' = 'recent' ): Promise { const result = await hermesApi({ + ...profileScoped(), path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` + `&archived=${archived}&order=${order}`, @@ -80,6 +101,7 @@ export async function listAllProfileSessions( : '' const result = await hermesApi({ + ...profileScoped(), path: `/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` + `&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`, @@ -228,6 +250,7 @@ export async function listSidebarSessions(req: SidebarSessionsRequest): Promise< try { result = await hermesApi({ + ...profileScoped(), path: `/api/profiles/sessions/sidebar?${params.toString()}`, timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS }) @@ -299,11 +322,11 @@ export function searchSessions(query: string): Promise { // the given `profile`). The backend resolves exact ids and unique prefixes and // 404s when the id isn't on that profile — so a cheap by-id lookup replaces the // cross-profile list scan when locating an unknown id's owner. -export function getSession(id: string, profile?: string | null): Promise { - const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' +export function getSession(id: string, profile?: ProfileScope): Promise { + const suffix = sessionScopeQuery(profile) return hermesApi({ - ...(profile ? { profile } : {}), + ...sessionScoped(profile), path: `/api/sessions/${encodeURIComponent(id)}${suffix}` }) } @@ -314,13 +337,15 @@ export function getSession(id: string, profile?: string | null): Promise { const query = new URLSearchParams() - if (profile) { - query.set('profile', profile) + const sessionScope = sessionScoped(profile) + + if (sessionScope.profile) { + query.set('profile', sessionScope.profile) } if (page.limit !== undefined) { @@ -342,7 +367,7 @@ export function getSessionMessages( const suffix = query.size ? `?${query.toString()}` : '' return hermesApi({ - ...(profile ? { profile } : {}), + ...sessionScope, path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}` }) } @@ -356,7 +381,7 @@ export function getSessionMessages( */ export const LATEST_SESSION_MESSAGES_LIMIT = 120 -export function getLatestSessionMessages(id: string, profile?: string | null): Promise { +export function getLatestSessionMessages(id: string, profile?: ProfileScope): Promise { // includeCompacted: durable display history must include rows preserved by // in-place compaction (active=0, compacted=1); without them the transcript // silently ends at the compaction boundary and earlier turns are unreachable. @@ -394,7 +419,7 @@ export function getLatestSessionMessages(id: string, profile?: string | null): P */ export function getOlderSessionMessages( id: string, - profile: string | null | undefined, + profile: ProfileScope, offset: number, limit: number = LATEST_SESSION_MESSAGES_LIMIT ): Promise { @@ -403,7 +428,7 @@ export function getOlderSessionMessages( export async function getAllSessionMessages( id: string, - profile?: string | null, + profile?: ProfileScope, options: { maxJsonChars?: number } = {} ): Promise { const messages: SessionMessage[] = [] @@ -443,9 +468,9 @@ export async function getAllSessionMessages( return { session_id: resolvedSessionId, messages } } -export function deleteSession(id: string, profile?: string | null): Promise<{ ok: boolean }> { +export function deleteSession(id: string, profile?: ProfileScope): Promise<{ ok: boolean }> { return hermesApi<{ ok: boolean }>({ - ...(profile ? { profile } : {}), + ...sessionScoped(profile), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'DELETE' }) diff --git a/apps/desktop/src/app/artifacts/artifact-utils.ts b/apps/desktop/src/app/artifacts/artifact-utils.ts index c0af68ccabb6..0d679936adf1 100644 --- a/apps/desktop/src/app/artifacts/artifact-utils.ts +++ b/apps/desktop/src/app/artifacts/artifact-utils.ts @@ -311,6 +311,9 @@ function toolName(message: SessionMessage): string { } function isArtifactProducerTool(name: string): boolean { + // `bfl_flux3_*` tools were removed from the core toolset, but sessions + // recorded while they existed still carry their tool messages — keep + // matching so those artifacts stay visible in history. return ARTIFACT_PRODUCER_TOOL_RE.test(name) || name.startsWith('bfl_flux3_') } diff --git a/apps/desktop/src/app/chat/browser-popout-shell.tsx b/apps/desktop/src/app/chat/browser-popout-shell.tsx new file mode 100644 index 000000000000..32a0f9578100 --- /dev/null +++ b/apps/desktop/src/app/chat/browser-popout-shell.tsx @@ -0,0 +1,42 @@ +import type { CSSProperties } from 'react' + +import { PanelEmpty } from '@/app/overlays/panel' +import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar' +import { useI18n } from '@/i18n' +import { windowBrowserTabId } from '@/store/windows' + +import { PreviewTilePane } from './right-rail/preview' + +/** + * Dedicated shell for `?win=browser`: the in-app Browser, full-window, no + * session sidebar or layout tree. Same webview + address bar the docked tab + * uses — just in its own OS window. + */ +export function BrowserPopoutShell() { + const { t } = useI18n() + const tabId = windowBrowserTabId() + + return ( +
+ ) } -function ExitHudButton() { +function HudWindowButtons() { const { t } = useI18n() return ( - - - + <> + + + + + + + ) } diff --git a/apps/desktop/src/app/chat/composer/focus-chord.test.ts b/apps/desktop/src/app/chat/composer/focus-chord.test.ts new file mode 100644 index 000000000000..40730fcf1c75 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/focus-chord.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { $workspaceIsPage } from '@/app/routes' +import { closeSwitcher } from '@/store/session-switcher' + +import { onComposerFocusRequest } from './focus' +import { handleComposerFocusChord } from './focus-chord' + +// jsdom is not macOS, so the chord is Ctrl+L. isComposerChord reads +// ctrlKey on other systems and metaKey on macOS. +function chordEvent(init: KeyboardEventInit = {}): KeyboardEvent { + return new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ctrlKey: true, key: 'l', ...init }) +} + +/** The bus defers dispatch by one macrotask. This helper flushes it. */ +const flushBus = () => new Promise(resolve => setTimeout(resolve, 1)) + +async function focusRequests(event: KeyboardEvent): Promise { + const targets: string[] = [] + const off = onComposerFocusRequest(({ target }) => targets.push(target)) + + handleComposerFocusChord(event) + await flushBus() + off() + + return targets +} + +afterEach(() => { + $workspaceIsPage.set(false) + closeSwitcher() + document.body.replaceChildren() +}) + +describe('handleComposerFocusChord', () => { + it('focuses the active composer on the bare chord and swallows the key', async () => { + const event = chordEvent() + + expect(await focusRequests(event)).toEqual(['main']) + expect(event.defaultPrevented).toBe(true) + }) + + it('ignores non-chord keys (plain letter, shifted chord)', async () => { + expect(await focusRequests(chordEvent({ ctrlKey: false }))).toEqual([]) + // Shift+chord is the default for view.showBrowser, not for this handler. + expect(await focusRequests(chordEvent({ shiftKey: true }))).toEqual([]) + }) + + it('yields to a press someone else already claimed', async () => { + const event = chordEvent() + event.preventDefault() + + expect(await focusRequests(event)).toEqual([]) + }) + + it('leaves the key with a focused terminal: no selection means clear-screen', async () => { + const term = document.createElement('div') + term.setAttribute('data-terminal', '') + const inner = document.createElement('textarea') + term.append(inner) + document.body.append(term) + inner.focus() + + const event = chordEvent() + + expect(await focusRequests(event)).toEqual([]) + expect(event.defaultPrevented).toBe(false) + }) + + it('stands down behind blocking surfaces (dialog, full page)', async () => { + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog') + document.body.append(dialog) + expect(await focusRequests(chordEvent())).toEqual([]) + + document.body.replaceChildren() + $workspaceIsPage.set(true) + expect(await focusRequests(chordEvent())).toEqual([]) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/focus-chord.ts b/apps/desktop/src/app/chat/composer/focus-chord.ts new file mode 100644 index 000000000000..491c4fb78285 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/focus-chord.ts @@ -0,0 +1,38 @@ +/** + * ⌘/Ctrl+L moves focus to the composer from anywhere. This matches the + * address-bar muscle memory of a browser. The other handlers of the chord + * keep priority: + * + * - A terminal or preview selection sends itself to the composer through + * its own capture-phase listener. That listener stops propagation, so + * this handler never sees the press. This handler registers on the + * BUBBLE phase for that reason. + * - A user-rebound action on the same chord dispatches in the capture + * listener of use-keybinds and marks the event handled. This handler + * yields on `defaultPrevented`. + * - A focused terminal with no selection keeps the chord as clear-screen. + * `composerFocusBlockedBySurface()` owns that gate. The same gate covers + * type-to-focus and paste-to-focus, dialogs, full workspace pages, and + * the session switcher. + */ + +import { isComposerChord } from '@/lib/keybinds/chords' +import { composerFocusBlockedBySurface } from '@/lib/keybinds/composer-focus-keys' + +import { requestComposerFocus } from './focus' + +/** The window-level keydown fallback. use-keybinds registers it beside the + * paste listener. When this handler claims the press, it prevents the + * default action. */ +export function handleComposerFocusChord(event: KeyboardEvent): void { + if (event.defaultPrevented || !isComposerChord(event)) { + return + } + + if (composerFocusBlockedBySurface()) { + return + } + + event.preventDefault() + requestComposerFocus('active') +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.test.tsx new file mode 100644 index 000000000000..d8548d1c4cfa --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.test.tsx @@ -0,0 +1,132 @@ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { atom } from 'nanostores' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { assistantTextPart, type ChatMessage, chatMessageText } from '@/lib/chat-messages' +import { clearSpokenRepliesForTests, markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply' +import { playSpeechText } from '@/lib/voice-playback' +import { $voicePlayback, setVoicePlaybackState } from '@/store/voice-playback' +import { $autoSpeakReplies } from '@/store/voice-prefs' + +import { ComposerScopeProvider, MAIN_COMPOSER_SCOPE } from '../scope' + +import { useAutoSpeakReplies } from './use-auto-speak-replies' + +vi.mock('@/lib/voice-playback', () => ({ + playSpeechText: vi.fn() +})) + +const SESSION_ID = 'session-under-test' +const IDLE_STATE = { audioElement: null, messageId: null, sequence: 0, source: null, status: 'idle' as const } + +function assistantMessage(id: string, text: string): ChatMessage { + return { id, parts: [assistantTextPart(text)], role: 'assistant' } +} + +// #93515 — Edge TTS has no chunked-PCM API, so the WS attempt in +// playSpeechText's fallback ladder settles 'fallback' before any audio plays +// and the client retries over the POST endpoint. While that POST round-trip +// is in flight, the backend can rewrite the just-completed reply's renderer +// id (`assistant-stream-*`) to its durable id. The issue claims +// `resolveSpokenReply()` fails to follow that rewrite and the reply gets +// spoken a second time once `$voicePlayback` goes idle. +describe('useAutoSpeakReplies — Edge TTS fallback chain (#93515)', () => { + afterEach(() => { + cleanup() + clearSpokenRepliesForTests() + $autoSpeakReplies.set(false) + setVoicePlaybackState({ ...IDLE_STATE }) + vi.clearAllMocks() + }) + + it('does not re-speak the reply once playback goes idle after an id rewrite mid-fallback', async () => { + $autoSpeakReplies.set(true) + + const $messages = atom([]) + + // The exact pendingReply/markSpoken contract use-composer-voice.ts wires + // up for this hook, backed by the real ordinal-anchored dedupe. + const pendingReply = () => { + const messages = $messages.get() + const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) + const spoken = resolveSpokenReply(SESSION_ID, messages) + + if (!last || last.id === spoken?.id) { + return null + } + + return { id: last.id, pending: Boolean(last.pending), text: chatMessageText(last) } + } + + const markSpoken = () => { + const messages = $messages.get() + const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) + + if (last) { + markAssistantIdSpoken(SESSION_ID, messages, last.id) + } + } + + let settleFallback: (() => void) | null = null + + vi.mocked(playSpeechText).mockImplementation(async () => { + setVoicePlaybackState({ + audioElement: null, + messageId: 'assistant-stream-1', + sequence: 0, + source: 'read-aloud', + status: 'preparing' + }) + + // Holds mid-ladder — the WS-fallback-then-POST round trip the issue + // describes — until the test rewrites the message id underneath it. + await new Promise(resolve => { + settleFallback = resolve + }) + + $messages.set([assistantMessage('durable-42', 'hello there')]) + + setVoicePlaybackState({ + audioElement: null, + messageId: 'durable-42', + sequence: 0, + source: 'read-aloud', + status: 'idle' + }) + + return true + }) + + renderHook( + () => + useAutoSpeakReplies({ + conversationActive: false, + failureLabel: 'read-aloud failed', + markSpoken, + pendingReply, + sessionId: SESSION_ID + }), + { + wrapper: ({ children }) => ( + {children} + ) + } + ) + + act(() => { + $messages.set([assistantMessage('assistant-stream-1', 'hello there')]) + }) + + await waitFor(() => expect(playSpeechText).toHaveBeenCalledTimes(1)) + + await act(async () => { + settleFallback?.() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + + expect($voicePlayback.get().status).toBe('idle') + expect(playSpeechText).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 98830fc6726e..dff9371f5476 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -107,7 +107,13 @@ export function ChatBar({ onTranscribeAudio }: ChatBarProps) { const hudMode = useStore($hudMode) - const { grabbing: hudGrabbing, onPointerDown: onHudDragPointerDown } = useHudComposerDrag(hudMode) + const hudWindowing = window.hermesDesktop?.hud?.windowing + const hudNativeDrag = hudMode && hudWindowing?.nativeDrag === true + + const { grabbing: hudGrabbing, onPointerDown: onHudDragPointerDown } = useHudComposerDrag(hudMode && !hudNativeDrag, { + controlDrag: hudWindowing?.controlDrag === true, + workspaceTransfer: hudWindowing?.workspaceTransfer === true + }) // Typed stop phrase during an active voice conversation ends it — same // semantics as SAYING "stop" (voice-stop-word.ts) or clicking the pill's @@ -1006,7 +1012,11 @@ export function ChatBar({ 'min-h-[1.625rem] min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed', '**:data-ref-text:cursor-default', stacked && 'pl-3', - stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1' + stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1', + // Inside the native Wayland HUD drag region: a drag region swallows + // the page's mouse input whole, so the input must opt back out or it + // becomes unclickable. Buttons use the global no-drag rule. + hudNativeDrag && '[-webkit-app-region:no-drag]' )} contentEditable={!inputDisabled} data-placeholder={placeholder} @@ -1184,7 +1194,12 @@ export function ChatBar({ className={cn( 'group/composer relative w-full overflow-visible rounded-2xl', poppedOut && 'bg-transparent', - dragging && 'cursor-grabbing select-none touch-none' + dragging && 'cursor-grabbing select-none touch-none', + // Native Wayland HUD: setBounds cannot position a top-level + // surface, so the bar must ask the compositor to move it. X11 + // stays out of app-region mode so the renderer receives its + // Ctrl+primary-button drag. pt-4 is the carved-out grab band. + hudNativeDrag && 'hud-native-drag pt-4 [-webkit-app-region:drag]' )} data-drag-active={dragActive ? '' : undefined} data-hud-grabbing={hudGrabbing ? '' : undefined} @@ -1196,7 +1211,8 @@ export function ChatBar({ onDragLeave={handleDragLeave} onDragOver={handleDragOver} onDrop={handleDrop} - onPointerDown={hudMode ? onHudDragPointerDown : popoutAllowed ? onComposerGesturePointerDown : undefined} + onPointerDown={!hudMode && popoutAllowed ? onComposerGesturePointerDown : undefined} + onPointerDownCapture={hudMode ? onHudDragPointerDown : undefined} onSubmit={e => { e.preventDefault() diff --git a/apps/desktop/src/app/chat/composer/micro-actions.tsx b/apps/desktop/src/app/chat/composer/micro-actions.tsx index 65e3729eadad..76cda0cf17a9 100644 --- a/apps/desktop/src/app/chat/composer/micro-actions.tsx +++ b/apps/desktop/src/app/chat/composer/micro-actions.tsx @@ -21,7 +21,7 @@ const PILL = cn( composerFloatingPill, 'max-w-56', 'disabled:cursor-default disabled:opacity-50 disabled:hover:bg-(--composer-fill)', - 'focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50' + 'focus-visible:bg-(--chrome-action-hover) focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50' ) /** diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 3f45065a562f..b0965a66621f 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -33,6 +33,7 @@ import { $petActive } from '@/store/pet' import { $petOverlayActive } from '@/store/pet-overlay' import { $activeGatewayProfile, $gatewaySwapTarget, $profiles } from '@/store/profile' import { + $connection, $contextSuggestions, $freshDraftReady, $gatewayState, @@ -40,13 +41,14 @@ import { $introSeed, $resumeExhaustedSessionId, $sessions, + getSessionOwnerHint, resolveComposerSessionKey, sessionMatchesStoredId, sessionPinId, shouldMigrateComposerScope } from '@/store/session' import { $focusedStoredSessionId, sessionTileDelegate } from '@/store/session-states' -import { $transcriptTailBySessionId } from '@/store/transcript-tail' +import { $transcriptTailBySessionId, transcriptTailState } from '@/store/transcript-tail' import { isAuxiliaryWindow, isWatchWindow } from '@/store/windows' import type { ModelOptionsResponse } from '@/types/hermes' @@ -264,10 +266,23 @@ function ChatRuntimeBoundary({ const runtimeMessageRepository = useRuntimeMessageRepository(windowedMessages) const storedId = useStore(view.$storedId) + const connection = useStore($connection) + const activeProfile = useStore($activeGatewayProfile) // Subscribed (not read imperatively) so the "Show earlier" affordance // appears/retires as tail hydrations and backfill pages record their state. const transcriptTailStates = useStore($transcriptTailBySessionId) - const restBackfillAvailable = Boolean(storedId && transcriptTailStates[storedId]?.possiblyTruncated) + const connectionId = connection?.connectionId || (connection?.mode === 'local' ? 'local' : '') + + const ownerRoute = storedId + ? getSessionOwnerHint(storedId, connectionId ? { connectionId, profile: activeProfile } : undefined) + : undefined + + const tailProfile = ownerRoute + ? { connectionId: ownerRoute.connectionId, profile: ownerRoute.targetProfile || ownerRoute.profile } + : undefined + + const tailState = storedId && transcriptTailStates ? transcriptTailState(storedId, tailProfile) : undefined + const restBackfillAvailable = Boolean(tailState?.possiblyTruncated) const expandWindow = useCallback(() => { // The store window still holds older messages: growing pages is enough. @@ -276,9 +291,15 @@ function ChatRuntimeBoundary({ // PREPEND it to the session store before growing, so the grown window has // something older to show. Fire-and-forget: the prepend lands through the // session-state write path and re-renders this boundary. - if (!windowStateRef.current?.window.windowed && runtimeId && storedId && transcriptBackfillAvailable(storedId)) { + if ( + !windowStateRef.current?.window.windowed && + runtimeId && + storedId && + transcriptBackfillAvailable(storedId, tailProfile) + ) { void backfillOlderTranscriptPage({ storedSessionId: storedId, + profile: tailProfile, // Stale-response guard: a session switch remounts/re-keys this view; // checking the live atoms (not captured props) discards a page that // resolves after the user moved on — same pattern as isCurrentResume. @@ -294,7 +315,7 @@ function ChatRuntimeBoundary({ } setWindowPages(pages => pages + 1) - }, [runtimeId, storedId, view]) + }, [runtimeId, storedId, tailProfile, view]) const olderAvailable = windowed || restBackfillAvailable diff --git a/apps/desktop/src/app/chat/pane-mirror.test.ts b/apps/desktop/src/app/chat/pane-mirror.test.ts new file mode 100644 index 000000000000..eeceb0899489 --- /dev/null +++ b/apps/desktop/src/app/chat/pane-mirror.test.ts @@ -0,0 +1,101 @@ +import { atom } from 'nanostores' +import { afterEach, describe, expect, it } from 'vitest' + +import { contributesToWorkspace } from '@/components/pane-shell/workspace-scope' +import { registry } from '@/contrib/registry' + +import { paneMirror } from './pane-mirror' + +interface Tile { + id: string + owner?: string +} + +const cleanupSources: Array>> = [] +let sequence = 0 + +function setup(options: { + workspaceMode?: 'sessions' | 'bots' | ((tile: Tile) => 'sessions' | 'bots' | undefined) + workspaceOwnerKey?: string | ((tile: Tile) => string | undefined) +}) { + const source = atom([]) + const prefix = `pane-mirror-scope-${sequence++}` + cleanupSources.push(source) + + paneMirror({ + source, + key: tile => tile.id, + prefix, + minWidth: '10rem', + title: key => key, + render: () => null, + close: () => undefined, + ...options + })() + + return { + source, + contribution: (id: string) => registry.getArea('panes').find(entry => entry.id === `${prefix}:${id}`) + } +} + +afterEach(() => { + for (const source of cleanupSources.splice(0)) { + source.set([]) + } +}) + +describe('paneMirror workspace scope', () => { + it('forwards a static workspace mode', () => { + const mirror = setup({ workspaceMode: 'sessions' }) + mirror.source.set([{ id: 'one' }]) + + expect(mirror.contribution('one')).toMatchObject({ + workspaceMode: 'sessions', + workspaceOwnerKey: undefined + }) + }) + + it('resolves owner callbacks per tile and refreshes an unchanged title', () => { + const mirror = setup({ + workspaceMode: 'bots', + workspaceOwnerKey: tile => tile.owner + }) + + mirror.source.set([{ id: 'one', owner: 'connection-a::default' }]) + expect(mirror.contribution('one')?.workspaceOwnerKey).toBe('connection-a::default') + + mirror.source.set([{ id: 'one', owner: 'connection-b::default' }]) + expect(mirror.contribution('one')?.workspaceOwnerKey).toBe('connection-b::default') + }) + + it('leaves existing callers unscoped when options are omitted', () => { + const mirror = setup({}) + mirror.source.set([{ id: 'one' }]) + + expect(mirror.contribution('one')).toMatchObject({ + workspaceMode: undefined, + workspaceOwnerKey: undefined + }) + }) + + it('keeps an unscoped Browser tile visible in Bot Mode', () => { + const mirror = setup({}) + mirror.source.set([{ id: 'url:browser' }]) + + const pane = mirror.contribution('url:browser') + + expect(contributesToWorkspace(pane, 'sessions')).toBe(true) + expect(contributesToWorkspace(pane, 'bots', 'bot:connection-a::default')).toBe(true) + }) + + it('hides a Sessions-only Browser tile from Bot Mode', () => { + const mirror = setup({ workspaceMode: 'sessions' }) + mirror.source.set([{ id: 'url:browser' }]) + + const pane = mirror.contribution('url:browser') + + expect(contributesToWorkspace(pane, 'sessions')).toBe(true) + expect(contributesToWorkspace(pane, 'bots', 'bot:connection-a::default')).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/pane-mirror.ts b/apps/desktop/src/app/chat/pane-mirror.ts index 3a6995e1b4d6..c0886ff77113 100644 --- a/apps/desktop/src/app/chat/pane-mirror.ts +++ b/apps/desktop/src/app/chat/pane-mirror.ts @@ -10,14 +10,25 @@ import type { ReadableAtom } from 'nanostores' import type { ReactElement, ReactNode, PointerEvent as ReactPointerEvent } from 'react' import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store' +import type { MenuKit } from '@/components/ui/actions-menu' import { registry } from '@/contrib/registry' +import type { WorkspaceMode } from '@/contrib/types' import type { TileDock } from '@/store/session-states' +type WorkspaceValue = V | ((tile: T) => V | undefined) + +const workspaceValue = (value: WorkspaceValue | undefined, tile: T): V | undefined => + typeof value === 'function' ? (value as (tile: T) => V | undefined)(tile) : value + export interface PaneMirror { /** Reactive source list. */ source: ReadableAtom /** Extra atoms whose changes should re-sync (e.g. titles living elsewhere). */ also?: ReadableAtom[] + /** Workspace surface this tile belongs to. Omit for a global pane. */ + workspaceMode?: WorkspaceValue + /** Exact opaque owner inside Bot Mode. Omit outside an owner-scoped pane. */ + workspaceOwnerKey?: WorkspaceValue /** Stable key + pane-id seed for a tile. */ key: (tile: T) => string /** Pane-id namespace — the id is `${prefix}:${key}`. */ @@ -38,7 +49,12 @@ export interface PaneMirror { * as `tabLead` — a name that moves faster than re-registration (see * PaneChrome.tabTitle). Falls back to `title`. */ tabTitle?: (key: string) => ReactNode + /** Mint another tile of this kind — the strip's "+" (see PaneChrome.newTab). + * Per tile so a mirror can offer it for some of its tabs and not others. */ + newTab?: (key: string) => (() => void) | undefined render: (key: string) => ReactNode + /** Extra rows at the top of the zone tab menu (see PaneChrome.tabMenuPrefix). */ + tabMenuPrefix?: (key: string) => ((kit: MenuKit) => ReactNode) | undefined /** Wrap the tile's TAB (domain context menu — session verbs). */ tabWrap?: (key: string, tab: ReactElement) => ReactNode /** Override the tile's TAB drag (session drop language: stack/split/link). @@ -51,7 +67,11 @@ export interface PaneMirror { /** Build a `watch*` fn: syncs once, then re-syncs on every source/also change. * Module-level state lives in the returned closure, so call it once per app. */ export function paneMirror(cfg: PaneMirror): () => void { - const registered = new Map void; title: string }>() + const registered = new Map< + string, + { dispose: () => void; title: string; workspaceMode?: WorkspaceMode; workspaceOwnerKey?: string } + >() + const paneId = (key: string) => `${cfg.prefix}:${key}` const sync = () => { @@ -61,10 +81,17 @@ export function paneMirror(cfg: PaneMirror): () => void { for (const tile of tiles) { const key = cfg.key(tile) const title = cfg.title(key) + const workspaceMode = workspaceValue(cfg.workspaceMode, tile) + const workspaceOwnerKey = workspaceValue(cfg.workspaceOwnerKey, tile) const current = registered.get(key) // register() replaces same-id in place — safe for live title refreshes. - if (current && current.title === title) { + if ( + current && + current.title === title && + current.workspaceMode === workspaceMode && + current.workspaceOwnerKey === workspaceOwnerKey + ) { continue } @@ -81,6 +108,7 @@ export function paneMirror(cfg: PaneMirror): () => void { pos: cfg.dir?.(tile) ?? 'right' }, minWidth: cfg.minWidth, + newTab: cfg.newTab?.(key), // Every mirrored tile is a full workspace surface docked beside main — // and closeable, which is what keeps its tab when it lands in a zone of // its own (see strip-visibility.ts). @@ -88,12 +116,15 @@ export function paneMirror(cfg: PaneMirror): () => void { tabDrag: cfg.tabDrag ? (event: ReactPointerEvent, onTap: () => void) => cfg.tabDrag!(key, event, onTap) : undefined, // returns boolean (handled) — see PaneChrome.tabDrag + tabMenuPrefix: cfg.tabMenuPrefix?.(key), tabWrap: cfg.tabWrap ? (tab: ReactElement) => cfg.tabWrap!(key, tab) : undefined }, - render: () => cfg.render(key) + render: () => cfg.render(key), + workspaceMode, + workspaceOwnerKey }) - registered.set(key, { dispose, title }) + registered.set(key, { dispose, title, workspaceMode, workspaceOwnerKey }) if (!current) { registerPaneCloser(paneId(key), () => cfg.close(key)) diff --git a/apps/desktop/src/app/chat/preview-tile.test.ts b/apps/desktop/src/app/chat/preview-tile.test.ts new file mode 100644 index 000000000000..51073def256e --- /dev/null +++ b/apps/desktop/src/app/chat/preview-tile.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('./right-rail/preview', () => ({ + PreviewTilePane: () => null +})) + +vi.mock('./right-rail/preview-console-store', () => ({ + forgetPreviewConsole: () => undefined +})) + +import { contributesToWorkspace } from '@/components/pane-shell/workspace-scope' +import { registry } from '@/contrib/registry' +import { $previewTabs, closeRightRail, noteBrowserPage, openPreview } from '@/store/preview' + +import { browserTabExternalUrl, browserTabLabel, watchPreviewTiles } from './preview-tile' + +beforeAll(() => { + watchPreviewTiles() +}) + +afterEach(() => { + closeRightRail() +}) + +// By prefix, not by a literal id: a Browser tab's id is minted per tab now +// that there can be more than one of them. +function browserPane() { + return registry.getArea('panes').find(entry => entry.id.startsWith('preview-tile:url:')) +} + +describe('preview tiles in Bot Mode', () => { + it('registers the in-app Browser as a global pane so Bot Mode can show it', () => { + openPreview( + { kind: 'url', label: 'example.com', source: 'https://example.com', url: 'https://example.com' }, + 'explicit-link' + ) + + const pane = browserPane() + + expect(pane).toBeTruthy() + expect(pane?.workspaceMode).toBeUndefined() + expect(contributesToWorkspace(pane, 'sessions')).toBe(true) + expect(contributesToWorkspace(pane, 'bots', 'bot:connection-a::default')).toBe(true) + }) +}) + +describe('browserTabLabel', () => { + const target = { kind: 'url', label: 'Browser', source: 'about:blank', url: 'about:blank' } as const + + it('names the tab after the page', () => { + expect(browserTabLabel(target, { title: 'Hacker News', url: 'https://news.ycombinator.com/' })).toBe('Hacker News') + }) + + // Chromium hands back the address as the title when the page never set one, + // which is a worse tab label than the host it came from. + it('falls back to the host when the page has no title of its own', () => { + expect(browserTabLabel(target, { title: '', url: 'https://www.example.com/a/b' })).toBe('example.com') + expect(browserTabLabel(target, { title: 'https://example.com/a', url: 'https://example.com/a' })).toBe( + 'example.com' + ) + }) + + it('falls back to the surface when there is no page and no host', () => { + expect(browserTabLabel(target)).toBe('Browser') + expect(browserTabLabel(target, { title: '', url: 'about:blank' })).toBe('Browser') + }) + + // A tab restored from storage has reported nothing yet, so its target is all + // there is to name it by. + it('names an unreported tab from its target', () => { + expect(browserTabLabel({ ...target, url: 'https://github.com/nous' })).toBe('github.com') + }) +}) + +describe('browserTabExternalUrl', () => { + const openBrowser = (url: string) => { + openPreview({ kind: 'url', label: 'Browser', source: url, url }, 'explicit-link') + + return $previewTabs.get().find(tab => tab.target.kind === 'url')!.id + } + + it('hands the live page to the OS browser, not the address the tab was opened with', () => { + const tabId = openBrowser('https://example.com') + + noteBrowserPage(tabId, { title: 'Hacker News', url: 'https://news.ycombinator.com/' }) + + expect(browserTabExternalUrl(tabId)).toBe('https://news.ycombinator.com/') + }) + + it('falls back to the target when the tab has not reported a page yet', () => { + expect(browserTabExternalUrl(openBrowser('https://github.com/nous'))).toBe('https://github.com/nous') + }) + + it('refuses about:blank and other non-pages', () => { + expect(browserTabExternalUrl(openBrowser('about:blank'))).toBeNull() + }) + + it('is null for a file peek', () => { + openPreview(fileTarget('/tmp/a.ts'), 'file-browser') + + expect(browserTabExternalUrl('file:/tmp/a.ts')).toBeNull() + }) +}) + +type DockData = { dock?: { pane?: string; pos?: string } } | undefined + +function dockOf(paneId: string) { + return (registry.getArea('panes').find(entry => entry.id === paneId)?.data as DockData)?.dock +} + +const fileTarget = (path: string) => + ({ kind: 'file', label: path.split('/').at(-1) ?? path, path, source: path, url: path }) as const + +describe('preview tiles stack, not split (#93610)', () => { + it('docks the first preview right and stacks the second as a center tab in the same zone', () => { + openPreview(fileTarget('/tmp/a.ts'), 'file-browser') + + const first = dockOf('preview-tile:file:/tmp/a.ts') + + expect(first?.pos).toBe('right') + + openPreview(fileTarget('/tmp/b.ts'), 'file-browser') + + const second = dockOf('preview-tile:file:/tmp/b.ts') + + expect(second?.pos).toBe('center') + expect(second?.pane).toBe('preview-tile:file:/tmp/a.ts') + + // The first pane's registration is untouched — one preview zone, two tabs. + expect(dockOf('preview-tile:file:/tmp/a.ts')?.pos).toBe('right') + }) + + it('stacks an artifact opened after a file into the same preview zone', () => { + openPreview(fileTarget('/tmp/a.ts'), 'file-browser') + openPreview({ kind: 'artifact', label: 'Chart', source: 'artifact-1', url: 'artifact-1' }, 'explicit-link') + + const artifact = dockOf('preview-tile:artifact:artifact-1') + + expect(artifact?.pos).toBe('center') + expect(artifact?.pane).toBe('preview-tile:file:/tmp/a.ts') + }) + + it('lets a lone preview open its own right-docked zone again after all tabs closed', () => { + openPreview(fileTarget('/tmp/a.ts'), 'file-browser') + openPreview(fileTarget('/tmp/b.ts'), 'file-browser') + closeRightRail() + + openPreview(fileTarget('/tmp/c.ts'), 'file-browser') + + expect(dockOf('preview-tile:file:/tmp/c.ts')?.pos).toBe('right') + }) +}) diff --git a/apps/desktop/src/app/chat/preview-tile.tsx b/apps/desktop/src/app/chat/preview-tile.tsx index 7051fbe80f6c..7777cf954c74 100644 --- a/apps/desktop/src/app/chat/preview-tile.tsx +++ b/apps/desktop/src/app/chat/preview-tile.tsx @@ -10,12 +10,30 @@ * one bar instead of two. */ +import { useStore } from '@nanostores/react' + import { findGroup } from '@/components/pane-shell/tree/model' -import { $activeTreeGroup, $layoutTree, revealTreePane } from '@/components/pane-shell/tree/store' +import { $activeTreeGroup, $layoutTree, revealTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store' +import { type MenuKit, renderActionItem } from '@/components/ui/actions-menu' import { FileTypeIcon } from '@/components/ui/file-type-icon' import { ToolIcon } from '@/components/ui/tool-icon' +import { translateNow } from '@/i18n' +import { openExternalLink } from '@/lib/external-link' import { $rightRailActiveTabId, type RightRailTabId, selectRightRailTab } from '@/store/layout' -import { $previewTabs, closeRightRailTab, type PreviewTarget } from '@/store/preview' +import { + $browserPages, + $dockedPreviewTabs, + $previewTabs, + adoptPersistedBrowserTab, + type BrowserPage, + closeRightRailTab, + forgetBrowserPage, + markBrowserTabPopped, + newBrowserTab, + popOutBrowserTab, + type PreviewTarget +} from '@/store/preview' +import { canOpenBrowserWindow } from '@/store/windows' import { paneMirror } from './pane-mirror' import { PreviewTilePane } from './right-rail/preview' @@ -26,9 +44,56 @@ function targetFor(tabId: string): PreviewTarget | null { return $previewTabs.get().find(tab => tab.id === tabId)?.target ?? null } -/** Tab title. A URL is a BROWSER — the tab names the surface, not the page, so - * it doesn't rename itself on every navigation. A file names the file; an - * artifact is titled rather than located, so its label is the whole name. */ +/** Schemes that are not a page the user's default browser can usefully open + * (blank vessel, Chromium internals, injected documents). */ +const NON_EXTERNAL_URL = /^(about|blob|chrome|data|devtools|javascript):/i + +/** The URL a Browser tab should hand to the OS browser — the page it is + * showing now, else the address it was opened with. Null when there isn't + * one (`about:blank`, a half-typed address, a file/artifact tab). */ +export function browserTabExternalUrl(tabId: string): null | string { + const target = targetFor(tabId) + + if (target?.kind !== 'url') { + return null + } + + const url = $browserPages.get()[tabId]?.url || target.url + + return url && !NON_EXTERNAL_URL.test(url) ? url : null +} + +function browserTabMenuPrefix(tabId: string) { + if (targetFor(tabId)?.kind !== 'url') { + return undefined + } + + return (kit: MenuKit) => ( + <> + {canOpenBrowserWindow() + ? renderActionItem(kit, { + icon: 'empty-window', + key: 'pop-out', + label: translateNow('preview.popOut'), + onSelect: () => popOutBrowserTab(tabId) + }) + : null} + {renderActionItem(kit, { + disabled: !browserTabExternalUrl(tabId), + icon: 'link-external', + key: 'open-external', + label: translateNow('preview.openInExternal'), + onSelect: () => openExternalLink(browserTabExternalUrl(tabId) ?? '') + })} + + ) +} + +/** Tab title. A URL tab is titled by the CONTRIBUTION as the surface — see + * `BrowserTabLabel` for the live page name the strip actually renders — so + * navigating doesn't re-register the pane on every hop. A file names the + * file; an artifact is titled rather than located, so its label is the whole + * name. */ function previewTitle(tabId: string): string { const target = targetFor(tabId) @@ -50,6 +115,40 @@ function previewTitle(tabId: string): string { return tail || value || 'Preview' } +/** + * What to call a Browser tab. Its page title, else the host it is on, else + * the surface — the ladder every browser walks, and the reason more than one + * Browser is usable at all: three tabs reading "Browser" name nothing. + * + * A page that never set a title reports its own address as one, which is a + * worse label than the host it came from, so an address-shaped title falls + * through. + */ +export function browserTabLabel(target: PreviewTarget, page?: BrowserPage): string { + const url = page?.url || target.url + const title = page?.title.trim() + + if (title && title !== url) { + return title + } + + try { + return new URL(url).hostname.replace(/^www\./, '') || 'Browser' + } catch { + // `about:blank` and half-typed addresses have no host to fall back to. + return 'Browser' + } +} + +/** Live tab label for a Browser: it renames itself as the page navigates, + * without the contribution re-registering (see PaneChrome.tabTitle). */ +function BrowserTabLabel({ tabId }: { tabId: string }) { + const pages = useStore($browserPages) + const target = targetFor(tabId) + + return target ? browserTabLabel(target, pages[tabId]) : null +} + /** The tab's lead glyph — the same file/tool icon family the file tree and code * fences resolve through, so a `.tsx` peek and its sidebar row agree. */ function PreviewTabLead({ tabId }: { tabId: string }) { @@ -72,12 +171,36 @@ function PreviewTabLead({ tabId }: { tabId: string }) { const PREVIEW_TILE_PREFIX = 'preview-tile' +const previewPaneId = (tabId: string) => `${PREVIEW_TILE_PREFIX}:${tabId}` + +/** The pane a NEW preview tile should stack into: another preview tile already + * in the tree, else another open tab adopted earlier in the same pass (a + * reload restores every tab at once, before any of them is in the tree). + * `undefined` means this is the first preview — it opens its own zone. */ +function existingPreviewAnchor(tabId: string): string | undefined { + const own = previewPaneId(tabId) + const inTree = treePanesWithPrefix(`${PREVIEW_TILE_PREFIX}:`).find(id => id !== own) + + if (inTree) { + return inTree + } + + const other = $dockedPreviewTabs.get().find(tab => tab.id !== tabId) + + return other ? previewPaneId(other.id) : undefined +} + /** Keep pane contributions mirroring `$previewTabs`, keep the store's selection * and the tree's active pane agreeing, and front a tile when its tab is * selected. Call once from the root. */ export function watchPreviewTiles(): void { watchPreviewTileMirror() + window.hermesDesktop?.onBrowserPopoutClosed?.(tabId => { + adoptPersistedBrowserTab(tabId) + markBrowserTabPopped(tabId, false) + }) + // The reveal analog of session tiles (session-states calls revealTreePane on // open): `openPreview` selects the tab, and the TREE must front its pane — // un-minimize, un-hide, activate in its zone. Both stores, because re-opening @@ -121,19 +244,31 @@ export function watchPreviewTiles(): void { } const watchPreviewTileMirror = paneMirror<{ id: string }>({ - source: $previewTabs, + source: $dockedPreviewTabs, + // Unscoped on purpose. `$previewTabs` is one global Browser/file surface — + // clicking a link in a bot chat must open the same pane Sessions already + // shows. Scoping this to `sessions` filtered the pane out of Bot Mode, so + // `openPreview` ran and the click looked like a no-op. key: tab => tab.id, prefix: PREVIEW_TILE_PREFIX, - // Identical to route (page) tiles: its own zone docked beside main, sized by - // the split weights. NOT anchored to the file tree — the old rail was a - // files-adjacent strip, and carrying that over welded preview into the file - // browser's zone, so ⌘J (toggle file browser) took the preview with it. - dir: () => 'right', + // The FIRST preview still opens its own zone docked beside main (identical + // to route tiles — NOT anchored to the file tree, so ⌘J can't take it + // along). Every SUBSEQUENT preview stacks into that zone as a center tab: + // without the anchor each opened file split a new zone off the right edge + // (#93610), turning three file opens into three ever-narrower columns. + dir: tab => (existingPreviewAnchor(tab.id) ? 'center' : 'right'), + anchor: tab => existingPreviewAnchor(tab.id), minWidth: '22rem', title: previewTitle, tabLead: tabId => , + tabTitle: tabId => (targetFor(tabId)?.kind === 'url' ? : undefined), + // A Browser is a vessel, so there can be more of it — a file peek is one of + // a kind and leaves the strip's "+" to whatever else the zone holds. + newTab: tabId => (targetFor(tabId)?.kind === 'url' ? newBrowserTab : undefined), + tabMenuPrefix: browserTabMenuPrefix, render: tabId => , close: tabId => { + forgetBrowserPage(tabId) forgetPreviewConsole(tabId) closeRightRailTab(tabId) } diff --git a/apps/desktop/src/app/chat/right-rail/preview-browser-bar.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-browser-bar.test.tsx index 64a8cb578bee..7bd51099aa92 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-browser-bar.test.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-browser-bar.test.tsx @@ -12,7 +12,7 @@ const baseProps = { onBack: vi.fn(), onForward: vi.fn(), onNavigate: vi.fn(), - onOpenExternal: vi.fn(), + onPopOut: vi.fn(), onReload: vi.fn(), onToggleConsole: vi.fn(), onToggleDevTools: vi.fn(), @@ -86,7 +86,7 @@ describe('PreviewBrowserBar', () => { expect(rendered.getByRole('button', { name: 'Forward' })).toBeTruthy() expect(rendered.getByRole('button', { name: 'Reload page' })).toBeTruthy() expect(rendered.getByRole('button', { name: 'Copy URL' })).toBeTruthy() - expect(rendered.getByRole('button', { name: 'Open in browser' })).toBeTruthy() + expect(rendered.getByRole('button', { name: 'Pop out' })).toBeTruthy() expect(rendered.getByRole('button', { name: 'Show preview console' })).toBeTruthy() expect(rendered.getByRole('button', { name: 'Open preview DevTools' })).toBeTruthy() expect(address(rendered)).toBeTruthy() @@ -110,7 +110,7 @@ describe('PreviewBrowserBar', () => { ['Back', 'onBack'], ['Forward', 'onForward'], ['Reload page', 'onReload'], - ['Open in browser', 'onOpenExternal'] + ['Pop out', 'onPopOut'] ] as const)('fires %s', (label, handler) => { const spy = vi.fn() const rendered = render() @@ -163,7 +163,10 @@ describe('PreviewBrowserBar', () => { expect(address(rendered).value).toBe('example.org/docs') }) - it('normalizes and navigates on Enter, then releases the field back to the page', () => { + // Committing used to drop the field back to `url` — the page you were + // LEAVING — so the address you typed flashed away and came back once the + // load landed. What you asked for stays put until the page actually moves. + it('normalizes and navigates on Enter, and holds the address it asked for', () => { const onNavigate = vi.fn() const rendered = render() const input = address(rendered) @@ -173,7 +176,35 @@ describe('PreviewBrowserBar', () => { fireEvent.keyDown(input, { key: 'Enter' }) expect(onNavigate).toHaveBeenCalledWith('http://localhost:5173') - expect(address(rendered).value).toBe('https://example.com') + expect(address(rendered).value).toBe('http://localhost:5173') + }) + + // A redirect means the address you asked for is no longer the truth. + it('releases the asked-for address once the page lands somewhere', () => { + const rendered = render() + const input = address(rendered) + + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'example.org' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + rendered.rerender() + + expect(address(rendered).value).toBe('https://www.example.org/home') + }) + + // Re-focusing mid-flight must offer what is on screen to edit, not resurrect + // the address of the page being left behind. + it('hands the in-flight address to the field on re-focus', () => { + const rendered = render() + const input = address(rendered) + + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'example.org' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + fireEvent.blur(input) + fireEvent.focus(input) + + expect(address(rendered).value).toBe('https://example.org') }) it('refuses to navigate to a script-bearing scheme and marks the field invalid', () => { @@ -229,6 +260,19 @@ describe('PreviewBrowserBar', () => { expect(address(rendered).value).toBe('https://example.com') }) + // Progress belongs beside the address it describes; the reload glyph sits in + // a row of four and reads as chrome rather than as this page's state. + it('shows progress inside the address field only while loading', () => { + const { container, rerender } = render() + const field = screen.getByRole('textbox', { name: 'Address' }).parentElement + + expect(field?.querySelector('.codicon-loading')).toBeTruthy() + + rerender() + + expect(container.querySelector('.codicon-loading')).toBeNull() + }) + it('spins the reload glyph only while loading', () => { const { container, rerender } = render() @@ -273,4 +317,37 @@ describe('PreviewBrowserBar', () => { expect(copyButton.parentElement?.contains(address)).toBe(true) expect(copyButton.className).toContain('absolute') }) + + it('shows Open in browser when only the external handler is provided', () => { + const onOpenExternal = vi.fn() + + const rendered = render() + + expect(rendered.getByRole('button', { name: 'Open in browser' })).toBeTruthy() + expect(rendered.queryByRole('button', { name: 'Pop out' })).toBeNull() + + fireEvent.click(rendered.getByRole('button', { name: 'Open in browser' })) + + expect(onOpenExternal).toHaveBeenCalledOnce() + }) + + it('prefers pop-out over open-in-browser when both handlers are provided', () => { + const rendered = render() + + expect(rendered.getByRole('button', { name: 'Pop out' })).toBeTruthy() + expect(rendered.queryByRole('button', { name: 'Open in browser' })).toBeNull() + }) + + it('shows Pop in when the window is already popped out', () => { + const onPopIn = vi.fn() + const rendered = render() + + expect(rendered.getByRole('button', { name: 'Pop in' })).toBeTruthy() + expect(rendered.queryByRole('button', { name: 'Pop out' })).toBeNull() + expect(rendered.queryByRole('button', { name: 'Open in browser' })).toBeNull() + + fireEvent.click(rendered.getByRole('button', { name: 'Pop in' })) + + expect(onPopIn).toHaveBeenCalledOnce() + }) }) diff --git a/apps/desktop/src/app/chat/right-rail/preview-browser-bar.tsx b/apps/desktop/src/app/chat/right-rail/preview-browser-bar.tsx index f5dccb937470..ec7b87f0d001 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-browser-bar.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-browser-bar.tsx @@ -1,6 +1,6 @@ /** - * BROWSER BAR: back / forward / reload / address / open-in-browser for a URL - * preview. + * BROWSER BAR: back / forward / reload / address / pop-out (or pop-in) for a + * URL preview. * * The Browser tab had no way to move: no history, and the only address on * screen was a read-only label. Every other embedded browser (VS Code's Simple @@ -14,13 +14,14 @@ * buttons, so a glyph here and a glyph on the strip are still the same button. */ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' import { Input } from '@/components/ui/input' import { PaneStripGlyph } from '@/components/ui/pane-tab' import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' interface PreviewBrowserBarProps { canGoBack: boolean @@ -31,7 +32,9 @@ interface PreviewBrowserBarProps { onBack: () => void onForward: () => void onNavigate: (url: string) => void - onOpenExternal: () => void + onOpenExternal?: () => void + onPopIn?: () => void + onPopOut?: () => void onReload: () => void onToggleConsole: () => void onToggleDevTools: () => void @@ -95,6 +98,8 @@ export function PreviewBrowserBar({ onForward, onNavigate, onOpenExternal, + onPopIn, + onPopOut, onReload, onToggleConsole, onToggleDevTools, @@ -105,9 +110,18 @@ export function PreviewBrowserBar({ // Null while the field is idle, so the address tracks navigation on its own; // a string once the user takes it over, so typing survives a page load. const [draft, setDraft] = useState(null) + // The address we asked for and are still waiting on. Without it, committing + // dropped the field straight back to `url` — the page you were LEAVING — + // so every navigation flashed the old address before the new one arrived. + const [pending, setPending] = useState(null) // Only while the user is typing: a page that navigates itself is never the // user's mistake to flag. const invalid = draft !== null && draft.trim().length > 0 && !normalizePreviewAddress(draft) + const shown = draft ?? pending ?? url + + // The page moved (or a redirect landed somewhere else entirely), so the real + // address supersedes what we asked for. + useEffect(() => setPending(null), [url]) const commit = (value: string) => { const address = normalizePreviewAddress(value) @@ -117,6 +131,7 @@ export function PreviewBrowserBar({ } setDraft(null) + setPending(address) onNavigate(address) } @@ -144,15 +159,26 @@ export function PreviewBrowserBar({ It copies what the field shows: on a remote gateway, that is the reach-resolved address. */}
+ {/* Progress lives IN the field, where the address it belongs to is — + the reload glyph also spins, but it sits in a row of four and + reads as chrome rather than as this page's state. */} + {loading && ( + + )} setDraft(null)} onChange={event => setDraft(event.target.value)} onFocus={event => { - setDraft(url) + setDraft(shown) event.currentTarget.select() }} onKeyDown={event => { @@ -169,7 +195,7 @@ export function PreviewBrowserBar({ placeholder={copy.addressPlaceholder} size="xs" spellCheck={false} - value={draft ?? url} + value={shown} />
- } - label={t.preview.openInBrowser} - onSelect={onOpenExternal} - /> + {onPopIn ? ( + } + label={t.preview.popIn} + onSelect={onPopIn} + /> + ) : onPopOut ? ( + } + label={t.preview.popOut} + onSelect={onPopOut} + /> + ) : onOpenExternal ? ( + } + label={t.preview.openInBrowser} + onSelect={onOpenExternal} + /> + ) : null} } diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index ffbc96d7e22a..f3723f19d749 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -13,7 +13,6 @@ import { Streamdown } from 'streamdown' import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/composer/focus' import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs' import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions' -import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection' import { RichCodeBlock } from '@/components/assistant-ui/embeds' import { CodeEditor } from '@/components/chat/code-editor' import { FileDiffPanel } from '@/components/chat/diff-lines' @@ -32,6 +31,7 @@ import { } from '@/lib/desktop-fs' import { Check, Pencil, X } from '@/lib/icons' import { createMemoizedMathPlugin } from '@/lib/katex-memo' +import { isComposerChord } from '@/lib/keybinds/chords' import { shikiLanguageForFilename } from '@/lib/markdown-code' import { normalizeFilePreviewMath } from '@/lib/markdown-preprocess' import { cn } from '@/lib/utils' @@ -605,7 +605,7 @@ export function SourceView({ filePath, language, text }: { filePath?: string; la } const onKeyDown = (event: KeyboardEvent) => { - if (!isAddSelectionShortcut(event)) { + if (!isComposerChord(event)) { return } diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index 817cec638a5d..cfbfc8beb611 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -18,7 +18,16 @@ import { reachablePreviewUrl } from '@/lib/preview-reach' import { rafCoalesce } from '@/lib/raf-coalesce' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' -import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget } from '@/store/preview' +import { + $browserPages, + $previewServerRestart, + commitBrowserTabLocation, + failPreviewServerRestart, + noteBrowserPage, + popOutBrowserTab, + type PreviewTarget +} from '@/store/preview' +import { canOpenBrowserWindow, isBrowserWindow } from '@/store/windows' import { ArtifactPreview } from './preview-artifact' import { PreviewBrowserBar } from './preview-browser-bar' @@ -61,6 +70,20 @@ type PreviewWebview = HTMLElement & { sendInputEvent?: (event: PreviewInputEvent) => void } +/** Electron throws if getURL/getTitle run before attach + dom-ready, or after + * the guest has been removed. Optional chaining does not help — the method + * exists, it just refuses. */ +function guestPage(webview: PreviewWebview | null | undefined, fallbackUrl = ''): { title: string; url: string } { + try { + return { + title: webview?.getTitle?.() ?? '', + url: webview?.getURL?.() || fallbackUrl + } + } catch { + return { title: '', url: fallbackUrl } + } +} + /** The raw Chromium params riding the webview tag's `context-menu` event. */ interface GuestContextMenuParams { dictionarySuggestions?: string[] @@ -213,6 +236,8 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = const consoleHeight = useStore(consoleState.$height) const consoleOpen = useStore(consoleState.$open) const [currentUrl, setCurrentUrl] = useState(target.url) + const liveUrlRef = useRef(currentUrl) + liveUrlRef.current = currentUrl const [devtoolsOpen, setDevtoolsOpen] = useState(false) const [history, setHistory] = useState({ back: false, forward: false }) const [loading, setLoading] = useState(true) @@ -228,6 +253,27 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = const isRemoteHtmlTarget = target.kind === 'file' && target.previewKind === 'html' && Boolean(target.dataUrl || target.transient) + // Hand the live address to storage when this guest is about to go away + // (pop-out, dock-back, tab close). The other renderer builds from + // `target.url`; without this it would reopen the tab's original page. + useEffect(() => { + if (target.kind !== 'url' || !tabId) { + return + } + + const persist = () => { + const page = $browserPages.get()[tabId] + commitBrowserTabLocation(tabId, page?.url || liveUrlRef.current, page?.title) + } + + window.addEventListener('pagehide', persist) + + return () => { + window.removeEventListener('pagehide', persist) + persist() + } + }, [tabId, target.kind]) + const isRemoteHtml = isRemoteHtmlTarget && target.renderMode !== 'source' && Boolean(target.dataUrl) const remoteHtmlDocument = useMemo( @@ -385,6 +431,10 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = const navigateTo = useCallback( (url: string) => { setLoadError(null) + // The reach probe below is a round-trip of its own, and `did-start-loading` + // can't fire until it resolves — so own the loading state from the moment + // we accept the address, or the bar sits idle over a request in flight. + setLoading(true) // Typed addresses get the same loopback reach as agent-opened ones — on a // remote gateway `localhost:5173` is usually the dev server the user is // there to look at, not something on their own laptop. @@ -455,8 +505,7 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = return { text: typeof text === 'string' ? text : '', - title: webview.getTitle?.() ?? '', - url: webview.getURL?.() ?? '' + ...guestPage(webview) } }) }, [isWebPreview, tabId]) @@ -737,14 +786,31 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = if ((detail.level ?? 0) >= 3 && isModuleMimeError(message)) { setLoadError({ description: copy.moduleMimeDescription, - url: webview.getURL?.() || target.url + url: guestPage(webview, target.url).url }) setLoading(false) } } - const syncHistory = () => - setHistory({ back: webview.canGoBack?.() ?? false, forward: webview.canGoForward?.() ?? false }) + const syncHistory = () => { + try { + setHistory({ back: webview.canGoBack?.() ?? false, forward: webview.canGoForward?.() ?? false }) + } catch { + // Same attach / dom-ready rule as getURL. + } + } + + // Tell the strip what this Browser is showing, so its tab renames itself + // like a tab anywhere else. Deliberately NOT written back into the tab's + // target: the guest is built from `target.url`, so that would rebuild the + // webview mid-navigation and throw away the history. + const notePage = () => { + if (target.kind !== 'url' || !tabId) { + return + } + + noteBrowserPage(tabId, guestPage(webview, target.url)) + } const onNavigate = (event: Event) => { const detail = event as Event & { url?: string } @@ -754,6 +820,8 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = setCurrentUrl(detail.url) } + notePage() + // Ask the webview rather than counting navigations: the guest page can // move itself (redirects, history.pushState, a link into a new document), // so it is the only thing that knows what its history holds. Wired to @@ -781,7 +849,7 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = setLoadError({ code: errorCode, description: detail.errorDescription || copy.unreachableDescription, - url: detail.validatedURL || webview.getURL?.() || target.url + url: detail.validatedURL || guestPage(webview, target.url).url }) setLoading(false) } @@ -794,6 +862,7 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = // cancelled navigation) still settles the history — resync so the // buttons can't be left stale. syncHistory() + notePage() } // The WEBVIEW is the source of truth for DevTools, not our click handler: @@ -885,6 +954,9 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = webview.addEventListener('did-navigate-in-page', onNavigate) webview.addEventListener('did-start-loading', onStart) webview.addEventListener('did-stop-loading', onStop) + // SPAs title themselves long after the load settles, and a route change + // renames the page without navigating at all. + webview.addEventListener('page-title-updated', notePage) host.appendChild(webview) webviewRef.current = webview @@ -898,9 +970,10 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = webview.removeEventListener('did-navigate-in-page', onNavigate) webview.removeEventListener('did-start-loading', onStart) webview.removeEventListener('did-stop-loading', onStop) + webview.removeEventListener('page-title-updated', notePage) webview.remove() } - }, [appendConsoleEntry, consoleState, copy, isRemoteHtml, isWebPreview, target.url]) + }, [appendConsoleEntry, consoleState, copy, isRemoteHtml, isWebPreview, tabId, target.kind, target.url]) return (
@@ -368,6 +387,8 @@ export function ProfileRail() { /> setPendingSoul(null)} profileName={pendingSoul} /> + + ) } @@ -618,7 +639,11 @@ interface ProfileSquareProps { onRecolor: (color: null | string) => void onRename: () => void onEditSoul: () => void + onConnectRemote: () => void onDelete: () => void + // hostname[:port] of this profile's remote override, or null when the + // profile runs locally. Drives the "remote" badge on the square. + remoteHost: null | string } // Hold this long without moving (a drag would have started first) to open the @@ -636,11 +661,13 @@ function ProfileSquare({ active, color, label, + onConnectRemote, onDelete, onEditSoul, onRecolor, onRename, - onSelect + onSelect, + remoteHost }: ProfileSquareProps) { const { t } = useI18n() const p = t.profiles @@ -693,7 +720,7 @@ function ProfileSquare({ - {label} + {remoteHost ? `${label} · ${p.remoteOverride.badge(remoteHost)}` : label} @@ -783,6 +822,10 @@ function ProfileSquare({ {p.exportProfile} + + + {remoteHost ? p.remoteOverride.badge(remoteHost) : p.remoteOverride.menuItem} + ({ vi.mock('@/store/windows', () => ({ canOpenSessionInTerminal: () => false, canOpenSessionWindow: () => false, + isBrowserWindow: () => false, isSecondaryWindow: () => false, openSessionInNewWindow: vi.fn(), openSessionInTerminal: vi.fn() diff --git a/apps/desktop/src/app/chat/sidebar/session-row.test.tsx b/apps/desktop/src/app/chat/sidebar/session-row.test.tsx index 38beaa3c4be4..bfbb0f77c967 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.test.tsx @@ -21,6 +21,11 @@ vi.mock('@/i18n', () => ({ useI18n: () => ({ t: { sidebar: { + messageCount: (count: number) => `${count} messages`, + toolCallCount: (count: number) => `${count} tool calls`, + projects: { + home: 'Home' + }, row: { ageMin: 'm', ageNow: 'now', @@ -31,6 +36,7 @@ vi.mock('@/i18n', () => ({ needsInput: 'Needs input', sessionActions: 'Session actions', sessionRunning: 'Running', + todoProgress: 'Tasks completed', waitingForAnswer: 'Waiting for answer' } }, @@ -150,9 +156,10 @@ const handoffAvatar = (container: HTMLElement) => const noop = vi.fn() -const renderRow = (session: SessionInfo) => +const renderRow = (session: SessionInfo, extra?: { card?: boolean }) => render( { expect(tipTrigger(avatar as HTMLElement)).toBeTruthy() }) }) + +describe('Inbox-style session card', () => { + it('gives truncated card lines room for glyph ink instead of clipping them', () => { + renderRow( + makeSession({ + cwd: '/Users/tomek/pursuit-support-agent', + message_count: 133, + model: 'gpt-4.1', + title: 'Ruff lint and pytest verification' + }), + { card: true } + ) + + const workspace = screen.getByText('pursuit-support-agent') + const title = screen.getByText('Ruff lint and pytest verification').parentElement + const footer = screen.getByText('GPT-4.1').parentElement + + expect(title).toBeTruthy() + expect(footer).toBeTruthy() + + for (const el of [workspace, title!, footer!]) { + expect(el.className).not.toMatch(/\bleading-none\b/) + expect(el.className).toMatch(/leading-\[1\.35\]/) + } + + expect(workspace.className).toMatch(/\btruncate\b/) + expect(screen.getByText('133 messages')).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index aa88248daaa1..41aa0abc2cba 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -38,6 +38,7 @@ import { SessionStatusDot } from '../session-status-dot' import { SIDEBAR_ROW_CARD_MIN_H, + SIDEBAR_TRUNCATED_LEADING, SidebarRowBody, SidebarRowGrab, SidebarRowLabel, @@ -228,7 +229,7 @@ function SidebarSessionRowImpl({