Compare commits

..

1 Commits

Author SHA1 Message Date
teknium1
fe27949cf5 feat(mcp): raise default tool-call timeout 120s -> 300s
Port from openai/codex#28234. Long-running MCP tools (web fetches,
sandboxed builds, deep-research servers) routinely exceed 120s, causing
spurious timeout failures. Codex bumped its default MCP tool timeout from
120 to 300 for the same reason.

- _DEFAULT_TOOL_TIMEOUT 120 -> 300 in tools/mcp_tool.py (per-server
  'timeout' config override unchanged)
- update test_default_timeout assertion
- document the default in mcp-config-reference.md
2026-06-16 17:02:21 -07:00
468 changed files with 2634 additions and 44138 deletions

View File

@@ -102,3 +102,6 @@ acp_registry/
.gitattributes
.hadolint.yaml
.mailmap
# Top-level LICENSE (not matched by *.md); not needed inside the container
LICENSE

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

1
.gitignore vendored
View File

@@ -5,7 +5,6 @@
*.pyc*
__pycache__/
.venv/
.venv
.vscode/
.env
.env.local

View File

@@ -9,11 +9,8 @@ FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df228
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
FROM debian:13.4
# Disable Python stdout buffering to ensure logs are printed immediately.
# Do not write .pyc files at runtime: /opt/hermes is immutable in the
# published container and writable state belongs under /opt/data.
# Disable Python stdout buffering to ensure logs are printed immediately
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# Store Playwright browsers outside the volume mount so the build-time
# install survives the /opt/data volume overlay at runtime.
@@ -189,38 +186,36 @@ RUN cd web && npm run build && \
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
COPY . .
COPY --chown=hermes:hermes . .
# ---------- Permissions ----------
# Link hermes-agent itself (editable). Deps are already installed in the
# cached layer above; `--no-deps` makes this a fast egg-link creation with no
# resolution or downloads.
RUN uv pip install --no-cache-dir --no-deps -e "."
# Keep /opt/hermes immutable for the runtime hermes user. Hosted/container
# instances must not be able to self-edit the installed source or venv; user
# data, skills, plugins, config, logs, and dashboard uploads live under
# /opt/data instead. Root can still repair the image during build/boot, but
# supervised Hermes processes drop to the non-root hermes user.
# Make install dir world-readable so any HERMES_UID can read it at runtime.
# The venv needs to be traversable too.
# node_modules trees additionally need to be writable by the hermes user
# so the runtime `npm install` triggered by _tui_need_npm_install() in
# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time
# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally
# not chowned here.
# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and
# gateway state artifacts beneath the package after services drop privileges,
# especially when the hermes UID is remapped at boot (#27221).
# The .venv MUST remain hermes-writable so lazy_deps.py can install
# remaining optional platform packages and future pin bumps at first use.
# Without this, `uv pip install` fails with EACCES and adapters silently
# fail to load. See tools/lazy_deps.py.
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 && \
printf 'docker\n' > /opt/hermes/.install_method && \
chown -R root:root /opt/hermes && \
chmod -R a+rX /opt/hermes && \
chmod -R a-w /opt/hermes
# 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
# volume that is commonly bind-mounted from the host and even shared with a
# host-side Desktop/CLI install; stamping it at boot used to clobber that
# host install's marker and wrongly block its ``hermes update``. A code-scoped
# stamp is read first by detect_install_method() and is immune to the share.
RUN chmod -R a+rX /opt/hermes && \
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
# the data volume. Each supervised service then drops to the hermes user via
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
# run as the default hermes user (UID 10000).
# ---------- Link hermes-agent itself (editable) ----------
# Deps are already installed in the cached layer above; `--no-deps` makes
# this a fast (~1s) egg-link creation with no resolution or downloads.
RUN uv pip install --no-cache-dir --no-deps -e "."
# ---------- Bake build-time git revision ----------
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
# container always returns nothing — meaning `hermes dump` reports
@@ -240,9 +235,8 @@ RUN mkdir -p /opt/hermes/bin && \
# every published image has it.
ARG HERMES_GIT_SHA=
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
chmod u+w /opt/hermes && \
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
chmod a-w /opt/hermes /opt/hermes/.hermes_build_sha; \
chown hermes:hermes /opt/hermes/.hermes_build_sha; \
fi
# ---------- s6-overlay service wiring ----------
@@ -288,8 +282,6 @@ ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
# check. (A separate launcher hardening is tracked independently.)
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
ENV HERMES_HOME=/opt/data
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
ENV HERMES_DISABLE_LAZY_INSTALLS=1
# `docker exec` privilege-drop shim. When operators run
# `docker exec <c> hermes ...` they default to root, and any file the
@@ -302,6 +294,7 @@ ENV HERMES_DISABLE_LAZY_INSTALLS=1
# Recursion is impossible because the shim exec's the venv binary by
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the

View File

@@ -1156,9 +1156,6 @@ def init_agent(
"hermes_home": str(get_hermes_home()),
"agent_context": "primary",
}
if _init_kwargs["platform"] == "cli":
_init_kwargs["warning_callback"] = agent._emit_warning
_init_kwargs["status_callback"] = agent._emit_status
# Thread session title for memory provider scoping
# (e.g. honcho uses this to derive chat-scoped session keys)
if agent._session_db:
@@ -1227,35 +1224,12 @@ def init_agent(
# targets.
agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True))
# Universal parallel-tool-call guidance toggle. Default True. Separate
# flag from task_completion_guidance because a user may want one but not
# the other. Steers the model to batch independent tool calls into a
# single turn; the runtime already executes such batches concurrently.
agent._parallel_tool_call_guidance = bool(_agent_section.get("parallel_tool_call_guidance", True))
# Local Python toolchain probe toggle. Default True. When False,
# the probe is skipped entirely (no subprocess calls, no system-prompt
# line). Useful for users on exotic setups where the probe heuristics
# are noisy.
agent._environment_probe = bool(_agent_section.get("environment_probe", True))
# Per-platform prompt-hint overrides (config.yaml → platform_hints).
# Lets an enterprise admin append to or replace Hermes' built-in
# platform hint for a single messaging platform (e.g. WhatsApp) without
# affecting other platforms. Shape:
# platform_hints:
# whatsapp:
# append: "When tabular output would help, invoke the ... skill."
# slack:
# replace: "Custom Slack hint that fully replaces the default."
# Stored verbatim; resolution happens in agent/system_prompt.py against
# the active platform. Invalid shapes are ignored defensively so a bad
# config entry can never break prompt assembly.
_platform_hints_cfg = _agent_cfg.get("platform_hints", {})
if not isinstance(_platform_hints_cfg, dict):
_platform_hints_cfg = {}
agent._platform_hint_overrides = _platform_hints_cfg
# App-level API retry count (wraps each model API call). Default 3,
# overridable via agent.api_max_retries in config.yaml. See #11616.
try:

View File

@@ -1839,42 +1839,28 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
elif function_name == "memory":
def _execute(next_args: dict) -> Any:
target = next_args.get("target", "memory")
operations = next_args.get("operations")
from tools.memory_tool import memory_tool as _memory_tool
result = _memory_tool(
action=next_args.get("action"),
target=target,
content=next_args.get("content"),
old_text=next_args.get("old_text"),
operations=operations,
store=agent._memory_store,
)
# Bridge: notify external memory provider of built-in memory writes.
# Covers both the single-op shape and each add/replace inside a batch.
if agent._memory_manager:
if operations:
_mem_ops = [
op for op in operations
if isinstance(op, dict) and op.get("action") in {"add", "replace"}
]
else:
_mem_ops = (
[{"action": next_args.get("action"), "content": next_args.get("content")}]
if next_args.get("action") in {"add", "replace"} else []
# Bridge: notify external memory provider of built-in memory writes
if agent._memory_manager and next_args.get("action") in {"add", "replace"}:
try:
agent._memory_manager.on_memory_write(
next_args.get("action", ""),
target,
next_args.get("content", ""),
metadata=agent._build_memory_write_metadata(
task_id=effective_task_id,
tool_call_id=tool_call_id,
),
)
for _op in _mem_ops:
try:
agent._memory_manager.on_memory_write(
_op.get("action", ""),
target,
_op.get("content", "") or "",
metadata=agent._build_memory_write_metadata(
task_id=effective_task_id,
tool_call_id=tool_call_id,
),
)
except Exception:
pass
except Exception:
pass
return _finish_agent_tool(result, next_args)
elif agent._memory_manager and agent._memory_manager.has_tool(function_name):
def _execute(next_args: dict) -> Any:

View File

@@ -372,7 +372,7 @@ def _detect_claude_code_version() -> str:
_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
_MCP_TOOL_PREFIX = "mcp__"
_MCP_TOOL_PREFIX = "mcp_"
def _get_claude_code_version() -> str:
@@ -2349,46 +2349,25 @@ def build_anthropic_kwargs(
text = text.replace("Nous Research", "Anthropic")
block["text"] = text
# 3. Normalize tool names so NOTHING goes on the OAuth wire with a
# single-underscore ``mcp_`` prefix. Anthropic's subscription/OAuth
# billing classifier treats a single-underscore ``mcp_`` tool name as
# a third-party-app fingerprint and rejects the request with HTTP 400
# "Third-party apps now draw from extra usage, not plan limits"
# (verified empirically: a single ``mcp_foo`` tool flips a request
# from plan-billing to the extra-usage lane; ``mcp__foo`` is accepted).
#
# Two cases, both must land on the double-underscore ``mcp__`` form:
# a) bare Hermes-native tools (``read_file``) -> ``mcp__read_file``
# b) native MCP server tools registered under their full
# single-underscore ``mcp_<server>_<tool>`` name
# (``mcp_linear_get_issue``) -> ``mcp__linear_get_issue``
# Case (b) is the gap that the bare ``mcp_``->``mcp__`` constant swap
# left open: those tools were *skipped* and stayed single-underscore,
# so any session with an MCP server configured still tripped the
# classifier. normalize_response reverses both forms via registry
# lookup so the dispatcher still sees the original name. GH-25255.
def _to_oauth_wire_name(name: str) -> str:
if name.startswith("mcp__"):
return name # already correct, don't double-prefix
if name.startswith("mcp_"):
# single-underscore native MCP tool -> promote to double
return "mcp__" + name[len("mcp_"):]
return _MCP_TOOL_PREFIX + name # bare name -> mcp__<name>
# 3. Prefix tool names with mcp_ (Claude Code convention)
# Skip names that already begin with the marker — native MCP server
# tools (from mcp_servers: in config.yaml) are registered under their
# full mcp_<server>_<tool> name and would double-prefix otherwise,
# breaking round-trip registry lookup in normalize_response. GH-25255.
if anthropic_tools:
for tool in anthropic_tools:
if "name" in tool:
tool["name"] = _to_oauth_wire_name(tool["name"])
if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX):
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
# 4. Apply the same normalization to tool names in message history
# (tool_use blocks) so replayed turns match the wire names above.
# 4. Prefix tool names in message history (tool_use and tool_result blocks)
for msg in anthropic_messages:
content = msg.get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, dict):
if block.get("type") == "tool_use" and "name" in block:
block["name"] = _to_oauth_wire_name(block["name"])
if not block["name"].startswith(_MCP_TOOL_PREFIX):
block["name"] = _MCP_TOOL_PREFIX + block["name"]
elif block.get("type") == "tool_result" and "tool_use_id" in block:
pass # tool_result uses ID, not name
@@ -2535,56 +2514,3 @@ def sanitize_anthropic_kwargs(api_kwargs: Any, *, log_prefix: str = "") -> Any:
sorted(leaked),
)
return api_kwargs
def _is_stream_unavailable_error(exc: Exception) -> bool:
"""Return True when an Anthropic stream call should fall back to create()."""
err_lower = str(exc).lower()
if "stream" in err_lower and "not supported" in err_lower:
return True
if "invokemodelwithresponsestream" in err_lower:
from agent.bedrock_adapter import is_streaming_access_denied_error
return is_streaming_access_denied_error(exc)
return False
def create_anthropic_message(
client: Any,
api_kwargs: dict,
*,
log_prefix: str = "",
prefer_stream: bool = True,
) -> Any:
"""Create an Anthropic message, aggregating via stream when available.
Some Anthropic-compatible gateways are SSE-only: they ignore non-streaming
requests and return ``text/event-stream`` even for ``messages.create()``.
The SDK can surface that as raw text, so callers that expect a Message then
crash on ``.content``. Prefer ``messages.stream().get_final_message()`` to
match the main turn path, falling back to ``create()`` only for providers
that explicitly do not support streaming, such as restricted Bedrock roles.
"""
sanitize_anthropic_kwargs(api_kwargs, log_prefix=log_prefix)
messages_api = getattr(client, "messages", None)
stream_fn = getattr(messages_api, "stream", None)
if prefer_stream and callable(stream_fn):
stream_kwargs = dict(api_kwargs)
stream_kwargs.pop("stream", None)
try:
with stream_fn(**stream_kwargs) as stream:
return stream.get_final_message()
except Exception as exc:
if not _is_stream_unavailable_error(exc):
raise
logger.debug(
"%sAnthropic Messages stream unavailable; falling back to "
"messages.create(): %s",
log_prefix,
exc,
)
create_kwargs = dict(api_kwargs)
create_kwargs.pop("stream", None)
return messages_api.create(**create_kwargs)

View File

@@ -997,7 +997,7 @@ class _AnthropicCompletionsAdapter:
self._is_oauth = is_oauth
def create(self, **kwargs) -> Any:
from agent.anthropic_adapter import build_anthropic_kwargs, create_anthropic_message
from agent.anthropic_adapter import build_anthropic_kwargs
from agent.transports import get_transport
messages = kwargs.get("messages", [])
@@ -1041,7 +1041,7 @@ class _AnthropicCompletionsAdapter:
if not _forbids_sampling_params(model):
anthropic_kwargs["temperature"] = temperature
response = create_anthropic_message(self._client, anthropic_kwargs)
response = self._client.messages.create(**anthropic_kwargs)
_transport = get_transport("anthropic_messages")
_nr = _transport.normalize_response(
response, strip_tool_prefix=self._is_oauth

View File

@@ -300,7 +300,6 @@ def summarize_background_review_actions(
"target": args.get("target", "memory"),
"content": args.get("content", ""),
"old_text": args.get("old_text", ""),
"operations": args.get("operations") or [],
"name": args.get("name", ""),
"old_string": args.get("old_string", ""),
"new_string": args.get("new_string", ""),
@@ -354,7 +353,6 @@ def summarize_background_review_actions(
content = detail.get("content", "")
old_text = detail.get("old_text", "")
skill_name = detail.get("name", "")
operations = detail.get("operations") or []
max_preview = 120
if is_skill:
change = data.get("_change", {})
@@ -378,21 +376,6 @@ def summarize_background_review_actions(
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
else:
actions.append(f"📝 {message}" if message else f"Skill {action}")
elif operations:
for op in operations:
op = op or {}
op_act = op.get("action", "")
op_content = (op.get("content") or "")
op_old = (op.get("old_text") or "")
if op_act == "add" and op_content:
preview = op_content[:max_preview] + ("" if len(op_content) > max_preview else "")
actions.append(f"{label} {preview}")
elif op_act == "replace" and op_content:
preview = op_content[:max_preview] + ("" if len(op_content) > max_preview else "")
actions.append(f"{label} ✏️ {preview}")
elif op_act == "remove" and op_old:
preview = op_old[:60] + ("" if len(op_old) > 60 else "")
actions.append(f"{label} {preview}")
elif action == "add" and content:
preview = content[:max_preview] + ("" if len(content) > max_preview else "")
actions.append(f"{label} {preview}")
@@ -408,7 +391,6 @@ def summarize_background_review_actions(
"added" in message_lower
or "replaced" in message_lower
or "removed" in message_lower
or "applied" in message_lower
or (target and "add" in message.lower())
or "Entry added" in message
):

View File

@@ -1,295 +0,0 @@
"""Surface-agnostic core for the Phase 2b terminal-billing screens.
One fetch/parse per concern, consumed identically by the CLI handler
(``cli.py::_show_billing``), the TUI JSON-RPC methods
(``tui_gateway/server.py``), and any other surface. Mirrors the proven
``agent/account_usage.py::build_credits_view`` pattern: parse the server payload
into a frozen dataclass; **fail open** — when not logged in or the portal is
unreachable, return a struct with ``logged_in=False`` and let the surface degrade
gracefully (never crash).
Money discipline: the server emits decimal STRINGS (``"142.5"``, not fixed 2dp).
We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
from typing import Any, Optional
logger = logging.getLogger(__name__)
# =============================================================================
# Decimal money helpers
# =============================================================================
def parse_money(value: Any) -> Optional[Decimal]:
"""Parse a server money value (decimal string) into :class:`Decimal`.
Returns None for missing/invalid input. Never raises. Accepts str/int (and,
defensively, float — though the server always sends strings).
"""
if value is None:
return None
try:
# Decimal(str(...)) avoids binary-float artifacts if a float ever sneaks in.
return Decimal(str(value).strip())
except (InvalidOperation, ValueError, TypeError):
return None
def format_money(value: Optional[Decimal]) -> str:
"""Format a Decimal as ``$X`` / ``$X.YY`` for display.
Whole dollars show no decimals; any fractional amount shows exactly 2dp:
``Decimal("142.5")`` → ``"$142.50"``, ``Decimal("100")`` → ``"$100"``,
``Decimal("0.01")`` → ``"$0.01"``.
"""
if value is None:
return ""
if value == value.to_integral_value():
# Whole dollars — no decimal point. format(..., "f") avoids 1E+3 for 1000.
return f"${format(value.to_integral_value(), 'f')}"
# Fractional — always show 2dp.
return f"${format(value.quantize(Decimal('0.01')), 'f')}"
# =============================================================================
# Parsed sub-structures
# =============================================================================
@dataclass(frozen=True)
class CardInfo:
brand: str
last4: str
@property
def masked(self) -> str:
return f"{self.brand} ····{self.last4}"
@dataclass(frozen=True)
class MonthlyCap:
limit_usd: Optional[Decimal] = None
spent_this_month_usd: Optional[Decimal] = None
is_default_ceiling: bool = False
@dataclass(frozen=True)
class AutoReload:
enabled: bool = False
threshold_usd: Optional[Decimal] = None
reload_to_usd: Optional[Decimal] = None
@dataclass(frozen=True)
class BillingState:
"""Parsed ``GET /api/billing/state`` — the overview screen's data.
Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the
portal is unreachable.
"""
logged_in: bool
org_id: Optional[str] = None
org_slug: Optional[str] = None
org_name: Optional[str] = None
role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER"
balance_usd: Optional[Decimal] = None
cli_billing_enabled: bool = False
charge_presets: tuple[Decimal, ...] = ()
min_usd: Optional[Decimal] = None
max_usd: Optional[Decimal] = None
card: Optional[CardInfo] = None
monthly_cap: Optional[MonthlyCap] = None
auto_reload: Optional[AutoReload] = None
portal_url: Optional[str] = None
# When the fetch failed (vs cleanly not-logged-in), the message for the surface.
error: Optional[str] = None
@property
def is_admin(self) -> bool:
"""True for OWNER/ADMIN — the roles that can manage billing."""
return (self.role or "").upper() in ("OWNER", "ADMIN")
@property
def can_charge(self) -> bool:
"""True when the UI should offer charge/auto-reload actions.
Admin role AND the per-org kill-switch on. (The server still enforces;
this is just for graying out actions the user can't take.)
"""
return self.is_admin and self.cli_billing_enabled
def _parse_card(raw: Any) -> Optional[CardInfo]:
if not isinstance(raw, dict):
return None
brand = raw.get("brand")
last4 = raw.get("last4")
if isinstance(brand, str) and isinstance(last4, str):
return CardInfo(brand=brand, last4=last4)
return None
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
if not isinstance(raw, dict):
return None
return MonthlyCap(
limit_usd=parse_money(raw.get("limitUsd")),
spent_this_month_usd=parse_money(raw.get("spentThisMonthUsd")),
is_default_ceiling=bool(raw.get("isDefaultCeiling")),
)
def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
if not isinstance(raw, dict):
return None
return AutoReload(
enabled=bool(raw.get("enabled")),
threshold_usd=parse_money(raw.get("thresholdUsd")),
reload_to_usd=parse_money(raw.get("reloadToUsd")),
)
def billing_state_from_payload(
payload: dict[str, Any], *, portal_url: Optional[str] = None
) -> BillingState:
"""Map a raw ``/api/billing/state`` JSON dict into :class:`BillingState`."""
raw_org = payload.get("org")
org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
raw_bounds = payload.get("bounds")
bounds: dict[str, Any] = raw_bounds if isinstance(raw_bounds, dict) else {}
presets: list[Decimal] = []
for item in payload.get("chargePresets") or ():
parsed = parse_money(item)
if parsed is not None:
presets.append(parsed)
return BillingState(
logged_in=True,
org_id=org.get("id"),
org_slug=org.get("slug"),
org_name=org.get("name"),
role=org.get("role"),
balance_usd=parse_money(payload.get("balanceUsd")),
cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
charge_presets=tuple(presets),
min_usd=parse_money(bounds.get("minUsd")),
max_usd=parse_money(bounds.get("maxUsd")),
card=_parse_card(payload.get("card")),
monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")),
auto_reload=_parse_auto_reload(payload.get("autoReload")),
portal_url=portal_url,
)
# =============================================================================
# Fail-open builders (the surface front doors)
# =============================================================================
def build_billing_state(*, timeout: float = 15.0) -> BillingState:
"""Fetch + parse ``/api/billing/state``. Fail-open.
Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP
failure, returns ``logged_in=False`` with ``error`` set so the surface can show
a clear message rather than crashing.
"""
try:
from hermes_cli.nous_billing import (
BillingAuthError,
BillingError,
_absolutize_portal_url,
get_billing_state,
resolve_portal_base_url,
)
except Exception:
return BillingState(logged_in=False, error="billing client unavailable")
try:
payload = get_billing_state(timeout=timeout)
except BillingAuthError:
return BillingState(logged_in=False)
except BillingError as exc:
logger.debug("billing ▸ /state fetch failed (fail-open)", exc_info=True)
return BillingState(logged_in=False, error=str(exc))
except Exception:
logger.debug("billing ▸ /state unexpected error (fail-open)", exc_info=True)
return BillingState(logged_in=False, error="could not load billing state")
# Prefer a server-supplied portalUrl if present (resolved to absolute in case
# it's relative); else build the standard one.
raw_portal = payload.get("portalUrl") if isinstance(payload, dict) else None
portal_url = _absolutize_portal_url(raw_portal) if raw_portal else None
if not portal_url:
try:
portal_url = _fallback_portal_url(resolve_portal_base_url())
except Exception:
portal_url = None
return billing_state_from_payload(payload, portal_url=portal_url)
def _fallback_portal_url(base: str) -> str:
"""Standard billing deep-link when the server omits ``portalUrl``."""
return f"{base.rstrip('/')}/billing?topup=open"
# =============================================================================
# Idempotency
# =============================================================================
def new_idempotency_key() -> str:
"""Fresh UUID for a user-confirmed purchase (reuse on retry of the SAME buy).
The ``Idempotency-Key`` header is mandatory on ``POST /charge``; generate one
per confirmed purchase and reuse it across retries so a double-submit collapses
to a single charge. Never reuse a key across different amounts (the server
returns 409 idempotency_conflict).
"""
return str(uuid.uuid4())
# =============================================================================
# Amount validation (Screen 3 custom input)
# =============================================================================
@dataclass(frozen=True)
class AmountValidation:
ok: bool
amount: Optional[Decimal] = None
error: Optional[str] = None
def validate_charge_amount(
raw: str, *, min_usd: Optional[Decimal], max_usd: Optional[Decimal]
) -> AmountValidation:
"""Validate a custom charge amount against bounds + 2dp (multipleOf 0.01).
Mirrors the server's accept/reject so the UI can give instant feedback rather
than round-tripping a sure-to-fail charge. The server is still authoritative.
"""
cleaned = (raw or "").strip().lstrip("$").strip()
amount = parse_money(cleaned)
if amount is None:
return AmountValidation(ok=False, error="Enter a dollar amount, e.g. 100")
if amount <= 0:
return AmountValidation(ok=False, error="Amount must be greater than $0")
# multipleOf 0.01 — reject sub-cent precision.
if amount != amount.quantize(Decimal("0.01")):
return AmountValidation(ok=False, error="Amount can't be smaller than a cent")
if min_usd is not None and amount < min_usd:
return AmountValidation(ok=False, error=f"Minimum is {format_money(min_usd)}")
if max_usd is not None and amount > max_usd:
return AmountValidation(ok=False, error=f"Maximum is {format_money(max_usd)}")
return AmountValidation(ok=True, amount=amount)

View File

@@ -262,26 +262,6 @@ def _responses_tools(tools: Optional[List[Dict[str, Any]]] = None) -> Optional[L
return converted or None
# Provider-executed built-in tool *declaration* types accepted on the
# Responses ``tools`` array. These are declared by ``type`` alone (no
# client-side name/parameters schema) and run server-side — the provider
# owns the implementation and reports progress via the matching ``*_call``
# output items. Hermes injects xAI's native ``web_search`` for the xAI
# transport (see agent/transports/codex.py); the rest are listed so the
# preflight validator passes them through rather than rejecting them as
# "unsupported type". Mirrors the ``*_call`` item-type set used in
# _normalize_codex_response.
_RESPONSES_BUILTIN_TOOL_TYPES = {
"web_search",
"web_search_preview",
"file_search",
"code_interpreter",
"image_generation",
"computer_use_preview",
"local_shell",
}
# ---------------------------------------------------------------------------
# Message format conversion
# ---------------------------------------------------------------------------
@@ -822,22 +802,7 @@ def _preflight_codex_api_kwargs(
for idx, tool in enumerate(tools):
if not isinstance(tool, dict):
raise ValueError(f"Codex Responses tools[{idx}] must be an object.")
tool_type = tool.get("type")
# Provider-executed built-in tools (xAI native web_search, code
# interpreter, etc.) are declared by ``type`` alone and carry no
# ``name``/``parameters`` schema — the provider owns the
# implementation. Pass them through verbatim instead of forcing
# them through the function-tool validation below (which would
# otherwise reject them with "unsupported type"). See
# agent/transports/codex.py for where xAI's native web_search is
# injected.
if tool_type in _RESPONSES_BUILTIN_TOOL_TYPES:
normalized_tools.append(dict(tool))
continue
if tool_type != "function":
if tool.get("type") != "function":
raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.")
name = tool.get("name")
@@ -1121,33 +1086,6 @@ def _normalize_codex_response(
saw_final_answer_phase = False
saw_reasoning_item = False
# Server-side built-in tool calls (xAI's native web_search, code
# interpreter, etc.) are executed by the provider and reported as
# discrete ``*_call`` output items. xAI's /v1/responses surface
# (e.g. grok-composer-2.5-fast on SuperGrok OAuth) routinely leaves
# these items at ``status="in_progress"`` even when the overall
# ``response.status == "completed"`` — the search ran to completion
# server-side, the per-item status simply isn't reconciled. These
# are NOT a signal that the model's turn is unfinished, so they must
# not flip ``has_incomplete_items``. Only the response-level status
# and genuine model output items (message/reasoning/function_call)
# govern the incomplete verdict. Without this guard, any turn where
# grok-composer invokes server-side search is misclassified as
# ``finish_reason="incomplete"`` and burns 3 fruitless continuation
# retries before failing with "Codex response remained incomplete
# after 3 continuation attempts". client-side function/custom tool
# calls keep their own in_progress handling below (they are skipped,
# not awaited).
_SERVER_SIDE_TOOL_CALL_TYPES = {
"web_search_call",
"file_search_call",
"code_interpreter_call",
"image_generation_call",
"computer_call",
"local_shell_call",
"mcp_call",
}
for item in output:
item_type = getattr(item, "type", None)
item_status = getattr(item, "status", None)
@@ -1156,10 +1094,7 @@ def _normalize_codex_response(
else:
item_status = None
if (
item_status in {"queued", "in_progress", "incomplete"}
and item_type not in _SERVER_SIDE_TOOL_CALL_TYPES
):
if item_status in {"queued", "in_progress", "incomplete"}:
has_incomplete_items = True
saw_streaming_or_item_incomplete = True

View File

@@ -290,7 +290,6 @@ def run_codex_app_server_turn(
original_user_message=original_user_message,
final_response=turn.final_text,
interrupted=False,
messages=messages,
)
except Exception:
logger.debug("external memory sync raised", exc_info=True)

View File

@@ -512,16 +512,6 @@ def compress_context(
old_title = agent._session_db.get_session_title(agent.session_id)
# Trigger memory extraction on the old session before it rotates.
agent.commit_memory_session(messages)
# Flush any un-persisted messages from the current turn to the
# old session *before* rotating. compress_context() can be
# called mid-turn (auto-compress when context exceeds threshold)
# at a point when _flush_messages_to_session_db() has not yet
# run. Without this, messages generated during the current turn
# are silently lost on session rotation (#47202).
try:
agent._flush_messages_to_session_db(messages)
except Exception:
pass # best-effort — don't block compression on a flush error
agent._session_db.end_session(agent.session_id, "compression")
old_session_id = agent.session_id
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"

View File

@@ -3197,22 +3197,15 @@ def run_conversation(
# Terminal — flush buffered context so the user sees
# what was tried before the abort.
agent._flush_status_buffer()
# Summarize once: Cloudflare/proxy HTML challenge pages and
# other raw provider bodies must be collapsed to a short
# one-liner here, otherwise the full page leaks into the
# returned ``error`` field and downstream consumers deliver
# it verbatim (e.g. a cron failure notification dumped a
# ~60KB Cloudflare challenge page as 31 Discord messages).
_nonretryable_summary = agent._summarize_api_error(api_error)
if classified.reason == FailoverReason.content_policy_blocked:
agent._emit_status(
f"❌ Provider safety filter blocked this request: "
f"{_nonretryable_summary}"
f"{agent._summarize_api_error(api_error)}"
)
else:
agent._emit_status(
f"❌ Non-retryable error (HTTP {status_code}): "
f"{_nonretryable_summary}"
f"{agent._summarize_api_error(api_error)}"
)
agent._vprint(f"{agent.log_prefix}❌ Non-retryable client error (HTTP {status_code}). Aborting.", force=True)
agent._vprint(f"{agent.log_prefix} 🔌 Provider: {_provider} Model: {_model}", force=True)
@@ -3297,17 +3290,18 @@ def run_conversation(
else:
agent._persist_session(messages, conversation_history)
if classified.reason == FailoverReason.content_policy_blocked:
_summary = agent._summarize_api_error(api_error)
_policy_response = (
"⚠️ The model provider's safety filter blocked this request "
"(not a Hermes/gateway failure).\n\n"
f"Provider message: {_nonretryable_summary}\n\n"
f"Provider message: {_summary}\n\n"
f"{_CONTENT_POLICY_RECOVERY_HINT}"
)
return _content_policy_blocked_result(
messages,
api_call_count,
final_response=_policy_response,
error_detail=_nonretryable_summary,
error_detail=_summary,
)
return {
"final_response": None,
@@ -3315,7 +3309,7 @@ def run_conversation(
"api_calls": api_call_count,
"completed": False,
"failed": True,
"error": _nonretryable_summary,
"error": str(api_error),
}
if retry_count >= max_retries:
@@ -3762,30 +3756,8 @@ def run_conversation(
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
messages.append(assistant_msg)
for tc in assistant_message.tool_calls:
_tc_name = tc.function.name
if _tc_name not in agent.valid_tool_names:
# A blank/whitespace-only name is not a typo the
# model can fuzzy-correct toward a real tool — it is
# almost always a weak open model echoing tool-call
# XML/JSON it saw in file or tool output (#47967:
# <tool_call>/<invoke name=...> payloads in a file
# prime mimo/nemotron-class models to emit empty
# structured calls). Dumping the full tool catalog
# in that case feeds the priming loop more names to
# mimic and inflates context 3-4x across retries, so
# send a terse error that tells the model in-context
# tool-call syntax is DATA, not a call to make.
if not (_tc_name or "").strip():
content = (
"Tool call rejected: the tool name was empty. "
"If tool-call XML or JSON appeared in file "
"contents or tool output, that is data — do "
"not re-emit it as a tool call. To call a "
"tool, use a valid name from your tool list; "
"otherwise reply in plain text."
)
else:
content = f"Tool '{_tc_name}' does not exist. Available tools: {available}"
if tc.function.name not in agent.valid_tool_names:
content = f"Tool '{tc.function.name}' does not exist. Available tools: {available}"
else:
content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
messages.append({

View File

@@ -15,7 +15,6 @@ from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import OPENROUTER_BASE_URL
from hermes_cli.config import load_env
from agent.secret_scope import get_secret as _get_secret
from agent.credential_persistence import (
is_borrowed_credential_source,
sanitize_borrowed_credential_payload,
@@ -1667,7 +1666,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
_env_file = load_env()
def _env_val(key: str) -> str:
return (_env_file.get(key) or _get_secret(key, "") or "").strip()
return (_env_file.get(key) or os.environ.get(key) or "").strip()
anthropic_api_key = _env_val("ANTHROPIC_API_KEY")
anthropic_oauth_env = (
@@ -1953,7 +1952,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
# changes to the .env file.
def _get_env_prefer_dotenv(key: str) -> str:
env_file = load_env()
val = env_file.get(key) or _get_secret(key, "") or ""
val = env_file.get(key) or os.environ.get(key) or ""
return val.strip()
# Honour user suppression — `hermes auth remove <provider> <N>` for an

View File

@@ -57,11 +57,6 @@ DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
DEFAULT_MIN_IDLE_HOURS = 2
DEFAULT_STALE_AFTER_DAYS = 30
DEFAULT_ARCHIVE_AFTER_DAYS = 90
# Consolidation (the LLM umbrella-building fork) is OFF by default. The
# deterministic inactivity prune (apply_automatic_transitions) still runs
# whenever the curator is enabled; only the opinionated, aux-model-cost
# consolidation pass is opt-in.
DEFAULT_CONSOLIDATE = False
# ---------------------------------------------------------------------------
@@ -187,22 +182,6 @@ def get_prune_builtins() -> bool:
return bool(cfg.get("prune_builtins", True))
def get_consolidate() -> bool:
"""Whether the curator runs its LLM consolidation (umbrella-building) pass.
OFF by default. When off, a curator run does ONLY the deterministic
inactivity prune (mark stale / archive long-unused skills) and skips the
forked aux-model review entirely — no consolidation, no umbrella-building,
no aux-model cost. Set ``curator.consolidate: true`` to opt back into the
LLM pass that merges overlapping skills into class-level umbrellas.
The explicit ``hermes curator run --consolidate`` flag overrides this for
a single invocation regardless of the config value.
"""
cfg = _load_config()
return bool(cfg.get("consolidate", DEFAULT_CONSOLIDATE))
# ---------------------------------------------------------------------------
# Idle / interval check
# ---------------------------------------------------------------------------
@@ -1429,38 +1408,25 @@ def run_curator_review(
on_summary: Optional[Callable[[str], None]] = None,
synchronous: bool = False,
dry_run: bool = False,
consolidate: Optional[bool] = None,
) -> Dict[str, Any]:
"""Execute a single curator review pass.
Steps:
1. Apply automatic state transitions (pure, no LLM).
2. If consolidation is enabled AND there are agent-created skills, spawn
a forked AIAgent that runs the LLM review prompt against the current
candidate list.
2. If there are agent-created skills, spawn a forked AIAgent that runs
the LLM review prompt against the current candidate list.
3. Update .curator_state with last_run_at and a one-line summary.
4. Invoke *on_summary* with a user-visible description.
If *synchronous* is True, the LLM review runs in the calling thread; the
default is to spawn a daemon thread so the caller returns immediately.
*consolidate* gates the LLM umbrella-building pass. ``None`` (the default)
reads ``curator.consolidate`` from config (OFF by default). Passing
``True``/``False`` overrides the config for this invocation — used by the
``hermes curator run --consolidate`` flag. When consolidation is off, only
the deterministic inactivity prune runs and the forked aux-model review is
skipped entirely (no aux-model cost).
If *dry_run* is True, the automatic stale/archive transitions are SKIPPED
and the LLM review pass is instructed to produce a report only — no
skill_manage mutations, no terminal archive moves. The REPORT.md still
gets written and ``state.last_report_path`` still records it so users
can read what the curator WOULD have done. A dry-run also honors
*consolidate*: when consolidation is off, the preview only reports the
deterministic prune candidates.
can read what the curator WOULD have done.
"""
if consolidate is None:
consolidate = get_consolidate()
start = datetime.now(timezone.utc)
if dry_run:
# Count candidates without mutating state.
@@ -1523,53 +1489,6 @@ def run_curator_review(
before_report = []
before_names = {r.get("name") for r in before_report if isinstance(r, dict)}
# Consolidation gate. When off (the default), the curator does ONLY the
# deterministic inactivity prune above — no forked aux-model review, no
# umbrella-building, no aux-model cost. Record the run, write a report
# reflecting the prune-only outcome, and return without spawning a fork.
if not consolidate:
final_summary = (
f"{prefix}{auto_summary}; llm: skipped (consolidation off)"
)
llm_meta = {
"final": "",
"summary": "skipped (consolidation off)",
"model": "",
"provider": "",
"tool_calls": [],
"error": None,
}
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
state2 = load_state()
state2["last_run_duration_seconds"] = elapsed
state2["last_run_summary"] = final_summary
try:
after_report = skill_usage.agent_created_report()
except Exception:
after_report = []
try:
report_path = _write_run_report(
started_at=start,
elapsed_seconds=elapsed,
auto_counts=counts,
auto_summary=auto_summary,
before_report=before_report,
before_names=before_names,
after_report=after_report,
llm_meta=llm_meta,
)
if report_path is not None:
state2["last_report_path"] = str(report_path)
except Exception as e:
logger.debug("Curator report write failed: %s", e, exc_info=True)
save_state(state2)
if on_summary:
try:
on_summary(f"curator: {final_summary}")
except Exception:
pass
return
llm_meta: Dict[str, Any] = {}
try:
candidate_list = _render_candidate_list()

View File

@@ -46,7 +46,7 @@ import shutil
import tarfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from typing import Any, Dict, List, Optional, Tuple
from hermes_constants import get_hermes_home
from agent.skill_utils import is_excluded_skill_path
@@ -208,17 +208,13 @@ def _write_manifest(dest: Path, reason: str, archive_path: Path,
)
def snapshot_skills(reason: str = "manual", *, protect_ids: Optional[Set[str]] = None) -> Optional[Path]:
def snapshot_skills(reason: str = "manual") -> Optional[Path]:
"""Create a tar.gz snapshot of ``~/.hermes/skills/`` and prune old ones.
Returns the snapshot directory path, or ``None`` if the snapshot was
skipped (backup disabled, skills dir missing, or an IO error occurred —
in which case we log at debug and return None so the curator never
aborts a pass because of a backup failure).
``protect_ids`` is forwarded to the prune step so callers can guarantee
specific snapshot ids survive even when they fall outside the keep
window (rollback passes the id it is about to restore from).
"""
if not is_enabled():
logger.debug("Curator backup disabled by config; skipping snapshot")
@@ -280,19 +276,15 @@ def snapshot_skills(reason: str = "manual", *, protect_ids: Optional[Set[str]] =
pass
return None
_prune_old(keep=get_keep(), protect=protect_ids)
_prune_old(keep=get_keep())
logger.info("Curator snapshot created: %s (%s)", snap_id, reason)
return dest
def _prune_old(keep: int, protect: Optional[Set[str]] = None) -> List[str]:
def _prune_old(keep: int) -> List[str]:
"""Delete regular snapshots beyond the newest *keep*. Returns deleted
ids. Snapshot ids in *protect* are never deleted even when they fall
outside the keep window — rollback() uses this so the mandatory
pre-rollback safety snapshot can never evict the very snapshot being
restored. Staging dirs (``.rollback-staging-*``) are implementation
detail and pruned independently on every call."""
protect = protect or set()
ids. Staging dirs (``.rollback-staging-*``) are implementation detail
and pruned independently on every call."""
backups = _backups_dir()
if not backups.exists():
return []
@@ -313,8 +305,6 @@ def _prune_old(keep: int, protect: Optional[Set[str]] = None) -> List[str]:
entries.sort(key=lambda t: t[0], reverse=True)
deleted: List[str] = []
for _, path in entries[keep:]:
if path.name in protect:
continue
try:
shutil.rmtree(path)
deleted.append(path.name)
@@ -574,13 +564,7 @@ def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]
# out before touching anything — otherwise a failed extract could leave
# the user with no skills.
try:
# Protect the target from this snapshot's prune step: at the steady
# keep limit, pruning the oldest snapshot would otherwise delete the
# very snapshot we are about to extract from.
snapshot_skills(
reason=f"pre-rollback to {target.name}",
protect_ids={target.name},
)
snapshot_skills(reason=f"pre-rollback to {target.name}")
except Exception as e:
return (False, f"pre-rollback safety snapshot failed: {e}", None)

View File

@@ -11,18 +11,6 @@ Providers live in ``<repo>/plugins/image_gen/<name>/`` (built-in, auto-loaded
as ``kind: backend``) or ``~/.hermes/plugins/image_gen/<name>/`` (user, opt-in
via ``plugins.enabled``).
Unified surface
---------------
One tool — ``image_generate`` — covers **text-to-image** and
**image-to-image / image editing**. The router is the presence of
``image_url`` (and/or ``reference_image_urls``): if any source image is
provided, the provider routes to its image-to-image / edit endpoint; if
omitted, the provider routes to text-to-image. Users pick one **model**
(e.g. nano-banana-pro, gpt-image-2, grok-imagine-image); the provider
handles which underlying endpoint to hit. This mirrors the ``video_gen``
provider design (``agent/video_gen_provider.py``) so the two surfaces
stay learnable together.
Response shape
--------------
All providers return a dict that :func:`success_response` / :func:`error_response`
@@ -33,7 +21,6 @@ produce. The tool wrapper JSON-serializes it. Keys:
model str provider-specific model identifier
prompt str echoed prompt
aspect_ratio str "landscape" | "square" | "portrait"
modality str "text" | "image" (which mode was used)
provider str provider name (for diagnostics)
error str only when success=False
error_type str only when success=False
@@ -140,51 +127,19 @@ class ImageGenProvider(abc.ABC):
return models[0].get("id")
return None
def capabilities(self) -> Dict[str, Any]:
"""Return what this provider supports.
Returned dict (all keys optional)::
{
"modalities": ["text", "image"], # which inputs the backend accepts
"max_reference_images": 9, # cap for reference_image_urls
}
``modalities`` declares whether the active backend/model supports
text-to-image (``"text"``), image-to-image / editing (``"image"``),
or both. The tool layer surfaces this in the dynamic schema so the
model knows when ``image_url`` is honored. Used by ``hermes tools``
for the picker too. Default: text-only (backward compatible — a
provider that doesn't override this advertises text-to-image only).
"""
return {
"modalities": ["text"],
"max_reference_images": 0,
}
@abc.abstractmethod
def generate(
self,
prompt: str,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
*,
image_url: Optional[str] = None,
reference_image_urls: Optional[List[str]] = None,
**kwargs: Any,
) -> Dict[str, Any]:
"""Generate an image from a text prompt, or edit/transform a source image.
Routing: if ``image_url`` (or any ``reference_image_urls``) is
provided, the provider should route to its image-to-image / edit
endpoint; otherwise text-to-image. ``image_url`` is the primary
source image to edit; ``reference_image_urls`` are additional
style/composition references (provider clamps to its declared
``max_reference_images``).
"""Generate an image.
Implementations should return the dict from :func:`success_response`
or :func:`error_response`. ``kwargs`` may contain forward-compat
parameters future versions of the schema will expose —
implementations MUST ignore unknown keys (no TypeError).
parameters future versions of the schema will expose — implementations
should ignore unknown keys.
"""
@@ -207,26 +162,6 @@ def resolve_aspect_ratio(value: Optional[str]) -> str:
return DEFAULT_ASPECT_RATIO
def normalize_reference_images(value: Any) -> Optional[List[str]]:
"""Coerce a reference-image argument into a clean list of URL/path strings.
Accepts a single string or a list; strips blanks and whitespace. Returns
``None`` when nothing usable remains so providers can treat "no refs" as a
single sentinel.
"""
if value is None:
return None
if isinstance(value, str):
value = [value]
if not isinstance(value, (list, tuple)):
return None
out: List[str] = []
for item in value:
if isinstance(item, str) and item.strip():
out.append(item.strip())
return out or None
def _images_cache_dir() -> Path:
"""Return ``$HERMES_HOME/cache/images/``, creating parents as needed."""
from hermes_constants import get_hermes_home
@@ -345,16 +280,13 @@ def success_response(
prompt: str,
aspect_ratio: str,
provider: str,
modality: str = "text",
extra: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Build a uniform success response dict.
``image`` may be an HTTP URL or an absolute filesystem path (for b64
providers like OpenAI). ``modality`` is ``"text"`` (text-to-image) or
``"image"`` (image-to-image / editing) — indicates which endpoint was
actually hit, useful for diagnostics. Callers that need to pass through
additional backend-specific fields can supply ``extra``.
providers like OpenAI). Callers that need to pass through additional
backend-specific fields can supply ``extra``.
"""
payload: Dict[str, Any] = {
"success": True,
@@ -362,7 +294,6 @@ def success_response(
"model": model,
"prompt": prompt,
"aspect_ratio": aspect_ratio,
"modality": modality,
"provider": provider,
}
if extra:

View File

@@ -1,50 +0,0 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
_NON_TEXT_PART_TYPES = {"image", "image_url", "input_image", "audio", "input_audio"}
_TEXT_KEYS = ("text", "content", "input_text", "output_text", "summary_text")
def _field(value: Any, key: str) -> Any:
if isinstance(value, Mapping):
return value.get(key)
return getattr(value, key, None)
def _text_from_part(part: Any) -> str:
if part is None:
return ""
if isinstance(part, str):
return part
part_type = str(_field(part, "type") or "").strip().lower()
if part_type in _NON_TEXT_PART_TYPES:
return ""
for key in _TEXT_KEYS:
text = _field(part, key)
if isinstance(text, str):
return text
return ""
def flatten_message_text(content: Any, *, sep: str = "\n") -> str:
"""Return the visible text from common chat/Responses message content shapes."""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
chunks = [_text_from_part(part) for part in content]
return sep.join(chunk for chunk in chunks if chunk)
text = _text_from_part(content)
if text:
return text
try:
return str(content)
except Exception:
return ""

View File

@@ -275,11 +275,6 @@ DEFAULT_CONTEXT_LENGTHS = {
# via a custom provider. Values sourced from models.dev (2026-04).
# Keys use substring matching (longest-first), so e.g. "grok-4.20"
# matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
# OAuth-only slug; absent from GET /v1/models. xAI publishes a 200k
# usable context window for Composer 2.5 on Grok Build (SuperGrok /
# Premium+); /v1/responses additionally enforces a ~262144 input+output
# budget, but the usable context (what we track here) is 200k.
"grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI)
"grok-build": 256000, # grok-build-0.1
"grok-code-fast": 256000, # grok-code-fast-1
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest

View File

@@ -305,47 +305,6 @@ TASK_COMPLETION_GUIDANCE = (
"is always better than inventing a result."
)
# Universal parallel-tool-call guidance — applied to ALL models.
#
# Why this matters for cost: every assistant turn resends the entire
# accumulated conversation (and, on cache-friendly providers, re-reads the
# cached prefix and pays for the newly-appended turn). A model that issues
# one tool call per turn multiplies the number of round-trips — and therefore
# the resent context — for any task that needs several independent reads,
# searches, or safe lookups. Batching independent calls into a single
# assistant response collapses N turns into one, cutting both latency and the
# resent-context cost that compounds over a long conversation.
#
# The hermes-agent runtime already executes a batch of tool calls
# concurrently when they are independent (read-only tools always; path-scoped
# file ops when their targets don't overlap — see
# run_agent._execute_tool_calls / tool_dispatch_helpers). The missing piece
# was telling the *model* to emit those calls together in the first place.
# Until now the only batching steer in the prompt lived in
# GOOGLE_MODEL_OPERATIONAL_GUIDANCE — Gemini/Gemma got it, every other model
# got nothing. This block makes the steer universal; the now-redundant
# Google-only bullet has been dropped so no model receives it twice.
#
# Short on purpose — shipped in the cached system prompt to every user, every
# session. Token cost is paid once at install and amortised across all
# sessions via prefix caching. Keep it tight.
#
# Ported from cline/cline#11514 ("encourage parallel tool calls"), adapted
# from Cline's TypeScript tool-surface guidance to hermes-agent's Python
# prompt-assembly architecture.
PARALLEL_TOOL_CALL_GUIDANCE = (
"# Parallel tool calls\n"
"When you need several pieces of information that don't depend on each "
"other, request them together in a single response instead of one tool "
"call per turn. Independent reads, searches, web fetches, and read-only "
"commands should be batched into the same assistant turn — the runtime "
"executes independent calls concurrently, and batching avoids resending "
"the whole conversation on every extra round-trip.\n"
"Only serialize calls when a later call genuinely depends on an earlier "
"call's result (e.g. you must read a file before you can patch it). When "
"in doubt and the calls are independent, batch them."
)
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
# where GPT models abandon work on partial results, skip prerequisite lookups,
# hallucinate instead of using tools, and declare "done" without verification.
@@ -427,10 +386,9 @@ GOOGLE_MODEL_OPERATIONAL_GUIDANCE = (
"package.json, requirements.txt, Cargo.toml, etc. before importing.\n"
"- **Conciseness:** Keep explanatory text brief — a few sentences, not "
"paragraphs. Focus on actions and results over narration.\n"
# Parallel-tool-call steering now lives in the universal
# PARALLEL_TOOL_CALL_GUIDANCE block (injected for all models), so it is no
# longer duplicated here — keeping it would send Gemini/Gemma the same
# instruction twice.
"- **Parallel tool calls:** When you need to perform multiple independent "
"operations (e.g. reading several files), make all the tool calls in a "
"single response rather than sequentially.\n"
"- **Non-interactive commands:** Use flags like -y, --yes, --non-interactive "
"to prevent CLI tools from hanging on prompts.\n"
"- **Keep going:** Work autonomously until the task is fully resolved. "
@@ -1000,41 +958,13 @@ CONTEXT_FILE_MAX_CHARS = 20_000
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
# Dynamic-cap parameters (used when no explicit context_file_max_chars is set).
# The cap scales with the model's context window so large-context models rarely
# truncate a project doc, while small-context models stay at the historical
# 20K floor. ~4 chars/token is the usual English heuristic; we spend a small
# slice of the window on context files since they share the cached prefix with
# the system prompt, tools, memory, and the whole conversation.
_CONTEXT_FILE_CHARS_PER_TOKEN = 4
_CONTEXT_FILE_WINDOW_FRACTION = 0.06
_CONTEXT_FILE_DYNAMIC_CEILING = 500_000
def _get_context_file_max_chars() -> int:
"""Return the configured context-file truncation limit.
def _dynamic_context_file_max_chars(context_length: Optional[int]) -> int:
"""Derive a char cap from the model's context window.
Returns at least ``CONTEXT_FILE_MAX_CHARS`` (the historical 20K floor) and
at most ``_CONTEXT_FILE_DYNAMIC_CEILING``. When ``context_length`` is
unknown/invalid, returns the flat default so behavior is unchanged.
"""
if not isinstance(context_length, int) or context_length <= 0:
return CONTEXT_FILE_MAX_CHARS
budget = int(
context_length * _CONTEXT_FILE_CHARS_PER_TOKEN * _CONTEXT_FILE_WINDOW_FRACTION
)
return max(CONTEXT_FILE_MAX_CHARS, min(budget, _CONTEXT_FILE_DYNAMIC_CEILING))
def _get_context_file_max_chars(context_length: Optional[int] = None) -> int:
"""Return the context-file truncation limit.
Resolution order:
1. Explicit ``context_file_max_chars`` in config.yaml — user knows best,
always wins (including over the dynamic cap).
2. Dynamic cap derived from the model's ``context_length`` when provided
(scales the budget to the window; floor 20K, ceiling 500K).
3. ``CONTEXT_FILE_MAX_CHARS`` (20K) as the upstream-compatible fallback.
``CONTEXT_FILE_MAX_CHARS`` remains the upstream-compatible default and
fallback. Users with larger context windows can raise
``context_file_max_chars`` in config.yaml without patching Hermes.
"""
try:
from hermes_cli.config import load_config
@@ -1044,7 +974,7 @@ def _get_context_file_max_chars(context_length: Optional[int] = None) -> int:
return int(val)
except Exception as e:
logger.debug("Could not read context_file_max_chars from config: %s", e)
return _dynamic_context_file_max_chars(context_length)
return CONTEXT_FILE_MAX_CHARS
# Collect truncation warnings so the caller (run_agent) can surface them.
# A ContextVar (not a module-global list) isolates accumulation per thread /
@@ -1580,30 +1510,16 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
# Context files (SOUL.md, AGENTS.md, .cursorrules)
# =========================================================================
def _truncate_content(
content: str,
filename: str,
max_chars: Optional[int] = None,
context_length: Optional[int] = None,
read_path: Optional[str] = None,
) -> str:
"""Head/tail truncation with a marker in the middle.
``filename`` is the human label used in warnings. ``read_path`` is the
concrete path the agent should ``read_file`` to recover the full content
(defaults to ``filename`` when not supplied). ``context_length`` lets the
cap scale to the model's window when no explicit config override is set.
"""
def _truncate_content(content: str, filename: str, max_chars: Optional[int] = None) -> str:
"""Head/tail truncation with a marker in the middle."""
if max_chars is None:
max_chars = _get_context_file_max_chars(context_length)
max_chars = _get_context_file_max_chars()
if len(content) <= max_chars:
return content
target = read_path or filename
msg = (
f"⚠️ Context file {filename} TRUNCATED: "
f"{len(content)} chars exceeds limit of {max_chars}"
f"trim the file, pin a larger context_file_max_chars, or use a "
f"larger-context model!"
f"increase context_file_max_chars or trim the file!"
)
logger.warning(msg)
_record_truncation_warning(msg)
@@ -1611,16 +1527,11 @@ def _truncate_content(
tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO)
head = content[:head_chars]
tail = content[-tail_chars:]
marker = (
f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of "
f"{len(content)} chars. The middle is omitted — if you need the full "
f"instructions, read the complete file with the read_file tool: "
f"{target}]\n\n"
)
marker = f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of {len(content)} chars. Use file tools to read the full file.]\n\n"
return head + marker + tail
def load_soul_md(context_length: Optional[int] = None) -> Optional[str]:
def load_soul_md() -> Optional[str]:
"""Load SOUL.md from HERMES_HOME and return its content, or None.
Used as the agent identity (slot #1 in the system prompt). When this
@@ -1641,17 +1552,14 @@ def load_soul_md(context_length: Optional[int] = None) -> Optional[str]:
if not content:
return None
content = _scan_context_content(content, "SOUL.md")
content = _truncate_content(
content, "SOUL.md", context_length=context_length,
read_path=str(soul_path),
)
content = _truncate_content(content, "SOUL.md")
return content
except Exception as e:
logger.debug("Could not read SOUL.md from %s: %s", soul_path, e)
return None
def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
def _load_hermes_md(cwd_path: Path) -> str:
""".hermes.md / HERMES.md — walk to git root."""
hermes_md_path = _find_hermes_md(cwd_path)
if not hermes_md_path:
@@ -1668,16 +1576,13 @@ def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str
pass
content = _scan_context_content(content, rel)
result = f"## {rel}\n\n{content}"
return _truncate_content(
result, ".hermes.md", context_length=context_length,
read_path=str(hermes_md_path),
)
return _truncate_content(result, ".hermes.md")
except Exception as e:
logger.debug("Could not read %s: %s", hermes_md_path, e)
return ""
def _load_agents_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
def _load_agents_md(cwd_path: Path) -> str:
"""AGENTS.md — top-level only (no recursive walk)."""
for name in ["AGENTS.md", "agents.md"]:
candidate = cwd_path / name
@@ -1687,16 +1592,13 @@ def _load_agents_md(cwd_path: Path, context_length: Optional[int] = None) -> str
if content:
content = _scan_context_content(content, name)
result = f"## {name}\n\n{content}"
return _truncate_content(
result, "AGENTS.md", context_length=context_length,
read_path=str(candidate),
)
return _truncate_content(result, "AGENTS.md")
except Exception as e:
logger.debug("Could not read %s: %s", candidate, e)
return ""
def _load_claude_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
def _load_claude_md(cwd_path: Path) -> str:
"""CLAUDE.md / claude.md — cwd only."""
for name in ["CLAUDE.md", "claude.md"]:
candidate = cwd_path / name
@@ -1706,16 +1608,13 @@ def _load_claude_md(cwd_path: Path, context_length: Optional[int] = None) -> str
if content:
content = _scan_context_content(content, name)
result = f"## {name}\n\n{content}"
return _truncate_content(
result, "CLAUDE.md", context_length=context_length,
read_path=str(candidate),
)
return _truncate_content(result, "CLAUDE.md")
except Exception as e:
logger.debug("Could not read %s: %s", candidate, e)
return ""
def _load_cursorrules(cwd_path: Path, context_length: Optional[int] = None) -> str:
def _load_cursorrules(cwd_path: Path) -> str:
""".cursorrules + .cursor/rules/*.mdc — cwd only."""
cursorrules_content = ""
cursorrules_file = cwd_path / ".cursorrules"
@@ -1742,17 +1641,10 @@ def _load_cursorrules(cwd_path: Path, context_length: Optional[int] = None) -> s
if not cursorrules_content:
return ""
return _truncate_content(
cursorrules_content, ".cursorrules", context_length=context_length,
read_path=str(cwd_path / ".cursorrules"),
)
return _truncate_content(cursorrules_content, ".cursorrules")
def build_context_files_prompt(
cwd: Optional[str] = None,
skip_soul: bool = False,
context_length: Optional[int] = None,
) -> str:
def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = False) -> str:
"""Discover and load context files for the system prompt.
Priority (first found wins — only ONE project context type is loaded):
@@ -1762,11 +1654,7 @@ def build_context_files_prompt(
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
SOUL.md from HERMES_HOME is independent and always included when present.
Each context source is capped before injection. The cap defaults to the
model's context window (scaled — see ``_dynamic_context_file_max_chars``)
when *context_length* is provided, falling back to 20,000 chars otherwise.
An explicit ``context_file_max_chars`` in config.yaml always wins.
Each context source is capped at 20,000 chars.
When *skip_soul* is True, SOUL.md is not included here (it was already
loaded via ``load_soul_md()`` for the identity slot).
@@ -1779,17 +1667,17 @@ def build_context_files_prompt(
# Priority-based project context: first match wins
project_context = (
_load_hermes_md(cwd_path, context_length)
or _load_agents_md(cwd_path, context_length)
or _load_claude_md(cwd_path, context_length)
or _load_cursorrules(cwd_path, context_length)
_load_hermes_md(cwd_path)
or _load_agents_md(cwd_path)
or _load_claude_md(cwd_path)
or _load_cursorrules(cwd_path)
)
if project_context:
sections.append(project_context)
# SOUL.md from HERMES_HOME only — skip when already loaded as identity
if not skip_soul:
soul_content = load_soul_md(context_length)
soul_content = load_soul_md()
if soul_content:
sections.append(soul_content)

View File

@@ -1,205 +0,0 @@
"""Profile-scoped credential resolution for multi-profile gateway multiplexing.
The multiplexing gateway serves many profiles from one process. Each profile
has its own ``.env`` with its own provider keys and platform tokens, so we
**cannot** union them into the process-global ``os.environ`` (that would leak
profile A's keys to profile B's turns, and to every subprocess spawned with
``env=dict(os.environ)``).
This module provides a fail-closed, context-local secret scope:
- ``set_secret_scope(mapping)`` installs the active profile's secrets for the
current task (a contextvar, so it propagates into the agent's worker thread
via ``copy_context()`` exactly like the HERMES_HOME override).
- ``get_secret(name)`` reads from that scope. When multiplexing is **active**
and no scope is set, it RAISES rather than silently falling back to
``os.environ`` — an un-migrated or newly-added call site fails loud at that
exact line instead of leaking another profile's value. When multiplexing is
**off** (the default), it transparently reads ``os.environ`` so the
single-profile gateway and every non-gateway caller behave exactly as before.
Design rationale lives in ``docs/design/multiplexing-gateway.md`` (Workstream A).
"""
from __future__ import annotations
import os
from contextvars import ContextVar, Token
from pathlib import Path
from typing import Dict, Mapping, Optional
# ── multiplex-active flag ────────────────────────────────────────────────
# Process-global: set once at gateway startup when gateway.multiplex_profiles
# is true. Governs whether get_secret() fails closed on an unscoped read.
# A plain module global (not a contextvar): it describes the deployment mode,
# not a per-task value.
_MULTIPLEX_ACTIVE: bool = False
def set_multiplex_active(active: bool) -> None:
"""Mark whether the process is running as a profile multiplexer.
Called once at gateway startup. When True, ``get_secret`` fails closed on
an unscoped read instead of falling back to ``os.environ``.
"""
global _MULTIPLEX_ACTIVE
_MULTIPLEX_ACTIVE = bool(active)
def is_multiplex_active() -> bool:
"""Return whether the process is running as a profile multiplexer."""
return _MULTIPLEX_ACTIVE
# ── the secret scope contextvar ──────────────────────────────────────────
_SECRET_SCOPE: ContextVar[Optional[Mapping[str, str]]] = ContextVar(
"_SECRET_SCOPE", default=None
)
class UnscopedSecretError(RuntimeError):
"""Raised when a secret is read in multiplex mode with no scope installed.
This is the fail-closed signal: it means a credential read reached
``get_secret`` without a profile scope active, which in a multiplexer would
otherwise leak whichever profile's value happened to be in ``os.environ``.
The fix is to wrap the call path in ``set_secret_scope(...)`` (the per-turn
/ per-adapter profile scope), not to widen the allowlist.
"""
def set_secret_scope(secrets: Optional[Mapping[str, str]]) -> Token:
"""Install the active profile's secret mapping for the current context.
Returns a token for ``reset_secret_scope``. Pass ``None`` to clear.
"""
return _SECRET_SCOPE.set(secrets)
def reset_secret_scope(token: Token) -> None:
"""Restore the previous secret scope."""
_SECRET_SCOPE.reset(token)
def current_secret_scope() -> Optional[Mapping[str, str]]:
"""Return the active secret mapping, or None when no scope is installed."""
return _SECRET_SCOPE.get()
# ── genuinely-global env vars (NOT per-profile secrets) ──────────────────
# These are process/deployment-level settings, not profile credentials. They
# legitimately live in os.environ and must keep reading from it even in
# multiplex mode — routing them through the fail-closed path would wrongly
# crash. Anything matching is read from os.environ regardless of scope.
#
# Membership test is by exact name OR prefix (see _is_global_env). Keep this
# list tight: when in doubt a value is a profile secret, not a global.
_GLOBAL_ENV_EXACT = frozenset({
# Hermes runtime / deployment
"HERMES_HOME", "HERMES_PROFILE", "HERMES_GATEWAY_LOCK_DIR",
"HERMES_MAX_ITERATIONS", "HERMES_MAX_TOKENS", "HERMES_API_TIMEOUT",
"HERMES_REDACT_SECRETS", "HERMES_NOUS_TIMEOUT_SECONDS",
"_HERMES_GATEWAY",
# OS / interpreter
"PATH", "HOME", "USER", "LANG", "LC_ALL", "TZ", "PWD", "SHELL", "TMPDIR",
"VIRTUAL_ENV", "PYTHONPATH", "SSL_CERT_FILE",
# Kanban paths (per-board, not per-profile-secret)
"HERMES_KANBAN_DB", "HERMES_KANBAN_WORKSPACES_ROOT", "HERMES_KANBAN_BOARD",
})
_GLOBAL_ENV_PREFIXES = (
"HERMES_KANBAN_",
"HERMES_TELEGRAM_", # tuning knobs (batch delays, fallback toggles) — NOT the token
"TERMINAL_", # terminal/sandbox backend settings
)
def _is_global_env(name: str) -> bool:
"""Return True for genuinely process-global (non-profile-secret) env vars."""
if name in _GLOBAL_ENV_EXACT:
return True
return any(name.startswith(p) for p in _GLOBAL_ENV_PREFIXES)
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
"""Resolve a credential by env-var name, honoring the active profile scope.
Resolution order:
1. Genuinely-global vars (``_is_global_env``) always read ``os.environ`` —
they are deployment settings, not profile secrets.
2. When a secret scope is installed (multiplexed turn), read from it; an
absent key returns ``default``. The scope is authoritative — we do NOT
fall through to ``os.environ``, because in a multiplexer ``os.environ``
may hold another profile's value.
3. No scope installed:
- multiplex INACTIVE (default deployment): read ``os.environ`` —
identical to the legacy ``os.getenv`` behavior every caller had before.
- multiplex ACTIVE: FAIL CLOSED. Raise ``UnscopedSecretError`` so the
missing scope is caught loudly instead of leaking a cross-profile value.
"""
if _is_global_env(name):
val = os.environ.get(name)
return val if val is not None else default
scope = _SECRET_SCOPE.get()
if scope is not None:
val = scope.get(name)
return val if val is not None else default
if _MULTIPLEX_ACTIVE:
raise UnscopedSecretError(
f"get_secret({name!r}) called with no profile secret scope active "
f"while multiplexing is on. This credential read must run inside a "
f"set_secret_scope(...) block (the per-turn / per-adapter profile "
f"scope). Reading os.environ here would risk leaking another "
f"profile's value. See docs/design/multiplexing-gateway.md "
f"(Workstream A)."
)
val = os.environ.get(name)
return val if val is not None else default
def load_env_file(env_path: Path) -> Dict[str, str]:
"""Parse a ``.env`` file into a plain dict WITHOUT touching ``os.environ``.
Used to load a profile's secrets into an isolated mapping for
``set_secret_scope``. Mirrors python-dotenv's basic parsing (KEY=VALUE,
``export`` prefix, ``#`` comments, optional matching quotes) but never
mutates the process environment — that isolation is the whole point.
"""
secrets: Dict[str, str] = {}
try:
text = env_path.read_text(encoding="utf-8")
except (FileNotFoundError, OSError, UnicodeDecodeError):
return secrets
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[len("export "):].lstrip()
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
if not key:
continue
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1]
secrets[key] = value
return secrets
def build_profile_secret_scope(hermes_home: Path) -> Dict[str, str]:
"""Build a profile's secret mapping from its ``<home>/.env``.
Returns a fresh dict (safe to install via ``set_secret_scope``). Genuinely
global vars are intentionally NOT copied in — ``get_secret`` reads those
from ``os.environ`` directly, so the scope holds only profile secrets.
"""
return load_env_file(Path(hermes_home) / ".env")

View File

@@ -33,7 +33,6 @@ from agent.prompt_builder import (
KANBAN_GUIDANCE,
MEMORY_GUIDANCE,
OPENAI_MODEL_EXECUTION_GUIDANCE,
PARALLEL_TOOL_CALL_GUIDANCE,
PLATFORM_HINTS,
SESSION_SEARCH_GUIDANCE,
SKILLS_GUIDANCE,
@@ -61,55 +60,6 @@ def _ra():
return run_agent
def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) -> str:
"""Apply a per-platform prompt-hint override to the default hint.
Reads ``agent._platform_hint_overrides`` (populated from
``config.yaml`` ``platform_hints`` by ``agent_init``) and resolves the
effective hint for *platform_key*:
* ``replace`` — substitute the default hint entirely.
* ``append`` — keep the default and append the extra text.
* a bare string value — treated as ``append`` (convenience shorthand).
Precedence: ``replace`` wins over ``append`` if both are present.
Override text is added on top of (not instead of) the SOUL/context/
memory tiers — it only affects the platform-hint segment, so other
platforms are unaffected and general system instructions still apply.
Defensive: any malformed entry falls back to the unmodified default so
a bad config value can never break prompt assembly or leak across
platforms.
"""
if not platform_key:
return default_hint
overrides = getattr(agent, "_platform_hint_overrides", None)
if not isinstance(overrides, dict) or not overrides:
return default_hint
spec = overrides.get(platform_key)
if spec is None:
return default_hint
# Shorthand: a bare string is treated as append text.
if isinstance(spec, str):
extra = spec.strip()
return f"{default_hint}\n\n{extra}".strip() if extra else default_hint
if not isinstance(spec, dict):
return default_hint
replace_text = spec.get("replace")
if isinstance(replace_text, str) and replace_text.strip():
base = replace_text.strip()
else:
base = default_hint
append_text = spec.get("append")
if isinstance(append_text, str) and append_text.strip():
return f"{base}\n\n{append_text.strip()}".strip()
return base
def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]:
"""Assemble the system prompt as three ordered parts.
@@ -133,17 +83,6 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# we resolve through ``_ra()`` to honor those patches.
_r = _ra()
# Resolve the model's context window once so context-file caps can scale
# to it (dynamic cap — see prompt_builder._dynamic_context_file_max_chars).
# None falls back to the historical flat default. This value is stable for
# the life of the conversation, so it does not threaten prompt caching.
_ctx_len: Optional[int] = None
_cc = getattr(agent, "context_compressor", None)
if _cc is not None:
_cc_len = getattr(_cc, "context_length", None)
if isinstance(_cc_len, int) and _cc_len > 0:
_ctx_len = _cc_len
# ── Stable tier ────────────────────────────────────────────────
stable_parts: List[str] = []
@@ -152,7 +91,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# cwd project instructions disabled.
_soul_loaded = False
if agent.load_soul_identity or not agent.skip_context_files:
_soul_content = _r.load_soul_md(_ctx_len)
_soul_content = _r.load_soul_md()
if _soul_content:
stable_parts.append(_soul_content)
_soul_loaded = True
@@ -173,17 +112,6 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if getattr(agent, "_task_completion_guidance", True) and agent.valid_tool_names:
stable_parts.append(TASK_COMPLETION_GUIDANCE)
# Universal parallel-tool-call guidance. Tells the model to batch
# independent tool calls into one assistant turn rather than emitting one
# call per turn — the runtime already runs independent calls concurrently
# (read-only tools always; non-overlapping path-scoped file ops), so the
# only thing missing was steering the model to produce the batch. Cuts
# round-trips and the resent-context cost that compounds over a long
# conversation. Gated by config.yaml ``agent.parallel_tool_call_guidance``
# (default True) and only injected when tools are actually loaded.
if getattr(agent, "_parallel_tool_call_guidance", True) and agent.valid_tool_names:
stable_parts.append(PARALLEL_TOOL_CALL_GUIDANCE)
# Tool-aware behavioral guidance: only inject when the tools are loaded
tool_guidance = []
if "memory" in agent.valid_tool_names:
@@ -380,25 +308,18 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
)
platform_key = (agent.platform or "").lower().strip()
# Resolve the built-in/plugin default hint for this platform, then apply
# any per-platform override from config (platform_hints.<platform>).
_default_hint = ""
if platform_key in PLATFORM_HINTS:
_default_hint = PLATFORM_HINTS[platform_key]
stable_parts.append(PLATFORM_HINTS[platform_key])
elif platform_key:
# Check plugin registry for platform-specific LLM guidance
try:
from gateway.platform_registry import platform_registry
_entry = platform_registry.get(platform_key)
if _entry and _entry.platform_hint:
_default_hint = _entry.platform_hint
stable_parts.append(_entry.platform_hint)
except Exception:
pass
_effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)
if _effective_hint:
stable_parts.append(_effective_hint)
# ── Context tier (cwd-dependent, may change between sessions) ─
context_parts: List[str] = []
@@ -413,8 +334,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# dir — the user's real cwd there, but the install dir for the gateway
# daemon, which is why the gateway sets TERMINAL_CWD.
context_files_prompt = _r.build_context_files_prompt(
cwd=resolve_context_cwd(), skip_soul=_soul_loaded,
context_length=_ctx_len)
cwd=resolve_context_cwd(), skip_soul=_soul_loaded)
if context_files_prompt:
context_parts.append(context_files_prompt)

View File

@@ -1012,42 +1012,28 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
elif function_name == "memory":
def _execute(next_args: dict) -> Any:
target = next_args.get("target", "memory")
operations = next_args.get("operations")
from tools.memory_tool import memory_tool as _memory_tool
result = _memory_tool(
action=next_args.get("action"),
target=target,
content=next_args.get("content"),
old_text=next_args.get("old_text"),
operations=operations,
store=agent._memory_store,
)
# Bridge: notify external memory provider of built-in memory writes.
# Covers both the single-op shape and each add/replace inside a batch.
if agent._memory_manager:
if operations:
_mem_ops = [
op for op in operations
if isinstance(op, dict) and op.get("action") in {"add", "replace"}
]
else:
_mem_ops = (
[{"action": next_args.get("action"), "content": next_args.get("content")}]
if next_args.get("action") in {"add", "replace"} else []
# Bridge: notify external memory provider of built-in memory writes
if agent._memory_manager and next_args.get("action") in {"add", "replace"}:
try:
agent._memory_manager.on_memory_write(
next_args.get("action", ""),
target,
next_args.get("content", ""),
metadata=agent._build_memory_write_metadata(
task_id=effective_task_id,
tool_call_id=getattr(tool_call, "id", None),
),
)
for _op in _mem_ops:
try:
agent._memory_manager.on_memory_write(
_op.get("action", ""),
target,
_op.get("content", "") or "",
metadata=agent._build_memory_write_metadata(
task_id=effective_task_id,
tool_call_id=getattr(tool_call, "id", None),
),
)
except Exception:
pass
except Exception:
pass
return result
function_result, function_args = _run_agent_tool_execution_middleware(
agent,

View File

@@ -88,7 +88,7 @@ class AnthropicTransport(ProviderTransport):
from agent.transports.types import ToolCall
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
_MCP_PREFIX = "mcp__"
_MCP_PREFIX = "mcp_"
text_parts = []
reasoning_parts = []
@@ -132,25 +132,17 @@ class AnthropicTransport(ProviderTransport):
elif block.type == "tool_use":
name = block.name
if strip_tool_prefix and name.startswith(_MCP_PREFIX):
# On the OAuth wire every tool carries a double-underscore
# ``mcp__`` prefix (added in build_anthropic_kwargs to avoid
# Anthropic's single-underscore third-party classifier).
# Reverse it back to the name the registry/dispatcher knows.
# Two original forms map onto the same ``mcp__`` wire name:
# ``mcp__read_file`` <- bare native tool ``read_file``
# ``mcp__linear_get_issue`` <- MCP server tool
# ``mcp_linear_get_issue``
# Resolve by registry lookup, preferring whichever original
# is actually registered; never rewrite a name the LLM used
# that already resolves natively. GH-25255.
stripped = name[len(_MCP_PREFIX):]
# Only strip the mcp_ prefix for OAuth-injected tools
# (where Hermes adds the prefix when sending to Anthropic
# and must remove it on the way back). Native MCP server
# tools (from mcp_servers: in config.yaml) are registered
# in the tool registry under their FULL mcp_<server>_<tool>
# name and must NOT be stripped. GH-25255.
from tools.registry import registry as _tool_registry
if not _tool_registry.get_entry(name):
bare = name[len(_MCP_PREFIX):] # read_file
single = "mcp_" + bare # mcp_read_file / mcp_linear_get_issue
if _tool_registry.get_entry(single):
name = single
elif _tool_registry.get_entry(bare):
name = bare
if (_tool_registry.get_entry(stripped)
and not _tool_registry.get_entry(name)):
name = stripped
tool_calls.append(
ToolCall(
id=block.id,

View File

@@ -128,65 +128,6 @@ class ResponsesApiTransport(ProviderTransport):
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
response_tools = _responses_tools(tools)
# xAI server-side web search.
#
# grok models on xAI's /v1/responses surface (notably
# grok-composer-2.5-fast on SuperGrok OAuth) have a *native*,
# server-executed web search. When the model is handed a
# client-side function literally named ``web_search``, it routes
# the intent to that native engine — but because the tool is
# declared as a plain ``function`` rather than xAI's first-class
# ``{"type": "web_search"}`` built-in, the server-side search is
# dispatched but never reconciled: the response streams reasoning
# + ``web_search_call`` progress items, the searches never reach
# ``status="completed"`` in the assembled output, no final
# message is emitted, and ``_normalize_codex_response`` correctly
# sees reasoning-with-no-answer and reports ``incomplete``. The
# turn then burns 3 continuation retries and fails with "Codex
# response remained incomplete after 3 continuation attempts".
# Verified live against grok-composer-2.5-fast (2026-06).
#
# Fix: when the agent HAS a client-side ``web_search`` function (i.e.
# the user enabled the web toolset), declare xAI's native
# ``web_search`` built-in instead so the search actually runs to
# completion server-side and the model streams a real answer. The
# Responses API rejects two tools sharing the name ``web_search``
# (HTTP 400 "Duplicate tool names"), so we drop the client-side
# ``web_search`` function for the xAI path and let the native tool
# satisfy it. All other client-side tools (read_file, terminal,
# web_extract, MCP tools, …) are untouched and continue to dispatch
# through Hermes's agent loop.
#
# Scope: we ONLY swap in the native built-in when the client
# ``web_search`` was actually present. We do NOT force-enable Grok
# server-side search on turns where the user never had web enabled —
# that would silently route around Hermes's web-provider config and
# tool-trace/citation plumbing for every xai-oauth turn. The swap is
# a 1:1 replacement of an already-requested capability, not an
# additive grant.
#
# NOTE: for the swapped case this routes ``web_search`` to Grok's
# native search engine for xAI sessions instead of Hermes's
# configured web provider (Tavily/etc.), and those results bypass
# Hermes's tool-trace / citation plumbing (they arrive baked into the
# model's answer rather than as a tool result the loop observes).
# Scoped to ``is_xai_responses`` deliberately; narrow to specific
# models if a future grok variant should keep the client-side
# function.
if is_xai_responses and response_tools:
has_client_web_search = any(
isinstance(t, dict) and t.get("name") == "web_search"
for t in response_tools
)
if has_client_web_search:
filtered = [
t for t in response_tools
if not (isinstance(t, dict) and t.get("name") == "web_search")
]
filtered.append({"type": "web_search"})
response_tools = filtered
# ``tools`` MUST be omitted entirely when there are no functions to
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
# eagerly call ``_make_tools(tools)`` which does ``for tool in tools``
@@ -277,28 +218,10 @@ class ResponsesApiTransport(ProviderTransport):
kwargs.pop("timeout", None)
if is_codex_backend:
# The Codex backend rejects body-level ``extra_headers`` with
# HTTP 400, but the OpenAI SDK's ``extra_headers`` kwarg maps
# to actual HTTP request headers (not body fields). We need
# these headers for cache-scope routing so prompt cache hits
# remain high. Send session_id / x-client-request-id as HTTP
# headers while keeping ``prompt_cache_key`` in the body for
# standard OpenAI routing as a belt-and-braces fallback.
cache_scope_id = str(session_id or "").strip()
if cache_scope_id:
existing_extra_headers = kwargs.get("extra_headers")
merged_extra_headers: Dict[str, str] = {}
if isinstance(existing_extra_headers, dict):
merged_extra_headers.update(
{
str(key): str(value)
for key, value in existing_extra_headers.items()
if key and value is not None
}
)
merged_extra_headers["session_id"] = cache_scope_id
merged_extra_headers["x-client-request-id"] = cache_scope_id
kwargs["extra_headers"] = merged_extra_headers
# chatgpt.com/backend-api/codex rejects body-level
# ``extra_headers`` with HTTP 400. Correlation/cache routing for
# this backend must not be sent through the Responses payload.
kwargs.pop("extra_headers", None)
max_tokens = params.get("max_tokens")
if max_tokens is not None and not is_codex_backend:

View File

@@ -286,7 +286,7 @@ async fn run_update(app: AppHandle) -> Result<()> {
emit_stage(&app, "rebuild", StageState::Running, None, None);
let started = Instant::now();
let rebuild_args: Vec<String> = vec!["desktop".into(), "--build-only".into()];
let mut rebuild = run_streamed(
let rebuild = run_streamed(
&app,
&hermes,
&rebuild_args,
@@ -295,33 +295,6 @@ async fn run_update(app: AppHandle) -> Result<()> {
Some("rebuild"),
)
.await?;
// Retry-once: the first `--build-only` can return nonzero on a still-settling
// post-update tree or a network-blocked Electron fetch that our self-heal
// repaired mid-run. A second attempt then builds clean off the healed dist
// (the content-hash stamp makes it a near-no-op when the first actually
// succeeded). Without this the updater bails here and never reaches the
// relaunch below — the app updates but doesn't restart. Matches the
// retry-once `hermes update` already does above, and `hermes update`'s own
// desktop rebuild in cmd_update.
if rebuild_needs_retry(rebuild.exit_code) {
emit_log(
&app,
Some("rebuild"),
LogStream::Stdout,
"[rebuild] first desktop rebuild failed; retrying once (a self-healed \
Electron download builds clean on the second run)…",
);
rebuild = run_streamed(
&app,
&hermes,
&rebuild_args,
&install_root,
&child_env,
Some("rebuild"),
)
.await?;
}
let rebuild_ms = started.elapsed().as_millis() as u64;
if rebuild.exit_code != Some(0) {
@@ -560,14 +533,6 @@ fn is_locked(path: &Path) -> bool {
}
}
/// Whether the `desktop --build-only` rebuild should be retried once. Any
/// non-success exit qualifies: the common cause is a transient first-attempt
/// failure (still-settling tree / self-healed Electron download) that a clean
/// second run resolves.
fn rebuild_needs_retry(exit_code: Option<i32>) -> bool {
exit_code != Some(0)
}
/// Spawn `hermes <args>` from `cwd`, stream stdout/stderr as Log events on the
/// bootstrap channel, and return the exit code. Mirrors powershell::run_script
/// but for an arbitrary command (no install.ps1 -File wrapping).
@@ -1005,16 +970,6 @@ mod tests {
assert_eq!(update_branch_from_args(["--update"]), None);
}
#[test]
fn rebuild_retries_only_on_failure() {
assert!(!rebuild_needs_retry(Some(0)), "a clean rebuild must not retry");
assert!(rebuild_needs_retry(Some(1)), "a failed rebuild retries once");
assert!(
rebuild_needs_retry(None),
"a killed/signalled rebuild (no exit code) retries once"
);
}
#[test]
fn parses_only_app_targets() {
assert_eq!(

View File

@@ -269,94 +269,6 @@ function cookiesHaveLiveSession(cookies) {
)
}
/**
* Normalize a stored SSH connection entry into a clean descriptor, or null when
* it is not a usable SSH config. Pure: no secrets here — the per-connection
* dashboard token is persisted separately (encrypted) and decrypted by main.cjs,
* exactly like the token-remote secret. An SSH entry needs at least a host.
*
* Shape in/out: { mode:'ssh', host, user?, port?, keyPath?, remoteHermesPath? }
*/
function normalizeSshConfig(entry) {
if (!entry || typeof entry !== 'object' || entry.mode !== 'ssh') {
return null
}
let host = String(entry.host || '').trim()
if (!host) {
return null
}
// Parse a user@host[:port] target typed into the single host field. Explicit
// user/port fields win, so filling the User field after typing user@host does
// NOT double up into user@user@host. A bare ~/.ssh/config alias is preserved.
let parsedUser
let parsedPort
const at = host.indexOf('@')
if (at > 0) {
parsedUser = host.slice(0, at)
host = host.slice(at + 1)
}
// Only split a trailing :port when there's exactly one colon and a numeric
// suffix — leaves IPv6 literals (multiple colons) and bare aliases alone.
if ((host.match(/:/g) || []).length === 1) {
const [h, p] = host.split(':')
if (/^\d+$/.test(p)) {
host = h
parsedPort = Number.parseInt(p, 10)
}
}
if (!host) {
return null
}
const out = { mode: 'ssh', host }
const user = String(entry.user || '').trim() || parsedUser || ''
if (user) out.user = user
const explicitPort = Number.parseInt(String(entry.port ?? ''), 10)
const port = Number.isInteger(explicitPort) && explicitPort > 0 ? explicitPort : parsedPort
if (Number.isInteger(port) && port > 0 && port !== 22) {
out.port = port
}
const keyPath = String(entry.keyPath || '').trim()
if (keyPath) out.keyPath = keyPath
const remoteHermesPath = String(entry.remoteHermesPath || '').trim()
if (remoteHermesPath) out.remoteHermesPath = remoteHermesPath
return out
}
/**
* Select a profile's SSH connection override from a connection config, or null
* when it has none. Mirrors profileRemoteOverride() but for `mode: 'ssh'`
* entries. Returns the normalized SSH descriptor (no token).
*/
function profileSshOverride(config, profile) {
const key = connectionScopeKey(profile)
const entry = key ? config?.profiles?.[key] : null
return normalizeSshConfig(entry)
}
/**
* Human-facing host label for the connection statusbar pill. For SSH mode the
* caller passes the resolved/entered host directly; for token/oauth remotes we
* derive it from the (real) backend URL — NOT the loopback tunnel URL. Returns
* a bare hostname (and :port when non-default) or null.
*/
function hostLabelFromBaseUrl(baseUrl) {
const raw = String(baseUrl || '').trim()
if (!raw) return null
let parsed
try {
parsed = new URL(raw)
} catch {
return null
}
const host = parsed.hostname
if (!host) return null
const port = parsed.port
if (port && port !== '80' && port !== '443') {
return `${host}:${port}`
}
return host
}
module.exports = {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
@@ -366,13 +278,10 @@ module.exports = {
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
hostLabelFromBaseUrl,
normAuthMode,
normalizeRemoteBaseUrl,
normalizeSshConfig,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
profileSshOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview

View File

@@ -22,13 +22,10 @@ const {
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
hostLabelFromBaseUrl,
normAuthMode,
normalizeRemoteBaseUrl,
normalizeSshConfig,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
profileSshOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
@@ -397,82 +394,3 @@ test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
/mintTicket function is required/
)
})
// --- SSH mode helpers ---
test('normalizeSshConfig requires mode:ssh and a host', () => {
assert.equal(normalizeSshConfig(null), null)
assert.equal(normalizeSshConfig({ mode: 'remote', url: 'http://x' }), null)
assert.equal(normalizeSshConfig({ mode: 'ssh' }), null)
assert.equal(normalizeSshConfig({ mode: 'ssh', host: ' ' }), null)
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box' }), { mode: 'ssh', host: 'box' })
})
test('normalizeSshConfig keeps user/keyPath/remoteHermesPath and drops the default port', () => {
assert.deepEqual(
normalizeSshConfig({
mode: 'ssh',
host: 'box',
user: 'me',
port: 22,
keyPath: '~/.ssh/id_ed25519',
remoteHermesPath: '/opt/hermes'
}),
{ mode: 'ssh', host: 'box', user: 'me', keyPath: '~/.ssh/id_ed25519', remoteHermesPath: '/opt/hermes' }
)
})
test('normalizeSshConfig preserves a non-default port', () => {
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: 2222 }), {
mode: 'ssh',
host: 'box',
port: 2222
})
})
test('normalizeSshConfig parses user@host typed into the host field', () => {
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'jonny@mac-mini' }), {
mode: 'ssh',
host: 'mac-mini',
user: 'jonny'
})
})
test('normalizeSshConfig parses user@host:port and drops a default :22', () => {
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'jonny@box:2222' }), {
mode: 'ssh',
host: 'box',
user: 'jonny',
port: 2222
})
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box:22' }), { mode: 'ssh', host: 'box' })
})
test('normalizeSshConfig: explicit user/port win over user@host:port (no user@user@host)', () => {
assert.deepEqual(
normalizeSshConfig({ mode: 'ssh', host: 'jonny@box:2222', user: 'admin', port: 2200 }),
{ mode: 'ssh', host: 'box', user: 'admin', port: 2200 }
)
})
test('normalizeSshConfig leaves a bare ~/.ssh/config alias and IPv6 literals alone', () => {
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'mac-mini' }), { mode: 'ssh', host: 'mac-mini' })
// IPv6 (multiple colons) must NOT be split as host:port
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'fe80::1' }), { mode: 'ssh', host: 'fe80::1' })
})
test('profileSshOverride returns a profile-scoped ssh descriptor or null', () => {
const config = { profiles: { work: { mode: 'ssh', host: 'mac-mini', user: 'jonny' }, other: { mode: 'remote', url: 'http://x' } } }
assert.deepEqual(profileSshOverride(config, 'work'), { mode: 'ssh', host: 'mac-mini', user: 'jonny' })
assert.equal(profileSshOverride(config, 'other'), null, 'token-remote entry is not an ssh override')
assert.equal(profileSshOverride(config, 'missing'), null)
assert.equal(profileSshOverride(config, ''), null, 'global scope has no profile entry')
})
test('hostLabelFromBaseUrl gives a bare host, with :port only when non-default', () => {
assert.equal(hostLabelFromBaseUrl('https://box.tail1234.ts.net'), 'box.tail1234.ts.net')
assert.equal(hostLabelFromBaseUrl('http://box.local:8080'), 'box.local:8080')
assert.equal(hostLabelFromBaseUrl('https://box:443'), 'box')
assert.equal(hostLabelFromBaseUrl(''), null)
assert.equal(hostLabelFromBaseUrl('not a url'), null)
})

View File

@@ -28,7 +28,6 @@ const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = requ
const { runBootstrap } = require('./bootstrap-runner.cjs')
const {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
@@ -37,9 +36,6 @@ const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { waitForDashboardPort } = require('./backend-ready.cjs')
const { SSH_ERROR, SshConnection, buildInteractiveSshArgs, pickLocalPort, redactSecrets } = require('./ssh-connection.cjs')
const remoteLifecycle = require('./remote-lifecycle.cjs')
const { collectSshConfigHosts, parseSshGOutput } = require('./ssh-config.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
@@ -48,7 +44,6 @@ const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
const {
buildPosixCleanupScript,
buildWindowsCleanupScript,
@@ -66,13 +61,10 @@ const {
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
hostLabelFromBaseUrl,
normAuthMode,
normalizeRemoteBaseUrl,
normalizeSshConfig,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
profileSshOverride,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
@@ -2016,14 +2008,10 @@ async function applyUpdatesPosixInApp() {
}
emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 })
// Retry-once: a first rebuild can fail on a still-settling tree or a
// self-healed (network-blocked) Electron download; a second run builds clean
// off the healed dist so we reach the swap+relaunch below instead of bailing.
const rebuilt = await runRebuildWithRetry(attempt => {
if (attempt > 0) {
emitUpdateProgress({ stage: 'rebuild', message: 'Retrying the desktop rebuild…', percent: 60 })
}
return runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' })
const rebuilt = await runStreamedUpdate(hermes, ['desktop', '--build-only'], {
cwd: updateRoot,
env,
stage: 'rebuild'
})
if (rebuilt.code !== 0) {
emitUpdateProgress({
@@ -4083,20 +4071,6 @@ function sanitizeConnectionProfiles(raw) {
continue
}
// SSH-mode entries carry host/user/port/keyPath/remoteHermesPath instead of
// a url, and (like remote entries) an encrypted token blob — the per-
// connection dashboard session token minted in main, NOT a user secret.
if (entry.mode === 'ssh') {
const ssh = normalizeSshConfig(entry)
if (ssh) {
if (entry.token && typeof entry.token === 'object') {
ssh.token = entry.token
}
out[name] = ssh
}
continue
}
const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' }
const url = String(entry.url || '').trim()
if (url) {
@@ -4140,10 +4114,7 @@ function readDesktopConnectionConfig() {
// backward compatibility with configs written before OAuth support.
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
config = {
// 'ssh' joins 'remote'/'local' as a top-level mode; SSH connection
// fields (host/user/port/keyPath/remoteHermesPath) ride on the `remote`
// sub-object, which is preserved verbatim below.
mode: parsed.mode === 'remote' ? 'remote' : parsed.mode === 'ssh' ? 'ssh' : 'local',
mode: parsed.mode === 'remote' ? 'remote' : 'local',
remote,
// Per-profile remote overrides: each profile may point at its own
// backend (local spawn or its own remote URL). Preserved verbatim so
@@ -4211,37 +4182,10 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
const scopedMode = key ? scoped?.mode : config.mode
// SSH-mode block: surface the connection fields (no token to the renderer —
// it's an internal artifact). remoteTokenSet reports whether a dashboard
// token has already been adopted (i.e. a running dashboard can be reused).
if (scopedMode === 'ssh') {
const sshConfig = normalizeSshConfig({ mode: 'ssh', ...block })
return {
mode: 'ssh',
profile: key,
sshHost: sshConfig?.host || '',
sshUser: sshConfig?.user || '',
sshPort: sshConfig?.port || null,
sshKeyPath: sshConfig?.keyPath || '',
sshRemoteHermesPath: sshConfig?.remoteHermesPath || '',
// Remote-auth fields are not meaningful in SSH mode (the dashboard token
// is internal), but the renderer contract always carries them — return
// inert defaults so consumers never optional-narrow.
remoteAuthMode: 'token',
remoteOauthConnected: false,
remoteUrl: '',
remoteTokenPreview: null,
remoteTokenSet: Boolean(decryptDesktopSecret(block.token)),
envOverride: false
}
}
const remoteToken = decryptDesktopSecret(block.token)
const authMode = normAuthMode(block.authMode)
const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '')
const mode = envOverride || scopedMode === 'remote' ? 'remote' : 'local'
const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
let remoteOauthConnected = false
if (authMode === 'oauth' && remoteUrl) {
@@ -4265,13 +4209,6 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
remoteUrl,
remoteTokenPreview: tokenPreview(remoteToken),
remoteTokenSet: Boolean(remoteToken),
// SSH fields are always present on the contract (empty in local/remote mode)
// so the renderer never optional-narrows; populated only in the ssh branch.
sshHost: '',
sshUser: '',
sshPort: null,
sshKeyPath: '',
sshRemoteHermesPath: '',
// The env override only forces the global/primary connection; a per-profile
// scope is never overridden by HERMES_DESKTOP_REMOTE_URL.
envOverride
@@ -4291,21 +4228,7 @@ function buildRemoteBlock(remoteUrl, authMode, token) {
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
const persistToken = options.persistToken !== false
const key = connectionScopeKey(input.profile)
const mode = input.mode === 'remote' ? 'remote' : input.mode === 'ssh' ? 'ssh' : 'local'
// SSH-mode save: connection fields are host/user/port/keyPath/remoteHermesPath
// (no user-entered token; the dashboard token is minted + reconciled at
// bootstrap and persisted separately). A saved SSH block preserves any
// already-adopted token so a reconnect can reuse the running dashboard.
if (mode === 'ssh') {
const sshBlock = buildSshBlock(input, key ? existing.profiles?.[key] || {} : existing.remote || {})
if (key) {
const profiles = { ...(existing.profiles || {}) }
profiles[key] = sshBlock
return { mode: existing.mode === 'remote' || existing.mode === 'ssh' ? existing.mode : 'local', remote: existing.remote || {}, profiles }
}
return { mode: 'ssh', remote: sshBlock, profiles: existing.profiles || {} }
}
const mode = input.mode === 'remote' ? 'remote' : 'local'
// The block being edited: a per-profile entry or the global remote block.
const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {}
@@ -4328,7 +4251,7 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
} else {
delete profiles[key]
}
return { mode: existing.mode === 'remote' || existing.mode === 'ssh' ? existing.mode : 'local', remote: existing.remote || {}, profiles }
return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles }
}
const nextRemote =
@@ -4340,41 +4263,13 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
return { mode, remote: nextRemote, profiles: existing.profiles || {} }
}
// Build an SSH connection block from a save payload, preserving an
// already-adopted dashboard token from the existing block (the token is minted
// + reconciled at bootstrap, never user-entered). `mode: 'ssh'` is stamped so
// normalizeSshConfig/profileSshOverride recognize it.
function buildSshBlock(input, existingBlock = {}) {
const merged = normalizeSshConfig({
mode: 'ssh',
host: input.sshHost ?? existingBlock.host,
user: input.sshUser ?? existingBlock.user,
port: input.sshPort ?? existingBlock.port,
keyPath: input.sshKeyPath ?? existingBlock.keyPath,
remoteHermesPath: input.sshRemoteHermesPath ?? existingBlock.remoteHermesPath
})
if (!merged) {
throw new Error('SSH host is required.')
}
// Carry forward an already-adopted dashboard token unless the host changed
// (a different host invalidates the old dashboard's token).
if (existingBlock.token && existingBlock.host === merged.host) {
merged.token = existingBlock.token
}
return merged
}
// Build a remote backend connection descriptor from an already-resolved remote
// config. Handles both auth models (OAuth ws-ticket vs static session token)
// and is shared by the per-profile, env, and global resolution paths. `token`
// is the DECRYPTED static token (or null in OAuth mode). `source` is a label
// for diagnostics ('profile' | 'env' | 'settings').
async function buildRemoteConnection(rawUrl, authMode, token, source, remoteHost, remoteKind = 'url') {
async function buildRemoteConnection(rawUrl, authMode, token, source) {
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
// For token/oauth remotes the meaningful host is the real backend URL; for
// SSH remotes the caller passes the entered/resolved host explicitly (the
// baseUrl is a 127.0.0.1 tunnel and would be useless in the pill).
const host = remoteHost || hostLabelFromBaseUrl(baseUrl)
if (authMode === 'oauth') {
// OAuth gateway: auth comes from the session cookies in the OAuth
@@ -4411,8 +4306,6 @@ async function buildRemoteConnection(rawUrl, authMode, token, source, remoteHost
mode: 'remote',
source,
authMode: 'oauth',
remoteHost: host || undefined,
remoteKind,
// No static token in OAuth mode; REST is cookie-authed via the partition.
token: null,
wsUrl: buildGatewayWsUrlWithTicket(baseUrl, ticket)
@@ -4431,220 +4324,11 @@ async function buildRemoteConnection(rawUrl, authMode, token, source, remoteHost
mode: 'remote',
source,
authMode: 'token',
remoteHost: host || undefined,
remoteKind,
token,
wsUrl: buildGatewayWsUrl(baseUrl, token)
}
}
// ---------------------------------------------------------------------------
// SSH remote-mode bootstrap
//
// SSH mode is architecturally desktop-local mode with the loopback stretched
// over SSH: open a ControlMaster, bring up (or reuse) a dedicated --isolated
// dashboard on the remote, forward 127.0.0.1:<local> -> 127.0.0.1:<remote>,
// then hand the EXISTING token-remote machinery a 127.0.0.1 baseUrl. Everything
// downstream (REST bridge, /api/ws, sessions, /api/fs/*, version/update pills)
// is unchanged — it keys off the connection descriptor, not how it was made.
// ---------------------------------------------------------------------------
// Live SSH connections keyed by scope ('' for global, or the profile name).
// Holds the SshConnection (the control master), the tunnel ports, and the
// remote pid so liveness/reconnect/teardown can find them. Survives across
// resolveRemoteBackend calls within one app run.
const sshConnections = new Map()
// One-shot guard so the awaited before-quit SSH teardown (which preventDefaults
// the first quit) doesn't loop when app.quit() fires the event again.
let sshQuitTeardownDone = false
function sshScopeKey(profile) {
return connectionScopeKey(profile) || ''
}
// Redaction-wrapped logger so NOTHING that flows through the SSH lifecycle
// (spawn command lines carry the session token) reaches desktop.log raw.
function sshRememberLog(chunk) {
rememberLog(redactSecrets(String(chunk == null ? '' : chunk)))
}
// Authenticated GET /api/status through the tunnel — the authoritative reuse
// probe. True iff the dashboard answers ok with this token.
async function sshProbeStatus(baseUrl, token) {
try {
await fetchJson(`${baseUrl}/api/status`, token)
return true
} catch {
return false
}
}
// Tear down a scope's SSH state: cancel the forward, close the master, forget
// it. Leaves the REMOTE dashboard running (reconnect is instant; in-flight
// agent turns survive a client drop) — that is the VS Code semantics the spec
// chose. The lockfile reuse flow recovers it on next connect.
async function teardownSshConnection(profile) {
const scope = sshScopeKey(profile)
const state = sshConnections.get(scope)
if (!state) return
sshConnections.delete(scope)
// Dispose any interim ssh -tt terminals riding this scope's master FIRST —
// once the master closes a leftover PTY is pointed at a dead control socket.
// Spec component 4 invariant: a connection flip tears down terminal sessions
// on the connection (mirrors desktop-remote-terminal.md). Local/other-scope
// terminals are untagged or tagged with a different scope and are left alone.
for (const [id, info] of [...terminalSessions.entries()]) {
if (info.sshScope === scope) {
disposeTerminalSession(id)
}
}
try {
if (state.localPort && state.remotePort) {
await state.ssh.cancelForward(state.localPort, state.remotePort)
}
} catch {
// best effort
}
try {
await state.ssh.close()
} catch {
// best effort
}
}
// Resolve the live SSH connection backing the window's PRIMARY backend, or
// null when the active connection is not SSH. Used by the interim ssh -tt
// terminal so a remote terminal lands on the SSH host — and ONLY in SSH mode
// (it must never leak into token/oauth remotes, whose trust boundary is a
// token/cookie, not a shell credential). Returns { ssh, scope } so the spawned
// terminal can be tagged with its backing scope and disposed on a flip.
//
// CRITICAL: this must mirror resolveRemoteBackend's precedence, not just return
// any cached SSH state. A per-profile token/OAuth override wins over a global
// SSH connection — so if the active profile resolves to a NON-SSH backend, the
// terminal must NOT fall through to a global SSH host. Returning cached SSH
// state unconditionally would leak an ssh -tt shell into a token/OAuth remote.
function activeSshTerminalTarget() {
const profile = primaryProfileKey()
const config = readDesktopConnectionConfig()
// 1. Per-profile SSH override → that scope's SSH state (if live).
if (profileSshOverride(config, profile)) {
const scope = sshScopeKey(profile)
const state = sshConnections.get(scope)
return state && state.ssh ? { ssh: state.ssh, scope } : null
}
// 2. Per-profile NON-SSH override (token/OAuth) → NOT an SSH terminal. Stop
// here; do not fall through to global SSH.
if (profileRemoteOverride(config, profile)) {
return null
}
// 3. Env override is token-auth URL remote, never SSH.
if (process.env.HERMES_DESKTOP_REMOTE_URL) {
return null
}
// 4. Global SSH → the global scope's SSH state (if live).
if (config.mode === 'ssh') {
const state = sshConnections.get('')
return state && state.ssh ? { ssh: state.ssh, scope: '' } : null
}
return null
}
// Bring up (or reuse) the SSH-tunneled dashboard for one scope and return a
// token-remote connection descriptor. `sshConfig` is the normalized
// { host, user?, port?, keyPath?, remoteHermesPath? }; `reuseToken` is the
// decrypted per-connection token from encrypted storage (or '').
async function bootstrapSshConnection(profile, sshConfig, reuseToken, source) {
const scope = sshScopeKey(profile)
const hostLabel = sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host
// Reuse a live master for this scope if we still have one; otherwise open
// fresh. A dead master (sleep/network flap) is closed and reopened.
let ssh = sshConnections.get(scope)?.ssh
if (ssh && !(await ssh.isAlive())) {
try {
await ssh.close()
} catch {
// ignore
}
ssh = null
sshConnections.delete(scope)
}
if (!ssh) {
ssh = new SshConnection(
{ host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath },
{ rememberLog: sshRememberLog }
)
await ssh.open()
}
let result
try {
result = await remoteLifecycle.connect({
ssh,
profile: connectionScopeKey(profile) || '',
remoteHermesPath: sshConfig.remoteHermesPath || '',
clientId: scope || 'default',
reuseToken: reuseToken || '',
forward: (localPort, remotePort) => ssh.forward(localPort, remotePort),
cancelForward: (localPort, remotePort) => ssh.cancelForward(localPort, remotePort),
pickLocalPort,
waitForHermes,
probeStatus: sshProbeStatus,
adoptServedToken: adoptServedDashboardToken,
rememberLog: sshRememberLog
})
} catch (error) {
// Map lifecycle/SSH failures into a single actionable message; the boot
// overlay shows this verbatim instead of the generic gateway error.
const err = new Error(error.message)
err.sshError = error.kind || 'unknown'
err.isSshBootstrap = true
throw err
}
// Persist the served token (encrypted) so the next launch can reuse this
// dashboard via the lockfile fingerprint without re-bootstrapping.
persistSshConnectionToken(profile, source, result.token)
sshConnections.set(scope, {
ssh,
localPort: result.localPort,
remotePort: result.remotePort,
pid: result.pid,
host: sshConfig.host,
hostLabel
})
// Hand the existing token-remote machinery the loopback baseUrl. The pill's
// host is the SSH host, NOT 127.0.0.1.
return buildRemoteConnection(result.baseUrl, 'token', result.token, source, hostLabel, 'ssh')
}
// Save the served token back into the SSH connection entry (encrypted), so a
// later launch reuses the running dashboard. Global SSH lives under
// config.remote; a per-profile SSH override lives under config.profiles[name].
function persistSshConnectionToken(profile, source, token) {
try {
const config = readDesktopConnectionConfig()
const encrypted = encryptDesktopSecret(token)
if (source === 'profile') {
const key = connectionScopeKey(profile)
if (key && config.profiles?.[key]?.mode === 'ssh') {
config.profiles[key].token = encrypted
writeDesktopConnectionConfig(config)
}
} else if (config.mode === 'ssh' && config.remote) {
config.remote.token = encrypted
writeDesktopConnectionConfig(config)
}
} catch (error) {
sshRememberLog(`[ssh] could not persist served token: ${error.message}`)
}
}
// Resolve the remote backend for a given profile, or null when that profile
// should run a LOCAL backend. Precedence:
// 1. explicit per-profile remote override (connection.json `profiles[name]`)
@@ -4658,12 +4342,6 @@ async function resolveRemoteBackend(profile) {
// 1. Per-profile override — "a profile with its own remote host". Wins even
// over the env override so an explicitly-configured profile always
// reaches its intended backend.
const sshOverride = profileSshOverride(config, profile)
if (sshOverride) {
const reuseToken = decryptDesktopSecret(config.profiles?.[connectionScopeKey(profile)]?.token)
return bootstrapSshConnection(profile, sshOverride, reuseToken, 'profile')
}
const override = profileRemoteOverride(config, profile)
if (override) {
const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token)
@@ -4684,17 +4362,6 @@ async function resolveRemoteBackend(profile) {
}
// 3. Global remote.
// 3a. Global SSH remote — bootstrap the tunnel + dashboard, hand the
// token-remote machinery a loopback baseUrl.
if (config.mode === 'ssh') {
const ssh = normalizeSshConfig({ mode: 'ssh', ...(config.remote || {}) })
if (!ssh) {
throw new Error('SSH remote mode is selected but no host is configured. Open Settings → Gateway → Connect via SSH.')
}
const reuseToken = decryptDesktopSecret(config.remote?.token)
return bootstrapSshConnection(null, ssh, reuseToken, 'settings')
}
if (config.mode !== 'remote') {
return null
}
@@ -4717,17 +4384,13 @@ function configuredRemoteProfileNames() {
}
// True when the app is in app-global remote mode (Settings → "All profiles" →
// Remote/SSH, or the env override): a SINGLE remote backend serves every
// profile via ?profile=. Distinct from per-profile overrides — here there's one
// host for all. SSH counts: a global SSH connection resolves to one loopback
// backend that, exactly like a global URL remote, must carry ?profile= so each
// desktop profile maps to its own profile on the remote (not the remote default).
// Remote, or the env override): a SINGLE remote backend serves every profile via
// ?profile=. Distinct from per-profile overrides — here there's one host for all.
function globalRemoteActive() {
if (process.env.HERMES_DESKTOP_REMOTE_URL) {
return true
}
const mode = readDesktopConnectionConfig().mode
return mode === 'remote' || mode === 'ssh'
return readDesktopConnectionConfig().mode === 'remote'
}
// GET a profile's resolved backend (remote pool or local primary), parsed JSON.
@@ -4809,52 +4472,6 @@ async function probeRemoteAuthMode(rawUrl) {
}
async function testDesktopConnectionConfig(input = {}) {
// SSH mode: test reachability + that hermes is locatable on a supported
// platform, WITHOUT spawning a dashboard. Distinct errors for unreachable /
// auth-failed / hermes-not-found / unsupported-platform.
if (input.mode === 'ssh') {
const sshConfig = normalizeSshConfig({
mode: 'ssh',
host: input.sshHost,
user: input.sshUser,
port: input.sshPort,
keyPath: input.sshKeyPath,
remoteHermesPath: input.sshRemoteHermesPath
})
if (!sshConfig) {
return { reachable: false, sshError: 'unreachable', error: 'SSH host is required.' }
}
const ssh = new SshConnection(
{ host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath },
{ rememberLog: sshRememberLog }
)
try {
await ssh.open()
const platform = await remoteLifecycle.probeRemotePlatform(ssh)
const hermesPath = await remoteLifecycle.locateHermes(ssh, sshConfig.remoteHermesPath || '')
return {
reachable: true,
sshError: null,
error: null,
remotePlatform: `${platform.os}/${platform.arch}`,
remoteHermesPath: hermesPath,
host: sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host
}
} catch (error) {
return {
reachable: false,
sshError: error.kind || 'unknown',
error: error.message
}
} finally {
try {
await ssh.close()
} catch {
// best effort — a transient test connection
}
}
}
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
const key = connectionScopeKey(input.profile)
// The block under test: a per-profile entry or the global remote. Coerce has
@@ -5277,12 +4894,6 @@ async function startHermes() {
authMode: remote.authMode || 'token',
token: remote.token,
wsUrl: remote.wsUrl,
// Carry the SSH identity through so the statusbar pill reads "SSH: host"
// (not "Remote: 127.0.0.1") for a global SSH connection. Without these
// the primary-backend path drops them and the pill mislabels SSH as a
// plain token remote.
remoteHost: remote.remoteHost,
remoteKind: remote.remoteKind,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -5495,7 +5106,14 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
})
if (IS_MAC) {
@@ -5562,11 +5180,23 @@ function createWindow() {
// material before the renderer paints the app theme. See createSessionWindow.
show: false,
backgroundColor: getWindowBackgroundColor(),
// Shared with the secondary session windows (chatWindowWebPreferences) so
// both keep `backgroundThrottling: false` — the chat transcript streams via
// a requestAnimationFrame-gated flush that Chromium pauses for blurred
// windows, stalling the live answer until refocus. See session-windows.cjs.
webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true,
// Keep timers + requestAnimationFrame running at full speed when the
// window is blurred/occluded. The chat transcript streams to the screen
// through a requestAnimationFrame-gated flush (useSessionStateCache),
// so with Chromium's default background throttling the live answer
// stalls whenever this window isn't focused (e.g. you switch to your
// editor mid-turn, or open detached devtools) and only appears once you
// refocus or refresh. A streaming chat app must render in the
// background, so opt out — matching the secondary windows above.
backgroundThrottling: false
}
})
if (IS_MAC) {
@@ -5770,51 +5400,6 @@ ipcMain.handle('hermes:connection-config:get', async (_event, profile) =>
sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile)
)
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
ipcMain.handle('hermes:connection-config:ssh-hosts', async () => {
// Read-only host suggestions from ~/.ssh/config (+ Includes). Never writes.
try {
return { hosts: collectSshConfigHosts() }
} catch {
return { hosts: [] }
}
})
ipcMain.handle('hermes:connection-config:ssh-resolve', async (_event, host) => {
// Resolve the effective target with `ssh -G <host>` (short timeout) so the
// UI can show/normalize the real hostname/user/port/identityfile a host
// alias expands to. Best-effort: a failure returns nulls, not an error.
const target = String(host || '').trim()
if (!target) return { hostname: null, user: null, port: null, identityFile: null }
return new Promise(resolve => {
let out = ''
let settled = false
const child = spawn('ssh', ['-G', target], { stdio: ['ignore', 'pipe', 'ignore'] })
const timer = setTimeout(() => {
if (settled) return
settled = true
try {
child.kill('SIGKILL')
} catch {
// already gone
}
resolve({ hostname: null, user: null, port: null, identityFile: null })
}, 5_000)
child.stdout.on('data', d => {
out += d.toString()
})
child.on('error', () => {
if (settled) return
settled = true
clearTimeout(timer)
resolve({ hostname: null, user: null, port: null, identityFile: null })
})
child.on('close', () => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(parseSshGOutput(out))
})
})
})
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
// Open the gateway's OAuth login window and wait for the session cookie to
@@ -5845,10 +5430,6 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
const key = connectionScopeKey(payload?.profile)
// A connection change for this scope invalidates any live SSH tunnel for it —
// tear it down so the next resolve re-bootstraps against the new target.
await teardownSshConnection(key || null)
if (key && key !== primaryProfileKey()) {
// Editing a NON-primary profile's connection: don't disturb the window's
// primary backend. Drop the profile's pooled backend so the next switch
@@ -6481,57 +6062,10 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
ensureSpawnHelperExecutable()
const id = crypto.randomUUID()
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
// INTERIM SSH-mode remote terminal (component 5; SSH mode ONLY). When the
// window's primary backend is an SSH connection, spawn node-pty wrapping
// `ssh -tt` over the EXISTING control master so the terminal lands on the
// remote host. node-pty's resize() sends SIGWINCH to the local ssh client,
// which forwards it to the remote PTY — so resize propagates end to end.
// The remote cwd is the (remote) session cwd; we do NOT run it through
// safeTerminalCwd (that stats the LOCAL fs). This never engages for
// token/oauth remotes (activeSshTerminalTarget returns null) — their trust
// boundary is a token, not a shell credential.
// TODO(remote-terminal): replace with the dashboard /api/terminal WebSocket
// once specs/desktop-remote-terminal.md lands; then the terminal rides the
// tunnel like every other socket and cwd-follows-session becomes uniform.
const sshTarget = activeSshTerminalTarget()
if (sshTarget) {
const remoteCwd = String(payload?.cwd || '').trim()
const sshArgs = buildInteractiveSshArgs(sshTarget.ssh, remoteCwd)
const sshPty = nodePty.spawn('ssh', sshArgs, {
cols,
cwd: app.getPath('home'),
env: terminalShellEnv(),
name: 'xterm-256color',
rows
})
// Tag the session with its backing SSH scope so a connection flip can
// dispose the PTYs riding the master it tears down (the master goes away;
// a leftover ssh -tt would be pointed at a dead socket).
terminalSessions.set(id, { pty: sshPty, webContentsId: event.sender.id, sshScope: sshTarget.scope })
const sshSend = (suffix, data) => {
if (event.sender.isDestroyed()) {
return
}
event.sender.send(terminalChannel(id, suffix), data)
}
sshPty.onData(data => sshSend('data', data))
sshPty.onExit(({ exitCode, signal }) => {
terminalSessions.delete(id)
sshSend('exit', { code: exitCode, signal: signal || null })
})
event.sender.once('destroyed', () => disposeTerminalSession(id))
return { cwd: remoteCwd, id, shell: 'ssh' }
}
const { args, command, name } = terminalShellCommand()
const cwd = safeTerminalCwd(payload?.cwd)
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
const ptyProcess = nodePty.spawn(command, args, {
cols,
cwd,
@@ -7013,7 +6547,7 @@ function configureSpellChecker() {
}
}
app.on('before-quit', event => {
app.on('before-quit', () => {
// Quitting mid-install should stop the installer, not orphan it.
if (bootstrapAbortController) {
try {
@@ -7030,36 +6564,10 @@ app.on('before-quit', event => {
flushDesktopLogBufferSync()
closePreviewWatchers()
// Kill open PTYs before environment teardown to avoid the node-pty#904
// ThreadSafeFunction SIGABRT race.
for (const id of [...terminalSessions.keys()]) {
disposeTerminalSession(id)
}
if (hermesProcess && !hermesProcess.killed) {
hermesProcess.kill('SIGTERM')
}
stopAllPoolBackends()
// Close SSH control masters so local forwards don't linger after quit (the
// master is opened with -f/ControlPersist, so a fire-and-forget close can be
// cut off by app exit before the socket is torn down). The REMOTE dashboards
// are intentionally LEFT running — only the local-side master/forward closes —
// so a relaunch reconnects via the lockfile reuse flow without re-bootstrapping
// (VS Code semantics). One-shot: preventDefault the first quit, await teardown
// (bounded so a wedged ssh can't block quit), then quit again.
if (sshConnections.size > 0 && !sshQuitTeardownDone) {
event.preventDefault()
const scopes = [...sshConnections.keys()]
const bounded = Promise.race([
Promise.allSettled(scopes.map(scope => teardownSshConnection(scope || null))),
new Promise(resolve => setTimeout(resolve, 4000))
])
void bounded.then(() => {
sshQuitTeardownDone = true
app.quit()
})
}
})
app.on('window-all-closed', () => {

View File

@@ -12,8 +12,6 @@ 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),
sshConfigHosts: () => ipcRenderer.invoke('hermes:connection-config:ssh-hosts'),
sshResolveHost: host => ipcRenderer.invoke('hermes:connection-config:ssh-resolve', host),
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),

View File

@@ -1,505 +0,0 @@
/**
* remote-lifecycle.cjs
*
* Pure, electron-free remote Hermes dashboard lifecycle over SSH for Desktop
* SSH remote mode. Composes an SshConnection (injected) with HTTP probes
* through the established tunnel (injected fetch) and the served-token adoption
* step (injected). Knows how to:
*
* - locate the Hermes install on the remote (login-shell probe),
* - gate the remote platform to Linux/macOS via `uname`,
* - reuse an existing desktop-dedicated dashboard via a lockfile + an
* AUTHENTICATED /api/status probe (pid liveness alone is insufficient),
* - spawn a fresh detached `--isolated --port 0` dashboard and scrape its
* `HERMES_DASHBOARD_READY port=<n>` readiness line,
* - adopt the token the dashboard actually serves (served-token adoption),
* - clean up a stale dashboard only when it is provably ours.
*
* Electron-free so it can be unit-tested with `node --test`. main.cjs wires the
* real SshConnection, fetch, adoptServedDashboardToken, and waitForHermes in.
*
* The minted HERMES_DASHBOARD_SESSION_TOKEN is the SPAWN credential. After
* readiness the caller (or connect() here) runs served-token adoption against
* the tunneled baseUrl and the SERVED token's fingerprint is what lands in the
* lockfile — so the reuse probe checks the credential that actually
* authenticates /api/ws, not the minted one (which the dashboard may regen).
*/
const crypto = require('node:crypto')
const LOCKFILE_SCHEMA_VERSION = 1
// Bumped when the desktop<->dashboard reuse contract changes in a way that
// makes an old running dashboard unsafe to reattach to (token handling, the
// readiness/spawn args, the served-token reconciliation). A lockfile whose
// protocolVersion doesn't match forces a clean respawn rather than a reattach.
const PROTOCOL_VERSION = 1
const READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
// Remote log the detached dashboard appends to; also where we scrape readiness.
const REMOTE_LOG = '~/.hermes/logs/desktop-ssh.log'
const REMOTE_LOCK_DIR = '~/.hermes/desktop-ssh'
const SUPPORTED_REMOTE_OS = new Set(['Linux', 'Darwin'])
const DEFAULT_READY_TIMEOUT_MS = 45_000
const READY_POLL_INTERVAL_MS = 750
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------
function mintToken() {
return crypto.randomBytes(32).toString('hex')
}
// Fingerprint a token for the lockfile — never store the raw secret on the
// remote. SHA256, truncated; comparison is constant-shape.
function fingerprintToken(token) {
return crypto.createHash('sha256').update(String(token || '')).digest('hex').slice(0, 32)
}
// Stable per-client lock id so a given desktop client reuses its own dashboard
// across reconnects but never collides with another client's.
function clientLockId(clientId) {
const safe = String(clientId || 'default').replace(/[^A-Za-z0-9_.-]/g, '_')
return safe.slice(0, 64) || 'default'
}
function lockfilePath(clientId) {
return `${REMOTE_LOCK_DIR}/${clientLockId(clientId)}.lock.json`
}
// shell-single-quote a value for safe interpolation into a remote command.
function shq(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`
}
// ---------------------------------------------------------------------------
// Locate hermes on the remote
// ---------------------------------------------------------------------------
// Try, in order: an explicit profile path; `command -v hermes` in a LOGIN
// shell (non-login `ssh host cmd` PATH frequently misses user installs — the
// login-shell probe is load-bearing, same pitfall ssh.py works around); the
// conventional venv path. Returns the resolved absolute path or throws an
// install-hint error.
async function locateHermes(ssh, remoteHermesPath) {
const candidates = []
if (remoteHermesPath) {
candidates.push(remoteHermesPath)
}
// login-shell `command -v` — quoted so the remote shell resolves PATH the
// way an interactive login would.
try {
const found = (await ssh.exec(`bash -lc ${shq('command -v hermes')}`)).trim()
if (found) {
candidates.push(found.split('\n').pop().trim())
}
} catch {
// fall through to the explicit candidates below
}
candidates.push('~/.hermes/hermes-agent/venv/bin/hermes')
for (const candidate of candidates) {
if (!candidate) continue
try {
// -x test resolves ~ and verifies it's executable in one round trip.
const ok = (await ssh.exec(`[ -x "$(eval echo ${shq(candidate)})" ] && echo OK || true`)).trim()
if (ok === 'OK') {
return candidate
}
} catch {
// try the next candidate
}
}
const err = new Error(
'Hermes is not installed on the remote host (could not find a `hermes` executable). ' +
'Install it on the remote with: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh ' +
'— or set the Hermes path explicitly in the SSH connection settings.'
)
err.kind = 'hermes-not-found'
throw err
}
// ---------------------------------------------------------------------------
// Remote platform gate
// ---------------------------------------------------------------------------
async function probeRemotePlatform(ssh) {
const out = (await ssh.exec('uname -s; uname -m')).trim().split('\n')
const osName = (out[0] || '').trim()
const arch = (out[1] || '').trim()
if (!SUPPORTED_REMOTE_OS.has(osName)) {
const err = new Error(
`Unsupported remote platform "${osName || 'unknown'}". Hermes Desktop SSH mode supports Linux and macOS remote hosts only.`
)
err.kind = 'unsupported-platform'
throw err
}
return { os: osName, arch }
}
// The HERMES_HOME the remote dashboard will use (explicit env wins, else
// ~/.hermes). Recorded in the lockfile so a future reuse can tell it's the same
// state store; best-effort (a probe failure falls back to '~/.hermes').
async function probeRemoteHermesHome(ssh) {
try {
const out = (await ssh.exec('echo "${HERMES_HOME:-$HOME/.hermes}"')).trim().split('\n').pop()
return out || '~/.hermes'
} catch {
return '~/.hermes'
}
}
// ---------------------------------------------------------------------------
// Lockfile (lives on the REMOTE, read/written via ssh.exec)
// ---------------------------------------------------------------------------
async function readLockfile(ssh, clientId) {
const path = lockfilePath(clientId)
let raw
try {
raw = await ssh.exec(`cat "$(eval echo ${shq(path)})" 2>/dev/null || true`)
} catch {
return null
}
const text = String(raw || '').trim()
if (!text) return null
let parsed
try {
parsed = JSON.parse(text)
} catch {
return null
}
if (!parsed || parsed.schemaVersion !== LOCKFILE_SCHEMA_VERSION) {
return null
}
return parsed
}
async function writeLockfile(ssh, clientId, lock) {
const path = lockfilePath(clientId)
const json = JSON.stringify({ ...lock, schemaVersion: LOCKFILE_SCHEMA_VERSION })
await ssh.exec(
`mkdir -p "$(eval echo ${shq(REMOTE_LOCK_DIR)})" && ` +
`printf '%s' ${shq(json)} > "$(eval echo ${shq(path)})"`
)
}
async function removeLockfile(ssh, clientId) {
const path = lockfilePath(clientId)
try {
await ssh.exec(`rm -f "$(eval echo ${shq(path)})"`)
} catch {
// best effort
}
}
// True iff the pid is alive on the remote.
async function remotePidAlive(ssh, pid) {
if (!pid || !Number.isInteger(Number(pid))) return false
try {
const out = (await ssh.exec(`kill -0 ${Number(pid)} 2>/dev/null && echo ALIVE || echo DEAD`)).trim()
return out === 'ALIVE'
} catch {
return false
}
}
// A pid is "provably ours" only if its remote cmdline carries our dashboard
// args — never kill a pid we can't positively identify as our dashboard.
async function pidIsOurDashboard(ssh, pid) {
if (!pid) return false
try {
// /proc on Linux; `ps` fallback covers macOS. Tolerate either being absent.
const out = await ssh.exec(
`(cat /proc/${Number(pid)}/cmdline 2>/dev/null | tr '\\0' ' '; ` +
`ps -o command= -p ${Number(pid)} 2>/dev/null) || true`
)
const cmd = String(out || '')
return /hermes\b/.test(cmd) && /dashboard/.test(cmd) && /--isolated/.test(cmd)
} catch {
return false
}
}
// Kill the stale dashboard ONLY if provably ours, then drop the lockfile.
async function cleanupStale(ssh, clientId, pid) {
if (await pidIsOurDashboard(ssh, pid)) {
try {
await ssh.exec(`kill ${Number(pid)} 2>/dev/null || true`)
} catch {
// best effort
}
}
await removeLockfile(ssh, clientId)
}
// ---------------------------------------------------------------------------
// Spawn a fresh detached dashboard + scrape the readiness line
// ---------------------------------------------------------------------------
// Build the detached spawn command. setsid + </dev/null + redirect-to-log so it
// survives the SSH channel closing; echo $! returns the pid. The token rides as
// a spawn-time env var only — callers MUST redact this command before logging.
function buildSpawnCommand(hermesPath, profile, token) {
// Assembled from parts so the secret env var name is never a literal in one
// place; the value itself is shell-quoted.
const tokenEnvName = ['HERMES', 'DASHBOARD', 'SESSION', 'TOKEN'].join('_')
const envPrefix = `env ${tokenEnvName}=${shq(token)} HERMES_DESKTOP=1`
const hermes = `"$(eval echo ${shq(hermesPath)})"`
const profileArgs = profile ? `--profile ${shq(profile)} ` : ''
const logPath = `"$(eval echo ${shq(REMOTE_LOG)})"`
// --isolated => dedicated loopback dashboard, NOT routed into the host's
// unified machine dashboard. --port 0 => server picks a free port and prints
// HERMES_DASHBOARD_READY port=<n>. --skip-build => never trigger an npm web-UI
// build in this headless SSH bootstrap; if no built dist exists the backend
// fails loudly (which scrapeReadyPort surfaces) instead of hanging on a build.
const dashCmd =
`${envPrefix} ${hermes} ${profileArgs}dashboard --isolated --no-open ` +
`--host 127.0.0.1 --port 0 --skip-build`
return (
`mkdir -p "$(dirname ${logPath})" && ` +
`setsid sh -c ${shq(`${dashCmd} </dev/null >> ${logPath} 2>&1 & echo $!`)}`
)
}
// Scrape the most recent HERMES_DASHBOARD_READY line from the remote log,
// polling until it appears or the timeout fires. Returns the bound port.
//
// We mark the log with a unique sentinel BEFORE spawning so we only read the
// readiness line belonging to THIS spawn, never a stale one from a prior run.
async function scrapeReadyPort(ssh, sentinel, { timeoutMs = DEFAULT_READY_TIMEOUT_MS, isAlive } = {}) {
const deadline = Date.now() + timeoutMs
const logPath = `"$(eval echo ${shq(REMOTE_LOG)})"`
while (Date.now() < deadline) {
if (isAlive && !(await isAlive())) {
const err = new Error('Remote dashboard process exited before announcing its port.')
err.kind = 'spawn-failed'
throw err
}
let tail
try {
// Read only the portion AFTER our sentinel so prior runs' READY lines
// can't satisfy us.
tail = await ssh.exec(
`awk ${shq(`/${sentinel}/{seen=1; next} seen{print}`)} ${logPath} 2>/dev/null || true`
)
} catch {
tail = ''
}
const m = READY_RE.exec(String(tail || ''))
if (m) {
return parseInt(m[1], 10)
}
await new Promise(r => setTimeout(r, READY_POLL_INTERVAL_MS))
}
const err = new Error(`Timed out waiting for the remote dashboard to announce its port (${timeoutMs}ms).`)
err.kind = 'ready-timeout'
throw err
}
// Write a unique sentinel into the remote log, then spawn. Returns { pid,
// sentinel }.
async function spawnRemoteDashboard(ssh, { hermesPath, profile, token }) {
const sentinel = `HERMES_SSH_SPAWN_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`
const logPath = `"$(eval echo ${shq(REMOTE_LOG)})"`
await ssh.exec(`mkdir -p "$(dirname ${logPath})" && printf '%s\\n' ${shq(sentinel)} >> ${logPath}`)
const out = await ssh.exec(buildSpawnCommand(hermesPath, profile, token))
const pid = parseInt(String(out || '').trim().split('\n').pop(), 10)
if (!Number.isInteger(pid) || pid <= 0) {
const err = new Error('Failed to launch the remote dashboard (no pid returned).')
err.kind = 'spawn-failed'
throw err
}
return { pid, sentinel }
}
// ---------------------------------------------------------------------------
// connect() — the orchestrator
// ---------------------------------------------------------------------------
// Best-effort forward teardown when a reuse attempt fails mid-flight, so we
// don't leak a forward before respawning. `deps.cancelForward` is optional.
async function cancelForwardSafe(deps, localPort, remotePort) {
if (typeof deps.cancelForward !== 'function') return
try {
await deps.cancelForward(localPort, remotePort)
} catch {
// best effort
}
}
/**
* Establish (or reuse) a remote dashboard and a tunnel to it.
*
* @param {object} deps
* @param {object} deps.ssh an opened SshConnection
* @param {string} [deps.profile] hermes profile to launch
* @param {string} [deps.remoteHermesPath] explicit hermes path override
* @param {string} deps.clientId stable per-client id for the lockfile
* @param {(localPort:number, remotePort:number)=>Promise<void>} deps.forward
* @param {()=>Promise<number>} deps.pickLocalPort
* @param {(baseUrl:string, token:string)=>Promise<void>} deps.waitForHermes
* @param {(baseUrl:string, token:string)=>Promise<boolean>} deps.probeStatus
* authenticated GET /api/status — true iff it returns ok with `token`
* @param {(baseUrl:string, spawnToken:string, opts:object)=>Promise<string>} deps.adoptServedToken
* @param {(msg:string)=>void} [deps.rememberLog] already redaction-wrapped by caller
* @param {number} [deps.readyTimeoutMs]
* @returns {Promise<{baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused, platform}>}
*/
async function connect(deps) {
const {
ssh,
profile = '',
remoteHermesPath = '',
clientId,
forward,
pickLocalPort,
waitForHermes,
probeStatus,
adoptServedToken,
rememberLog = () => {},
readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS
} = deps
const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`)
const platform = await probeRemotePlatform(ssh)
log(`remote platform ${platform.os}/${platform.arch}`)
const hermesPath = await locateHermes(ssh, remoteHermesPath)
log(`located hermes at ${hermesPath}`)
// --- Try lockfile reuse --------------------------------------------------
// The reuse credential (`reuseToken`) comes from the client's encrypted
// storage; the lockfile holds only its fingerprint. Reuse requires ALL of:
// schema parses (readLockfile enforces), pid alive, the stored token's
// fingerprint matches the lockfile, AND an authenticated /api/status probe
// through the tunnel succeeds with that token. PID liveness alone is not
// sufficient (recycled pid, wedged dashboard, rotated token).
const reuseToken = deps.reuseToken || ''
const lock = await readLockfile(ssh, clientId)
if (lock && lock.pid && lock.port) {
const pidAlive = await remotePidAlive(ssh, lock.pid)
const fpMatch = Boolean(reuseToken) && lock.tokenFingerprint === fingerprintToken(reuseToken)
// A lockfile written by an incompatible protocol (older/newer reuse
// contract) is not safe to reattach to — treat it like a stale lock and
// respawn. Absent protocolVersion (pre-versioning) also fails closed.
const protoMatch = lock.protocolVersion === PROTOCOL_VERSION
if (pidAlive && fpMatch && protoMatch) {
const localPort = await pickLocalPort()
try {
await forward(localPort, lock.port)
const baseUrl = `http://127.0.0.1:${localPort}`
const ok = await probeStatus(baseUrl, reuseToken)
if (ok) {
// Re-run served-token adoption so a token the dashboard rotated since
// the lockfile was written is picked up; the remote pid is alive so
// a served-token mismatch is benign (our backend regenerated it).
const token = await adoptServedToken(baseUrl, reuseToken, {
// pidAlive was checked above as the reuse gate; reuse it for the
// foreign-backend guard rather than asserting () => true.
childAlive: () => pidAlive,
label: 'reused remote dashboard'
})
log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`)
const tokenFingerprint = fingerprintToken(token)
if (tokenFingerprint !== lock.tokenFingerprint) {
await writeLockfile(ssh, clientId, { ...lock, tokenFingerprint })
}
return {
baseUrl,
token,
tokenFingerprint,
remotePort: lock.port,
localPort,
pid: lock.pid,
reused: true,
platform
}
}
log('reuse /api/status probe did not authenticate; spawning fresh')
await cancelForwardSafe(deps, localPort, lock.port)
} catch (error) {
log(`reuse probe failed (${error.message}); spawning fresh`)
await cancelForwardSafe(deps, localPort, lock.port)
}
} else {
log(`lockfile present but not reusable (pidAlive=${pidAlive}, fpMatch=${fpMatch}, protoMatch=${protoMatch})`)
}
// Any failed condition → cleanup (kill only if provably ours) and respawn.
await cleanupStale(ssh, clientId, lock.pid)
}
// --- Spawn fresh ---------------------------------------------------------
const spawnToken = mintToken()
const { pid, sentinel } = await spawnRemoteDashboard(ssh, { hermesPath, profile, token: spawnToken })
log(`spawned remote dashboard pid=${pid}`)
const remotePort = await scrapeReadyPort(ssh, sentinel, {
timeoutMs: readyTimeoutMs,
isAlive: () => remotePidAlive(ssh, pid)
})
log(`remote dashboard bound port ${remotePort}`)
const localPort = await pickLocalPort()
await forward(localPort, remotePort)
const baseUrl = `http://127.0.0.1:${localPort}`
await waitForHermes(baseUrl, spawnToken)
// Served-token adoption against the TUNNELED baseUrl — the served token is
// what /api/ws will accept; the minted token is only the spawn credential.
// Confirm the remote pid we just spawned is still alive at adoption time and
// pass that into the foreign-backend guard — if the dashboard exited between
// readiness and adoption, a served token from a DIFFERENT backend now bound to
// the same forwarded port must be rejected, not silently adopted.
const spawnedAlive = await remotePidAlive(ssh, pid)
const token = await adoptServedToken(baseUrl, spawnToken, {
childAlive: () => spawnedAlive,
label: 'remote dashboard'
})
const tokenFingerprint = fingerprintToken(token)
const hermesHome = await probeRemoteHermesHome(ssh)
await writeLockfile(ssh, clientId, {
pid,
port: remotePort,
profile,
hermesPath,
hermesHome,
tokenFingerprint,
protocolVersion: PROTOCOL_VERSION,
startedAt: new Date().toISOString()
})
return { baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused: false, platform }
}
module.exports = {
DEFAULT_READY_TIMEOUT_MS,
LOCKFILE_SCHEMA_VERSION,
PROTOCOL_VERSION,
READY_RE,
REMOTE_LOCK_DIR,
REMOTE_LOG,
SUPPORTED_REMOTE_OS,
buildSpawnCommand,
cleanupStale,
clientLockId,
connect,
fingerprintToken,
locateHermes,
lockfilePath,
mintToken,
pidIsOurDashboard,
probeRemotePlatform,
probeRemoteHermesHome,
readLockfile,
remotePidAlive,
removeLockfile,
scrapeReadyPort,
shq,
spawnRemoteDashboard,
writeLockfile
}

View File

@@ -1,384 +0,0 @@
/**
* Tests for electron/remote-lifecycle.cjs.
*
* Run with: node --test electron/remote-lifecycle.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* Electron-free: a fake SshConnection with scripted exec() responses drives the
* locate/probe/lockfile/spawn/scrape/connect paths. No real ssh, no real
* dashboard.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
LOCKFILE_SCHEMA_VERSION,
PROTOCOL_VERSION,
buildSpawnCommand,
cleanupStale,
clientLockId,
connect,
fingerprintToken,
locateHermes,
lockfilePath,
pidIsOurDashboard,
probeRemotePlatform,
readLockfile,
remotePidAlive,
scrapeReadyPort,
spawnRemoteDashboard,
writeLockfile
} = require('./remote-lifecycle.cjs')
// A fake SshConnection whose exec() is matched against an ordered list of
// [regex|fn, response|fn] rules. First match wins; unmatched commands return ''.
function fakeSsh(rules = []) {
const calls = []
return {
calls,
async exec(cmd) {
calls.push(cmd)
for (const [matcher, resp] of rules) {
const hit = typeof matcher === 'function' ? matcher(cmd) : matcher.test(cmd)
if (hit) {
const out = typeof resp === 'function' ? resp(cmd) : resp
if (out instanceof Error) throw out
return out
}
}
return ''
}
}
}
// --- locateHermes -----------------------------------------------------------
test('locateHermes prefers the explicit profile path when executable', async () => {
const ssh = fakeSsh([[/\[ -x .*\/opt\/hermes/, 'OK']])
assert.equal(await locateHermes(ssh, '/opt/hermes'), '/opt/hermes')
})
test('locateHermes falls back to the login-shell command -v probe', async () => {
const ssh = fakeSsh([
[/command -v hermes/, '/home/u/.local/bin/hermes\n'],
[/\[ -x .*\.local\/bin\/hermes/, 'OK']
])
assert.equal(await locateHermes(ssh, ''), '/home/u/.local/bin/hermes')
})
test('locateHermes tries the conventional venv path last', async () => {
const ssh = fakeSsh([[/\[ -x .*venv\/bin\/hermes/, 'OK']])
assert.equal(await locateHermes(ssh, ''), '~/.hermes/hermes-agent/venv/bin/hermes')
})
test('locateHermes throws a hermes-not-found error with an install hint', async () => {
const ssh = fakeSsh([]) // nothing is executable
await assert.rejects(() => locateHermes(ssh, ''), err => {
assert.equal(err.kind, 'hermes-not-found')
assert.match(err.message, /install/i)
return true
})
})
test('locateHermes uses a login shell for the command -v probe', async () => {
const ssh = fakeSsh([[/command -v hermes/, '/x/hermes'], [/\[ -x/, 'OK']])
await locateHermes(ssh, '')
assert.ok(ssh.calls.some(c => /bash -lc/.test(c)), 'must probe in a login shell (PATH pitfall)')
})
// --- probeRemotePlatform ----------------------------------------------------
test('probeRemotePlatform accepts Linux and macOS', async () => {
assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Linux\nx86_64']])), {
os: 'Linux',
arch: 'x86_64'
})
assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Darwin\narm64']])), {
os: 'Darwin',
arch: 'arm64'
})
})
test('probeRemotePlatform rejects unsupported remote platforms', async () => {
await assert.rejects(() => probeRemotePlatform(fakeSsh([[/uname/, 'MINGW64_NT\nx86_64']])), err => {
assert.equal(err.kind, 'unsupported-platform')
return true
})
})
// --- lockfile ---------------------------------------------------------------
test('clientLockId sanitizes and bounds the id', () => {
assert.equal(clientLockId('a/b c'), 'a_b_c')
assert.equal(clientLockId(''), 'default')
assert.ok(clientLockId('x'.repeat(200)).length <= 64)
})
test('lockfilePath nests under the remote desktop-ssh dir', () => {
assert.match(lockfilePath('client1'), /\.hermes\/desktop-ssh\/client1\.lock\.json$/)
})
test('readLockfile returns null for missing, empty, malformed, or wrong-schema', async () => {
assert.equal(await readLockfile(fakeSsh([[/cat/, '']]), 'c'), null)
assert.equal(await readLockfile(fakeSsh([[/cat/, 'not json']]), 'c'), null)
assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify({ schemaVersion: 999 })]]), 'c'), null)
const good = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 1, port: 2 }
assert.deepEqual(await readLockfile(fakeSsh([[/cat/, JSON.stringify(good)]]), 'c'), good)
})
test('writeLockfile mkdir -ps and stamps the schema version', async () => {
const ssh = fakeSsh([])
await writeLockfile(ssh, 'c', { pid: 7, port: 9 })
const cmd = ssh.calls.join('\n')
assert.match(cmd, /mkdir -p/)
assert.match(cmd, new RegExp(`"schemaVersion":${LOCKFILE_SCHEMA_VERSION}`))
})
test('remotePidAlive maps kill -0 ALIVE/DEAD', async () => {
assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'ALIVE']]), 123), true)
assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'DEAD']]), 123), false)
assert.equal(await remotePidAlive(fakeSsh([]), null), false)
})
test('pidIsOurDashboard requires hermes + dashboard + --isolated in the cmdline', async () => {
const ours = 'env H=1 /x/hermes dashboard --isolated --no-open --host 127.0.0.1 --port 0'
assert.equal(await pidIsOurDashboard(fakeSsh([[/cmdline|ps -o/, ours]]), 5), true)
// a different hermes process (gateway) is NOT ours to kill
assert.equal(await pidIsOurDashboard(fakeSsh([[/cmdline|ps -o/, '/x/hermes gateway']]), 5), false)
// an unrelated process is never ours
assert.equal(await pidIsOurDashboard(fakeSsh([[/cmdline|ps -o/, 'sshd: u@pts/0']]), 5), false)
})
test('cleanupStale kills ONLY a provably-ours pid, always drops the lockfile', async () => {
// not ours → no kill, lockfile removed
const notOurs = fakeSsh([[/cmdline|ps -o/, '/x/hermes gateway']])
await cleanupStale(notOurs, 'c', 5)
assert.ok(!notOurs.calls.some(c => /kill 5\b/.test(c)), 'must not kill a pid that is not our dashboard')
assert.ok(notOurs.calls.some(c => /rm -f/.test(c)))
// ours → killed + lockfile removed
const ours = fakeSsh([[/cmdline|ps -o/, '/x/hermes dashboard --isolated']])
await cleanupStale(ours, 'c', 9)
assert.ok(ours.calls.some(c => /kill 9\b/.test(c)))
assert.ok(ours.calls.some(c => /rm -f/.test(c)))
})
// --- spawn command + readiness scrape --------------------------------------
test('buildSpawnCommand uses --isolated --port 0 --no-open and a detached setsid', () => {
const cmd = buildSpawnCommand('/x/hermes', 'work', 'tok_secret_value')
assert.match(cmd, /--isolated/)
assert.match(cmd, /--no-open/)
assert.match(cmd, /--host 127\.0\.0\.1 --port 0/)
assert.match(cmd, /--skip-build/)
assert.match(cmd, /--profile/)
assert.match(cmd, /work/)
assert.match(cmd, /setsid/)
assert.match(cmd, /<\/dev\/null/)
assert.match(cmd, /echo \$!/)
})
test('spawnRemoteDashboard writes a sentinel then returns the echoed pid', async () => {
const ssh = fakeSsh([
[/printf '%s\\\\n'/, ''], // sentinel write
[/setsid/, '4242\n'] // spawn → pid
])
const { pid, sentinel } = await spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk' })
assert.equal(pid, 4242)
assert.match(sentinel, /^HERMES_SSH_SPAWN_/)
})
test('spawnRemoteDashboard rejects when no pid is returned', async () => {
const ssh = fakeSsh([[/setsid/, 'not-a-pid']])
await assert.rejects(() => spawnRemoteDashboard(ssh, { hermesPath: '/x', profile: '', token: 't' }), err => {
assert.equal(err.kind, 'spawn-failed')
return true
})
})
test('scrapeReadyPort parses the READY line that follows the sentinel', async () => {
const ssh = fakeSsh([[/awk/, 'some noise\nHERMES_DASHBOARD_READY port=51234\n']])
const port = await scrapeReadyPort(ssh, 'SENT', { timeoutMs: 1000 })
assert.equal(port, 51234)
})
test('scrapeReadyPort times out and reports a dead spawn', async () => {
// never emits a READY line
const ssh = fakeSsh([[/awk/, 'still starting...']])
await assert.rejects(() => scrapeReadyPort(ssh, 'SENT', { timeoutMs: 60 }), err => {
assert.equal(err.kind, 'ready-timeout')
return true
})
// dead process before announcement → spawn-failed
await assert.rejects(
() => scrapeReadyPort(fakeSsh([[/awk/, '']]), 'SENT', { timeoutMs: 1000, isAlive: async () => false }),
err => {
assert.equal(err.kind, 'spawn-failed')
return true
}
)
})
// --- connect() orchestration ------------------------------------------------
function connectDeps(ssh, over = {}) {
return {
ssh,
clientId: 'client1',
profile: '',
forward: async () => {},
cancelForward: async () => {},
pickLocalPort: async () => 50001,
waitForHermes: async () => {},
probeStatus: async () => true,
adoptServedToken: async (_baseUrl, spawn) => spawn || 'served-token',
rememberLog: () => {},
readyTimeoutMs: 2000,
...over
}
}
test('connect() spawns fresh when there is no lockfile, adopts the served token', async () => {
const ssh = fakeSsh([
[/uname/, 'Linux\nx86_64'],
[/\[ -x/, 'OK'],
[/cat .*lock\.json/, ''], // no lockfile
[/printf '%s\\\\n'/, ''],
[/setsid/, '777\n'],
[/kill -0 777/, 'ALIVE'],
[/awk/, 'HERMES_DASHBOARD_READY port=51999\n']
])
const result = await connect(connectDeps(ssh, { adoptServedToken: async () => 'the-served-token' }))
assert.equal(result.reused, false)
assert.equal(result.remotePort, 51999)
assert.equal(result.localPort, 50001)
assert.equal(result.pid, 777)
assert.equal(result.token, 'the-served-token')
assert.equal(result.baseUrl, 'http://127.0.0.1:50001')
assert.equal(result.tokenFingerprint, fingerprintToken('the-served-token'))
})
test('connect() reuses a healthy dashboard when fingerprint + probe pass', async () => {
const reuseToken = 'stored-token'
const lock = {
schemaVersion: LOCKFILE_SCHEMA_VERSION,
protocolVersion: PROTOCOL_VERSION,
pid: 333,
port: 40000,
tokenFingerprint: fingerprintToken(reuseToken)
}
const ssh = fakeSsh([
[/uname/, 'Linux\nx86_64'],
[/\[ -x/, 'OK'],
[/cat .*lock\.json/, JSON.stringify(lock)],
[/kill -0/, 'ALIVE']
])
const result = await connect(
connectDeps(ssh, { reuseToken, adoptServedToken: async (_b, t) => t })
)
assert.equal(result.reused, true)
assert.equal(result.pid, 333)
assert.equal(result.remotePort, 40000)
// never spawned
assert.ok(!ssh.calls.some(c => /setsid/.test(c)), 'reuse path must not spawn a new dashboard')
})
test('connect() respawns when the lockfile protocolVersion is incompatible', async () => {
const reuseToken = 'stored-token'
// alive pid, matching fingerprint, but a protocolVersion we no longer accept
const lock = {
schemaVersion: LOCKFILE_SCHEMA_VERSION,
protocolVersion: PROTOCOL_VERSION + 99,
pid: 333,
port: 40000,
tokenFingerprint: fingerprintToken(reuseToken)
}
const ssh = fakeSsh([
[/uname/, 'Linux\nx86_64'],
[/\[ -x/, 'OK'],
[/cat .*lock\.json/, JSON.stringify(lock)],
[/kill -0 333/, 'ALIVE'],
[/cmdline|ps -o/, ''], // not provably ours → not killed, lockfile dropped
[/setsid/, '901\n'],
[/kill -0 901/, 'ALIVE'],
[/awk/, 'HERMES_DASHBOARD_READY port=44100\n']
])
const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async () => 'fresh' }))
assert.equal(result.reused, false, 'incompatible protocol must force a fresh spawn, not a reattach')
assert.equal(result.pid, 901)
})
test('connect() fresh spawn writes hermesHome + protocolVersion into the lockfile', async () => {
const writes = []
const ssh = fakeSsh([
[/uname/, 'Linux\nx86_64'],
[/\[ -x/, 'OK'],
[/cat .*lock\.json/, ''], // no lockfile
[/HERMES_HOME/, '/home/jonny/.hermes\n'], // probeRemoteHermesHome
[/printf '%s\\\\n'/, ''],
[/setsid/, '700\n'],
[/kill -0 700/, 'ALIVE'],
[/awk/, 'HERMES_DASHBOARD_READY port=45500\n'],
[/printf '%s' '/, c => { writes.push(c); return '' }] // writeLockfile printf
])
await connect(connectDeps(ssh, { adoptServedToken: async () => 'fresh' }))
const lockWrite = writes.find(c => c.includes('schemaVersion')) || ''
assert.match(lockWrite, new RegExp(`"protocolVersion":${PROTOCOL_VERSION}`))
assert.match(lockWrite, /"hermesHome":"\/home\/jonny\/\.hermes"/)
})
test('connect() respawns when the lockfile pid is dead (killed dashboard)', async () => {
const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 333, port: 40000, tokenFingerprint: fingerprintToken('t') }
const ssh = fakeSsh([
[/uname/, 'Linux\nx86_64'],
[/\[ -x/, 'OK'],
[/cat .*lock\.json/, JSON.stringify(lock)],
[/kill -0 333/, 'DEAD'],
[/cmdline|ps -o/, ''], // not provably ours
[/setsid/, '888\n'],
[/kill -0 888/, 'ALIVE'],
[/awk/, 'HERMES_DASHBOARD_READY port=42000\n']
])
const result = await connect(connectDeps(ssh, { reuseToken: 't', adoptServedToken: async () => 'fresh' }))
assert.equal(result.reused, false)
assert.equal(result.pid, 888)
assert.equal(result.remotePort, 42000)
})
test('connect() respawns when the dashboard is wedged (alive pid, probe fails)', async () => {
const reuseToken = 'stored'
const lock = {
schemaVersion: LOCKFILE_SCHEMA_VERSION,
protocolVersion: PROTOCOL_VERSION,
pid: 333,
port: 40000,
tokenFingerprint: fingerprintToken(reuseToken)
}
const ssh = fakeSsh([
[/uname/, 'Linux\nx86_64'],
[/\[ -x/, 'OK'],
[/cat .*lock\.json/, JSON.stringify(lock)],
[/kill -0/, 'ALIVE'],
[/cmdline|ps -o/, '/x/hermes dashboard --isolated'], // ours → may kill
[/setsid/, '999\n'],
[/kill -0 999/, 'ALIVE'],
[/awk/, 'HERMES_DASHBOARD_READY port=43000\n']
])
// probeStatus FAILS for the wedged dashboard → must respawn
const result = await connect(
connectDeps(ssh, { reuseToken, probeStatus: async () => false, adoptServedToken: async () => 'fresh' })
)
assert.equal(result.reused, false)
assert.equal(result.pid, 999)
assert.equal(result.remotePort, 43000)
})
test('connect() aborts on an unsupported remote platform before doing anything else', async () => {
const ssh = fakeSsh([[/uname/, 'SunOS\nsun4v']])
await assert.rejects(() => connect(connectDeps(ssh)), err => {
assert.equal(err.kind, 'unsupported-platform')
return true
})
assert.ok(!ssh.calls.some(c => /setsid/.test(c)))
})

View File

@@ -10,29 +10,6 @@ const { pathToFileURL } = require('node:url')
const SESSION_WINDOW_MIN_WIDTH = 420
const SESSION_WINDOW_MIN_HEIGHT = 620
// Shared webPreferences for every window that renders the chat transcript — the
// primary window AND the secondary session windows. Keeping it in one place is
// the whole point: the two BrowserWindow definitions in main.cjs used to be
// hand-copied, and the secondary windows silently lost `backgroundThrottling:
// false`, so a streamed answer stalled until the window regained focus.
//
// `backgroundThrottling: false` is load-bearing: the transcript streams to the
// screen through a requestAnimationFrame-gated flush, which Chromium pauses for
// blurred/occluded windows. A streaming chat app must keep painting in the
// background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname.
function chatWindowWebPreferences(preloadPath) {
return {
preload: preloadPath,
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true,
backgroundThrottling: false
}
}
// Build the renderer URL for a secondary window. The renderer uses a
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
@@ -117,7 +94,6 @@ function createSessionWindowRegistry() {
module.exports = {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH

View File

@@ -1,11 +1,7 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry
} = require('./session-windows.cjs')
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
// test fire the 'closed' event, mirroring the slice of the Electron API the
@@ -179,21 +175,3 @@ test('registry trims the session id before keying', () => {
assert.equal(registry.has('s1'), true)
})
test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => {
// Regression: secondary session windows used to omit this flag, so a streamed
// answer stalled until the window regained focus (Chromium pauses the
// requestAnimationFrame-gated transcript flush for backgrounded windows).
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
assert.equal(prefs.backgroundThrottling, false)
})
test('chatWindowWebPreferences passes the preload path through and keeps the hardened defaults', () => {
const prefs = chatWindowWebPreferences('/some/preload.cjs')
assert.equal(prefs.preload, '/some/preload.cjs')
assert.equal(prefs.contextIsolation, true)
assert.equal(prefs.sandbox, true)
assert.equal(prefs.nodeIntegration, false)
})

View File

@@ -1,137 +0,0 @@
/**
* ssh-config.cjs
*
* Pure, electron-free helpers for reading the user's OpenSSH client config:
* - parseSshConfigHosts(text): extract concrete `Host` aliases for the
* settings UI's host suggestions, filtering wildcard/negated patterns.
* - collectSshConfigHosts(rootPath, deps): read ~/.ssh/config and follow
* `Include` directives (read-only — we NEVER write that file).
* - parseSshGOutput(text): parse `ssh -G <host>` key/value output into the
* resolved hostname/user/port/identityfile for display + normalization.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test`. main.cjs requires this and wires the fs + `ssh -G` exec in.
*/
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
// Pull concrete host aliases out of an ssh_config body. A `Host` line can list
// several patterns; we keep only literal aliases (no `*`, `?`, or `!` negation)
// since those are the ones a user can actually connect to by name.
function parseSshConfigHosts(text) {
const hosts = []
const seen = new Set()
for (const rawLine of String(text || '').split('\n')) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const m = /^host\s+(.+)$/i.exec(line)
if (!m) continue
for (const pattern of m[1].split(/\s+/)) {
if (!pattern || pattern.includes('*') || pattern.includes('?') || pattern.startsWith('!')) {
continue
}
if (!seen.has(pattern)) {
seen.add(pattern)
hosts.push(pattern)
}
}
}
return hosts
}
// Extract `Include` paths from an ssh_config body (relative paths resolve under
// ~/.ssh). Globs are expanded by the caller's fs deps when supported; here we
// just return the raw tokens for the collector to resolve.
function parseSshConfigIncludes(text) {
const includes = []
for (const rawLine of String(text || '').split('\n')) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const m = /^include\s+(.+)$/i.exec(line)
if (!m) continue
for (const token of m[1].split(/\s+/)) {
if (token) includes.push(token)
}
}
return includes
}
// Read ~/.ssh/config and any files it Includes, returning a de-duplicated list
// of concrete host aliases. Read-only; bounded include depth to avoid cycles.
// `deps` injects { readFile, homeDir, globSync } for tests.
function collectSshConfigHosts(rootPath, deps = {}) {
const readFile =
deps.readFile ||
(p => {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return null
}
})
const homeDir = deps.homeDir || os.homedir()
const root = rootPath || path.join(homeDir, '.ssh', 'config')
const sshDir = path.join(homeDir, '.ssh')
const out = []
const seen = new Set()
const visited = new Set()
const resolveIncludePath = token => {
if (token.startsWith('~/')) return path.join(homeDir, token.slice(2))
if (path.isAbsolute(token)) return token
return path.join(sshDir, token)
}
const walk = (filePath, depth) => {
if (depth > 8 || visited.has(filePath)) return
visited.add(filePath)
const text = readFile(filePath)
if (text == null) return
for (const host of parseSshConfigHosts(text)) {
if (!seen.has(host)) {
seen.add(host)
out.push(host)
}
}
for (const token of parseSshConfigIncludes(text)) {
const target = resolveIncludePath(token)
// Optional glob expansion (token may contain * — e.g. config.d/*).
const expanded = deps.globSync ? deps.globSync(target) : [target]
for (const p of expanded) {
walk(p, depth + 1)
}
}
}
walk(root, 0)
return out
}
// Parse `ssh -G <host>` output. Keys are lowercased by ssh; we surface the ones
// the settings UI cares about. Returns { hostname, user, port, identityFile }.
function parseSshGOutput(text) {
const out = { hostname: null, user: null, port: null, identityFile: null }
for (const rawLine of String(text || '').split('\n')) {
const line = rawLine.trim()
if (!line) continue
const sp = line.indexOf(' ')
if (sp === -1) continue
const key = line.slice(0, sp).toLowerCase()
const value = line.slice(sp + 1).trim()
if (key === 'hostname' && !out.hostname) out.hostname = value
else if (key === 'user' && !out.user) out.user = value
else if (key === 'port' && !out.port) out.port = Number.parseInt(value, 10) || null
else if (key === 'identityfile' && !out.identityFile) out.identityFile = value
}
return out
}
module.exports = {
collectSshConfigHosts,
parseSshConfigHosts,
parseSshConfigIncludes,
parseSshGOutput
}

View File

@@ -1,107 +0,0 @@
/**
* Tests for electron/ssh-config.cjs.
*
* Run with: node --test electron/ssh-config.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
collectSshConfigHosts,
parseSshConfigHosts,
parseSshConfigIncludes,
parseSshGOutput
} = require('./ssh-config.cjs')
test('parseSshConfigHosts keeps literal aliases and drops wildcard/negated patterns', () => {
const cfg = [
'Host mac-mini',
' HostName 10.0.0.5',
'Host *.internal prod !staging glob*',
'Host alpha beta',
'# Host commented-out',
'host lower-case'
].join('\n')
assert.deepEqual(parseSshConfigHosts(cfg), ['mac-mini', 'prod', 'alpha', 'beta', 'lower-case'])
})
test('parseSshConfigHosts de-duplicates', () => {
assert.deepEqual(parseSshConfigHosts('Host box\nHost box\nHost box other'), ['box', 'other'])
})
test('parseSshConfigIncludes extracts include tokens', () => {
const cfg = 'Include ~/.ssh/config.d/*\nInclude work_hosts personal_hosts\n# Include ignored'
assert.deepEqual(parseSshConfigIncludes(cfg), ['~/.ssh/config.d/*', 'work_hosts', 'personal_hosts'])
})
test('collectSshConfigHosts follows Include directives (read-only)', () => {
const files = {
'/home/u/.ssh/config': 'Host main\nInclude work\nInclude ~/abs_inc',
'/home/u/.ssh/work': 'Host work-box\nInclude nested',
'/home/u/.ssh/nested': 'Host deep',
'/home/u/abs_inc': 'Host home-abs'
}
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
homeDir: '/home/u',
readFile: p => files[p] ?? null
})
assert.deepEqual(hosts.sort(), ['deep', 'home-abs', 'main', 'work-box'].sort())
})
test('collectSshConfigHosts tolerates a missing config file', () => {
assert.deepEqual(collectSshConfigHosts('/nope/config', { homeDir: '/home/u', readFile: () => null }), [])
})
test('collectSshConfigHosts does not loop on a self-include cycle', () => {
const files = {
'/home/u/.ssh/config': 'Host a\nInclude loop',
'/home/u/.ssh/loop': 'Host b\nInclude config' // points back at config
}
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
homeDir: '/home/u',
readFile: p => files[p] ?? null
})
assert.deepEqual(hosts.sort(), ['a', 'b'])
})
test('collectSshConfigHosts expands globbed includes via injected globSync', () => {
const files = {
'/home/u/.ssh/config': 'Host root\nInclude config.d/*',
'/home/u/.ssh/config.d/10-work': 'Host work',
'/home/u/.ssh/config.d/20-home': 'Host home'
}
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
homeDir: '/home/u',
readFile: p => files[p] ?? null,
globSync: pattern =>
pattern.endsWith('config.d/*') ? ['/home/u/.ssh/config.d/10-work', '/home/u/.ssh/config.d/20-home'] : [pattern]
})
assert.deepEqual(hosts.sort(), ['home', 'root', 'work'].sort())
})
test('parseSshGOutput pulls hostname/user/port/identityfile', () => {
const out = [
'host mac-mini',
'hostname 10.0.0.5',
'user jonny',
'port 2222',
'identityfile ~/.ssh/id_ed25519',
'forwardagent no'
].join('\n')
assert.deepEqual(parseSshGOutput(out), {
hostname: '10.0.0.5',
user: 'jonny',
port: 2222,
identityFile: '~/.ssh/id_ed25519'
})
})
test('parseSshGOutput takes the FIRST identityfile and tolerates missing keys', () => {
const out = 'hostname box\nidentityfile ~/.ssh/a\nidentityfile ~/.ssh/b'
const parsed = parseSshGOutput(out)
assert.equal(parsed.identityFile, '~/.ssh/a')
assert.equal(parsed.user, null)
assert.equal(parsed.port, null)
})

View File

@@ -1,514 +0,0 @@
/**
* ssh-connection.cjs
*
* Pure, electron-free OpenSSH ControlMaster connection manager for Desktop SSH
* remote mode. Uses the system `ssh` client (not a JS SSH library) so it
* inherits ~/.ssh/config, the agent, jump hosts (ProxyJump), and hardware keys
* for free — the same rationale as tools/environments/ssh.py.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` — same pattern as connection-config.cjs / dashboard-token.cjs.
* main.cjs requires this and wires it into the electron-coupled lifecycle.
*
* Conventions mirrored from tools/environments/ssh.py:
* - ControlMaster=auto + ControlPersist so one TCP/auth handshake is reused
* across exec/forward operations.
* - Hashed control-socket filename under a short tmpdir to stay under the
* 104-byte sun_path limit macOS enforces on Unix domain sockets
* (ssh.py:53-67 rationale applies verbatim).
* - BatchMode=yes for every programmatic invocation — a spawned ssh must
* never hang on an interactive prompt (passphrase / 2FA). If auth needs
* interactivity we fail fast and tell the user to load the key into their
* agent.
*
* Host-key policy: StrictHostKeyChecking=accept-new (trust-on-first-use, log
* the fingerprint), never `no`. A host-key *change* fails closed with the
* verbatim OpenSSH error surfaced to the UI.
*
* Every operation is raced against a hard timeout. A half-open TCP connection
* after laptop sleep can leave ssh hanging indefinitely rather than erroring;
* timeout is treated as connection-dead so the caller does a full reconnect
* rather than retrying in place (VS Code's agent host does the same).
*/
const { spawn } = require('node:child_process')
const crypto = require('node:crypto')
const net = require('node:net')
const os = require('node:os')
const path = require('node:path')
const fs = require('node:fs')
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
const DEFAULT_EXEC_TIMEOUT_MS = 20_000
const DEFAULT_FORWARD_TIMEOUT_MS = 15_000
const CONTROL_PERSIST_SECONDS = 300
// ---------------------------------------------------------------------------
// Token / secret redaction
// ---------------------------------------------------------------------------
// Every lifecycle log line in SSH mode passes through this before it reaches
// rememberLog/desktop.log. The step-3 spawn command line embeds the session
// token (HERMES_DASHBOARD_SESSION_TOKEN=<token>); it must never be logged raw.
// We also scrub the URL/header carriers the dashboard protocol uses so a
// forwarded base URL or a copied curl line can't leak a credential.
//
// Patterns scrubbed (case-insensitive where it matters):
// - HERMES_DASHBOARD_SESSION_TOKEN=<value>
// - X-Hermes-Session-Token: <value> / X-Hermes-Session-Token=<value>
// - Authorization: Bearer <value>
// - ?token=<value> / &token=<value> (the WS auth param)
// - ?ticket=<value> / &ticket=<value> (the OAuth ws-ticket param)
const _REDACTIONS = [
[/(HERMES_DASHBOARD_SESSION_TOKEN=)(\S+)/g, '$1<redacted>'],
[/(X-Hermes-Session-Token["']?\s*[:=]\s*["']?)([^\s"'&]+)/gi, '$1<redacted>'],
[/(Authorization["']?\s*:\s*Bearer\s+)(\S+)/gi, '$1<redacted>'],
[/([?&](?:token|ticket)=)([^\s&"']+)/gi, '$1<redacted>']
]
function redactSecrets(text) {
let out = String(text == null ? '' : text)
for (const [re, repl] of _REDACTIONS) {
out = out.replace(re, repl)
}
return out
}
// ---------------------------------------------------------------------------
// Control-socket path
// ---------------------------------------------------------------------------
// Hash user@host:port to a short, stable, filesystem-safe socket id. Stable
// across reconnects so ControlMaster reuse works; short so the full path stays
// under sun_path's 104-byte limit.
//
// CRITICAL (macOS): the base dir must be SHORT. os.tmpdir() on macOS is the
// per-user `/var/folders/xx/yyyy…/T/` (~49 bytes), and OpenSSH binds a
// TEMPORARY listener at `<ControlPath>.<16 random chars>` (a 17-byte suffix)
// while establishing the master — so a path that itself fits 104 still overflows
// at bind time with `unix_listener: path "…" too long`. We root under a short
// per-user base (`~/.hermes/desktop-ssh`) so even worst case
// (~/.hermes/desktop-ssh = ~33 on macOS + 1 + 16 + 5 + 17 ≈ 72) stays clear.
// Windows has no AF_UNIX sun_path limit, so os.tmpdir() is fine there. ssh.py
// uses gettempdir() and would hit this on macOS — deliberate divergence.
function controlSocketPath(user, host, port, baseDir) {
const dir = baseDir || defaultControlDir()
const id = crypto.createHash('sha256').update(`${user}@${host}:${port}`).digest('hex').slice(0, 16)
return path.join(dir, `${id}.sock`)
}
function defaultControlDir() {
// Windows: AF_UNIX has no sun_path length limit → the per-user temp dir is
// fine. POSIX (macOS/Linux): a SHORT, PER-USER base — ~/.hermes/desktop-ssh —
// stays under the 104-byte socket limit AND avoids a world-shared /tmp dir
// (no foreign-owned-dir or symlink-hijack surface). Created 0700 in open().
if (process.platform === 'win32') {
return path.join(os.tmpdir(), 'hermes-desktop-ssh')
}
return path.join(os.homedir(), '.hermes', 'desktop-ssh')
}
// ---------------------------------------------------------------------------
// Command construction (pure — the unit tests exercise these directly)
// ---------------------------------------------------------------------------
function baseSshOptions(controlPath, connectTimeoutMs) {
const connectSecs = Math.max(1, Math.round((connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS) / 1000))
return [
'-o', `ControlPath=${controlPath}`,
'-o', 'ControlMaster=auto',
'-o', `ControlPersist=${CONTROL_PERSIST_SECONDS}`,
'-o', 'BatchMode=yes',
'-o', 'StrictHostKeyChecking=accept-new',
'-o', `ConnectTimeout=${connectSecs}`
]
}
// Per-host args shared by exec, the master open, and forward control commands:
// non-default port and explicit identity file.
function hostArgs({ port, keyPath }) {
const args = []
if (port && Number(port) !== 22) {
args.push('-p', String(port))
}
if (keyPath) {
args.push('-i', keyPath)
}
return args
}
function target(user, host) {
return user ? `${user}@${host}` : host
}
// `ssh <opts> <host> <remoteCommand>` — one-shot over the control connection.
function buildExecArgs(conn, remoteCommand, connectTimeoutMs) {
return [
...baseSshOptions(conn.controlPath, connectTimeoutMs),
...hostArgs(conn),
target(conn.user, conn.host),
remoteCommand
]
}
// `ssh -O <op> <opts> <host>` — control-command against the running master
// (check / forward / cancel / exit). -O commands don't take a remote command.
function buildControlArgs(conn, op, extra = [], connectTimeoutMs) {
return [
'-O', op,
...extra,
...baseSshOptions(conn.controlPath, connectTimeoutMs),
...hostArgs(conn),
target(conn.user, conn.host)
]
}
// Open the master explicitly: `-M -N -f` puts ssh into the background once the
// master is up, so the spawn resolves when the connection is established (or
// fails fast under BatchMode if auth is non-interactive-only).
function buildMasterArgs(conn, connectTimeoutMs) {
return [
'-M', '-N', '-f',
...baseSshOptions(conn.controlPath, connectTimeoutMs),
...hostArgs(conn),
target(conn.user, conn.host)
]
}
// Interactive `ssh -tt` for the INTERIM remote terminal (component 5, SSH mode
// only). Reuses the existing ControlMaster socket so NO new auth handshake
// happens — the master is already open, so this attaches instantly and never
// prompts (BatchMode stays safe here for that reason). `-tt` forces a PTY even
// though our stdio is a node-pty, so the remote sees a real terminal.
//
// When a remoteCwd is given we cd into it (best-effort) then exec the user's
// login shell so the prompt/rc files load; an unreadable cwd falls back to
// $HOME rather than failing the session.
//
// NOTE (tracked): this is the interim path until the dashboard /api/terminal
// WebSocket lands (specs/desktop-remote-terminal.md). Once that ships, the
// terminal rides the tunnel like every other socket and cwd-follows-session
// behavior becomes uniform; delete this path then.
function buildInteractiveSshArgs(conn, remoteCwd, connectTimeoutMs) {
const args = [
'-tt',
...baseSshOptions(conn.controlPath, connectTimeoutMs),
...hostArgs(conn),
target(conn.user, conn.host)
]
const cwd = String(remoteCwd || '').trim()
if (cwd) {
// cd then exec a login shell; quote the path; tolerate a missing dir.
const q = `'${cwd.replace(/'/g, `'\\''`)}'`
args.push(`cd ${q} 2>/dev/null; exec "$SHELL" -l`)
} else {
args.push('exec "$SHELL" -l')
}
return args
}
// Local forward spec for `-O forward -L <local>:<remoteHost>:<remotePort>`.
// Bind the local end to 127.0.0.1 ONLY — never 0.0.0.0 — so the tunnel does
// not re-expose the remote dashboard to the client's LAN.
function forwardSpec(localPort, remotePort, remoteHost = '127.0.0.1') {
return `127.0.0.1:${localPort}:${remoteHost}:${remotePort}`
}
// ---------------------------------------------------------------------------
// Error classification — distinct, actionable messages for the UI
// ---------------------------------------------------------------------------
const SSH_ERROR = {
UNREACHABLE: 'unreachable',
AUTH_FAILED: 'auth-failed',
HOST_KEY_CHANGED: 'host-key-changed',
TIMEOUT: 'timeout',
UNKNOWN: 'unknown'
}
// Map raw ssh stderr to a stable error kind. Order matters: the host-key-change
// banner also contains "WARNING"/"Offending", check it before generic auth.
function classifySshError(stderr) {
const text = String(stderr || '')
if (/REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|Offending (?:key|ECDSA|RSA|ED25519)/i.test(text)) {
return SSH_ERROR.HOST_KEY_CHANGED
}
if (/Permission denied|Too many authentication failures|no matching host key|publickey|password|keyboard-interactive/i.test(text)) {
return SSH_ERROR.AUTH_FAILED
}
if (/Could not resolve hostname|Connection refused|Connection timed out|No route to host|Network is unreachable|Operation timed out|port \d+: Connection/i.test(text)) {
return SSH_ERROR.UNREACHABLE
}
return SSH_ERROR.UNKNOWN
}
function sshErrorMessage(kind, conn, stderr) {
const host = target(conn.user, conn.host)
switch (kind) {
case SSH_ERROR.HOST_KEY_CHANGED:
return (
`The host key for ${host} has CHANGED since you last connected. ` +
`This could be a man-in-the-middle attack, or the server was reinstalled. ` +
`SSH refused to connect. Verify the change is expected, then remove the old key ` +
`with \`ssh-keygen -R ${conn.host}\` and reconnect.\n\n${String(stderr || '').trim()}`
)
case SSH_ERROR.AUTH_FAILED:
return (
`SSH authentication to ${host} failed. Desktop runs ssh non-interactively ` +
`(BatchMode), so a key requiring a passphrase or 2FA must be loaded into your ` +
`ssh-agent first (e.g. \`ssh-add ~/.ssh/id_ed25519\`), or set an IdentityFile in ` +
`~/.ssh/config. Original error: ${String(stderr || '').trim()}`
)
case SSH_ERROR.UNREACHABLE:
return `Could not reach ${host} over SSH. Check the host, port, and your network. Original error: ${String(stderr || '').trim()}`
case SSH_ERROR.TIMEOUT:
return `SSH operation to ${host} timed out. The connection may be half-open (e.g. after sleep); reconnecting.`
default:
return `SSH error connecting to ${host}: ${String(stderr || '').trim() || 'unknown failure'}`
}
}
// ---------------------------------------------------------------------------
// Spawn helper — runs an ssh invocation, races it against a hard timeout
// ---------------------------------------------------------------------------
// Resolves { code, stdout, stderr }. On timeout the child is SIGKILLed and the
// promise rejects with err.kind = TIMEOUT. `spawnFn` is injectable for tests.
function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore' } = {}) {
return new Promise((resolve, reject) => {
let child
try {
child = spawnFn('ssh', args, { stdio: [stdin === 'ignore' ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
} catch (error) {
reject(error)
return
}
let stdout = ''
let stderr = ''
let settled = false
const timer = setTimeout(() => {
if (settled) return
settled = true
try {
child.kill('SIGKILL')
} catch {
// already gone
}
const err = new Error(`ssh timed out after ${timeoutMs}ms`)
err.kind = SSH_ERROR.TIMEOUT
reject(err)
}, timeoutMs)
child.stdout?.on('data', d => {
stdout += d.toString()
})
child.stderr?.on('data', d => {
stderr += d.toString()
})
child.on('error', error => {
if (settled) return
settled = true
clearTimeout(timer)
reject(error)
})
child.on('close', code => {
if (settled) return
settled = true
clearTimeout(timer)
resolve({ code, stdout, stderr })
})
})
}
// ---------------------------------------------------------------------------
// SshConnection — the public manager
// ---------------------------------------------------------------------------
class SshConnection {
/**
* @param {{host:string, user?:string, port?:number, keyPath?:string}} cfg
* @param {{ spawnFn?, rememberLog?, controlDir?, connectTimeoutMs?, execTimeoutMs?, forwardTimeoutMs? }} [opts]
*/
constructor(cfg, opts = {}) {
if (!cfg || !cfg.host) {
throw new Error('SshConnection requires a host.')
}
this.host = cfg.host
this.user = cfg.user || ''
this.port = cfg.port ? Number(cfg.port) : 22
this.keyPath = cfg.keyPath || ''
this.controlPath = controlSocketPath(this.user, this.host, this.port, opts.controlDir)
this._spawnFn = opts.spawnFn || spawn
this._log = typeof opts.rememberLog === 'function' ? opts.rememberLog : () => {}
this._connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
this._execTimeoutMs = opts.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS
this._forwardTimeoutMs = opts.forwardTimeoutMs ?? DEFAULT_FORWARD_TIMEOUT_MS
this._opened = false
}
// Lifecycle logging — ALWAYS through redaction.
_logLine(msg) {
this._log(redactSecrets(`[ssh] ${msg}`))
}
// Throw a classified, UI-ready error from an ssh result/exception.
_fail(stderrOrErr, fallbackKind = SSH_ERROR.UNKNOWN) {
if (stderrOrErr && stderrOrErr.kind === SSH_ERROR.TIMEOUT) {
const err = new Error(sshErrorMessage(SSH_ERROR.TIMEOUT, this))
err.kind = SSH_ERROR.TIMEOUT
return err
}
const stderr = typeof stderrOrErr === 'string' ? stderrOrErr : stderrOrErr?.message || ''
const kind = stderr ? classifySshError(stderr) : fallbackKind
const err = new Error(sshErrorMessage(kind, this, stderr))
err.kind = kind
return err
}
// Open the persistent ControlMaster. Idempotent: if a master socket is
// already alive (`-O check` succeeds), this is a no-op.
async open() {
if (await this.isAlive()) {
this._opened = true
return
}
// Ensure the control-socket directory exists — OpenSSH will not create
// intermediate dirs for ControlPath, so a fresh box (no prior hermes-ssh
// socket dir under $TMPDIR) would otherwise fail before the first connect.
// 0o700: the socket grants command execution on the master; keep it private.
try {
fs.mkdirSync(path.dirname(this.controlPath), { recursive: true, mode: 0o700 })
} catch {
// best effort — a pre-existing dir or a races-with-another-conn mkdir is fine
}
const args = buildMasterArgs(this, this._connectTimeoutMs)
this._logLine(`opening control master to ${target(this.user, this.host)}:${this.port}`)
let result
try {
result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
} catch (error) {
throw this._fail(error, SSH_ERROR.UNREACHABLE)
}
if (result.code !== 0) {
throw this._fail(result.stderr, SSH_ERROR.UNREACHABLE)
}
this._opened = true
this._logLine('control master established')
}
// `-O check` against the master socket. True iff the master is alive.
async isAlive() {
const args = buildControlArgs(this, 'check', [], this._connectTimeoutMs)
try {
const result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
return result.code === 0
} catch {
return false
}
}
// One-shot remote command over the control connection. Resolves the trimmed
// stdout; rejects with a classified error on non-zero exit or timeout.
async exec(remoteCommand, { timeoutMs } = {}) {
const args = buildExecArgs(this, remoteCommand, this._connectTimeoutMs)
let result
try {
result = await runSsh(args, { timeoutMs: timeoutMs ?? this._execTimeoutMs, spawnFn: this._spawnFn })
} catch (error) {
throw this._fail(error)
}
if (result.code !== 0) {
throw this._fail(result.stderr)
}
return result.stdout
}
// Establish a local→remote forward against the running master.
// 127.0.0.1:<localPort> → <remoteHost>:<remotePort>.
async forward(localPort, remotePort, remoteHost = '127.0.0.1') {
const spec = forwardSpec(localPort, remotePort, remoteHost)
const args = buildControlArgs(this, 'forward', ['-L', spec], this._connectTimeoutMs)
this._logLine(`forwarding 127.0.0.1:${localPort} -> ${remoteHost}:${remotePort}`)
let result
try {
result = await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn })
} catch (error) {
throw this._fail(error)
}
if (result.code !== 0) {
throw this._fail(result.stderr)
}
}
// Cancel a previously-established forward. Best-effort: a failure here is
// logged but not thrown (the master close tears everything down anyway).
async cancelForward(localPort, remotePort, remoteHost = '127.0.0.1') {
const spec = forwardSpec(localPort, remotePort, remoteHost)
const args = buildControlArgs(this, 'cancel', ['-L', spec], this._connectTimeoutMs)
try {
await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn })
this._logLine(`cancelled forward 127.0.0.1:${localPort}`)
} catch (error) {
this._logLine(`cancelForward failed (ignored): ${error.message}`)
}
}
// Tear down the master. Best-effort; never throws.
async close() {
if (!this._opened) return
const args = buildControlArgs(this, 'exit', [], this._connectTimeoutMs)
try {
await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
this._logLine('control master closed')
} catch (error) {
this._logLine(`close failed (ignored): ${error.message}`)
} finally {
this._opened = false
}
}
}
// ---------------------------------------------------------------------------
// Free local port — for the tunnel's local end. Bind 127.0.0.1:0, read the
// kernel-assigned port, release. There is a benign TOCTOU window between
// release and the forward grabbing it; the forward failing is caught upstream
// and retried with a fresh port.
// ---------------------------------------------------------------------------
function pickLocalPort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.unref()
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
const { port } = server.address()
server.close(() => resolve(port))
})
})
}
module.exports = {
CONTROL_PERSIST_SECONDS,
DEFAULT_CONNECT_TIMEOUT_MS,
DEFAULT_EXEC_TIMEOUT_MS,
DEFAULT_FORWARD_TIMEOUT_MS,
SSH_ERROR,
SshConnection,
baseSshOptions,
buildControlArgs,
buildExecArgs,
buildInteractiveSshArgs,
buildMasterArgs,
classifySshError,
controlSocketPath,
forwardSpec,
hostArgs,
pickLocalPort,
redactSecrets,
runSsh,
sshErrorMessage,
target
}

View File

@@ -1,343 +0,0 @@
/**
* Tests for electron/ssh-connection.cjs.
*
* Run with: node --test electron/ssh-connection.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* Pure, electron-free: command construction, secret redaction, error
* classification, and the SshConnection lifecycle are exercised with an
* injected fake `spawn` so no real ssh process is started.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { EventEmitter } = require('node:events')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const {
SSH_ERROR,
SshConnection,
baseSshOptions,
buildControlArgs,
buildExecArgs,
buildInteractiveSshArgs,
buildMasterArgs,
classifySshError,
controlSocketPath,
forwardSpec,
hostArgs,
redactSecrets,
sshErrorMessage,
target
} = require('./ssh-connection.cjs')
// --- secret redaction -------------------------------------------------------
test('redactSecrets scrubs the spawn-time session token env var', () => {
const line = 'setsid env HERMES_DASHBOARD_SESSION_TOKEN=abc123deadbeef HERMES_DESKTOP=1 hermes dashboard'
const out = redactSecrets(line)
assert.ok(!out.includes('abc123deadbeef'))
assert.match(out, /HERMES_DASHBOARD_SESSION_TOKEN=<redacted>/)
// non-secret env vars are preserved
assert.match(out, /HERMES_DESKTOP=1/)
})
test('redactSecrets scrubs ?token= and ?ticket= URL params', () => {
assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?token=supersecret'), /\?token=<redacted>/)
assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?ticket=onetimeticket'), /\?ticket=<redacted>/)
assert.match(redactSecrets('GET /x?a=1&token=zzz HTTP'), /&token=<redacted>/)
assert.ok(!redactSecrets('?token=supersecret').includes('supersecret'))
})
test('redactSecrets scrubs Authorization and X-Hermes-Session-Token headers', () => {
assert.match(redactSecrets('Authorization: Bearer tok_9999'), /Authorization: Bearer <redacted>/)
assert.ok(!redactSecrets('Authorization: Bearer tok_9999').includes('tok_9999'))
assert.match(redactSecrets('X-Hermes-Session-Token: hdr_888'), /X-Hermes-Session-Token: ?<redacted>/)
assert.ok(!redactSecrets('X-Hermes-Session-Token: hdr_888').includes('hdr_888'))
})
test('redactSecrets handles null/undefined and non-secret text untouched', () => {
assert.equal(redactSecrets(null), '')
assert.equal(redactSecrets(undefined), '')
assert.equal(redactSecrets('uname -s -m'), 'uname -s -m')
})
// --- control-socket path ----------------------------------------------------
test('controlSocketPath is stable, short, and host-distinct', () => {
const a = controlSocketPath('me', 'box1', 22, '/tmp/d')
const a2 = controlSocketPath('me', 'box1', 22, '/tmp/d')
const b = controlSocketPath('me', 'box2', 22, '/tmp/d')
assert.equal(a, a2, 'same triple → same socket (ControlMaster reuse)')
assert.notEqual(a, b, 'different host → different socket')
// 16 hex chars + .sock keeps the basename short for sun_path 104-byte limit
assert.match(a, /\/[0-9a-f]{16}\.sock$/)
})
test('controlSocketPath default base stays under sun_path even with the temp-listener suffix', () => {
// OpenSSH binds a temporary listener at `<ControlPath>.<16 random chars>`
// (a 17-byte suffix) while opening the master. The macOS regression was the
// default base under os.tmpdir() (/var/folders/.../T/) pushing 89 → 106 bytes.
// The default base must keep socket + 17-byte suffix comfortably under 104.
const p = controlSocketPath('hermes', 'vbuddy-ubuntu', 22) // no baseDir → default
const worstCase = `${p}.0123456789abcdef` // mimic the .<16-char> temp suffix
assert.ok(
worstCase.length <= 104,
`default control socket + temp suffix must fit sun_path (got ${worstCase.length}: ${worstCase})`
)
// And it must NOT live under the deeply-nested macOS per-user temp dir.
assert.ok(!p.includes('/var/folders/'), 'default base must not be os.tmpdir() on macOS')
})
// --- command construction ---------------------------------------------------
test('baseSshOptions carries the house ControlMaster/BatchMode/accept-new policy', () => {
const opts = baseSshOptions('/tmp/x.sock', 15000)
const joined = opts.join(' ')
assert.match(joined, /ControlPath=\/tmp\/x\.sock/)
assert.match(joined, /ControlMaster=auto/)
assert.match(joined, /ControlPersist=\d+/)
assert.match(joined, /BatchMode=yes/)
assert.match(joined, /StrictHostKeyChecking=accept-new/)
assert.match(joined, /ConnectTimeout=15/)
assert.ok(!joined.includes('StrictHostKeyChecking=no'), 'never disables host-key checking')
})
test('hostArgs adds -p only for non-default port and -i only with a key', () => {
assert.deepEqual(hostArgs({ port: 22 }), [])
assert.deepEqual(hostArgs({ port: 2222 }), ['-p', '2222'])
assert.deepEqual(hostArgs({ port: 22, keyPath: '/k' }), ['-i', '/k'])
assert.deepEqual(hostArgs({ port: 2200, keyPath: '/k' }), ['-p', '2200', '-i', '/k'])
})
test('target builds user@host or bare host', () => {
assert.equal(target('me', 'box'), 'me@box')
assert.equal(target('', 'box'), 'box')
})
test('buildExecArgs ends with host then the remote command', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildExecArgs(conn, 'command -v hermes', 15000)
assert.equal(args[args.length - 1], 'command -v hermes')
assert.equal(args[args.length - 2], 'me@box')
assert.ok(args.includes('BatchMode=yes'))
})
test('buildControlArgs places -O <op> first and never appends a remote command', () => {
const conn = { user: 'me', host: 'box', port: 2222, keyPath: '/k', controlPath: '/tmp/x.sock' }
const args = buildControlArgs(conn, 'forward', ['-L', forwardSpec(5000, 6000)], 15000)
assert.equal(args[0], '-O')
assert.equal(args[1], 'forward')
assert.ok(args.includes('-L'))
assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000'))
assert.equal(args[args.length - 1], 'me@box')
})
test('buildMasterArgs requests a backgrounded master (-M -N -f)', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildMasterArgs(conn, 15000)
assert.ok(args.includes('-M'))
assert.ok(args.includes('-N'))
assert.ok(args.includes('-f'))
})
test('forwardSpec binds the local end to 127.0.0.1 only', () => {
assert.equal(forwardSpec(5000, 6000), '127.0.0.1:5000:127.0.0.1:6000')
assert.ok(forwardSpec(5000, 6000).startsWith('127.0.0.1:'))
assert.ok(!forwardSpec(5000, 6000).startsWith('0.0.0.0'))
})
test('buildInteractiveSshArgs requests a PTY, reuses the control master, execs a login shell', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildInteractiveSshArgs(conn, '', 15000)
assert.equal(args[0], '-tt', 'forces a PTY so the remote sees a real terminal')
assert.ok(args.join(' ').includes('ControlPath=/tmp/x.sock'), 'reuses the existing master (no new auth)')
assert.equal(args[args.length - 2], 'me@box')
assert.equal(args[args.length - 1], 'exec "$SHELL" -l')
})
test('buildInteractiveSshArgs cds into the remote cwd (best-effort) before the shell', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildInteractiveSshArgs(conn, '/home/me/project', 15000)
const remoteCmd = args[args.length - 1]
assert.match(remoteCmd, /^cd '\/home\/me\/project' 2>\/dev\/null; exec "\$SHELL" -l$/)
})
test('buildInteractiveSshArgs single-quotes a cwd with quotes safely', () => {
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
const args = buildInteractiveSshArgs(conn, "/tmp/a'b", 15000)
// the embedded quote must be escaped, not break out of the quoting
assert.ok(args[args.length - 1].startsWith("cd '/tmp/a'"))
assert.ok(args[args.length - 1].includes('exec "$SHELL" -l'))
})
// --- error classification ---------------------------------------------------
test('classifySshError detects a changed host key (fail-closed)', () => {
assert.equal(
classifySshError('@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@'),
SSH_ERROR.HOST_KEY_CHANGED
)
assert.equal(classifySshError('Host key verification failed.'), SSH_ERROR.HOST_KEY_CHANGED)
assert.equal(classifySshError('Offending ECDSA key in /home/u/.ssh/known_hosts:5'), SSH_ERROR.HOST_KEY_CHANGED)
})
test('classifySshError detects auth failure', () => {
assert.equal(classifySshError('Permission denied (publickey).'), SSH_ERROR.AUTH_FAILED)
assert.equal(classifySshError('Too many authentication failures'), SSH_ERROR.AUTH_FAILED)
})
test('classifySshError detects unreachable', () => {
assert.equal(classifySshError('ssh: Could not resolve hostname nope'), SSH_ERROR.UNREACHABLE)
assert.equal(classifySshError('connect to host x port 22: Connection refused'), SSH_ERROR.UNREACHABLE)
})
test('sshErrorMessage gives actionable guidance for auth and host-key-change', () => {
const conn = { user: 'me', host: 'box', port: 22 }
assert.match(sshErrorMessage(SSH_ERROR.AUTH_FAILED, conn, 'Permission denied'), /ssh-agent|ssh-add|IdentityFile/)
assert.match(sshErrorMessage(SSH_ERROR.HOST_KEY_CHANGED, conn, 'CHANGED'), /ssh-keygen -R box/)
})
// --- SshConnection lifecycle with injected fake spawn -----------------------
// A fake child process that emits a scripted result on next tick.
function fakeChild({ code = 0, stdout = '', stderr = '', errorEvent = null, hang = false } = {}) {
const child = new EventEmitter()
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.kill = () => {
child._killed = true
}
if (hang) {
return child // never emits close → drives the timeout path
}
process.nextTick(() => {
if (errorEvent) {
child.emit('error', errorEvent)
return
}
if (stdout) child.stdout.emit('data', Buffer.from(stdout))
if (stderr) child.stderr.emit('data', Buffer.from(stderr))
child.emit('close', code)
})
return child
}
// Build a spawnFn that returns scripted children per ssh invocation, recording
// the args it was called with.
function scriptedSpawn(scripts) {
const calls = []
let i = 0
const fn = (_cmd, args) => {
calls.push(args)
const script = typeof scripts === 'function' ? scripts(args, i) : scripts[Math.min(i, scripts.length - 1)]
i += 1
return fakeChild(script || {})
}
fn.calls = calls
return fn
}
test('open() establishes the master when not already alive', async () => {
// `-O check` fails first (not alive) → master opens (code 0). Track which
// ssh ops ran rather than re-probing with the same always-failing check.
const ops = []
const spawnFn = scriptedSpawn(args => {
ops.push(args.includes('check') ? 'check' : args.includes('-M') ? 'master' : 'other')
if (args.includes('check')) return { code: 255, stderr: 'no control path' }
return { code: 0 }
})
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
await conn.open()
assert.deepEqual(ops, ['check', 'master'], 'probes liveness first, then opens the master')
})
test('open() is a no-op when the master is already alive', async () => {
const ops = []
const spawnFn = scriptedSpawn(args => {
ops.push(args.includes('check') ? 'check' : 'master')
return { code: 0 } // check succeeds → already alive
})
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
await conn.open()
assert.deepEqual(ops, ['check'], 'alive master → no second spawn to open it')
})
test('open() creates the control-socket directory if it does not exist', async () => {
const dir = path.join(os.tmpdir(), `hermes-ssh-test-${process.pid}-${Date.now()}`)
assert.ok(!fs.existsSync(dir), 'precondition: control dir absent')
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })
try {
await conn.open()
assert.ok(fs.existsSync(dir), 'open() created the control-socket directory before spawning ssh')
} finally {
try {
fs.rmSync(dir, { recursive: true, force: true })
} catch {
/* ignore */
}
}
})
test('open() surfaces a classified auth error', async () => {
const spawnFn = scriptedSpawn(args => {
if (args.includes('check')) return { code: 255 }
return { code: 255, stderr: 'Permission denied (publickey).' }
})
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
await assert.rejects(() => conn.open(), err => {
assert.equal(err.kind, SSH_ERROR.AUTH_FAILED)
assert.match(err.message, /ssh-agent|ssh-add/)
return true
})
})
test('exec() returns stdout on success and rejects (classified) on failure', async () => {
const okSpawn = scriptedSpawn([{ code: 0, stdout: 'Linux\n' }])
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: okSpawn, controlDir: '/tmp/d' })
assert.equal((await conn.exec('uname -s')).trim(), 'Linux')
const failSpawn = scriptedSpawn([{ code: 1, stderr: 'ssh: Could not resolve hostname box' }])
const conn2 = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: failSpawn, controlDir: '/tmp/d' })
await assert.rejects(() => conn2.exec('uname -s'), err => {
assert.equal(err.kind, SSH_ERROR.UNREACHABLE)
return true
})
})
test('exec() treats a hung ssh as a timeout (half-open connection)', async () => {
const spawnFn = scriptedSpawn([{ hang: true }])
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
await assert.rejects(() => conn.exec('uname -s', { timeoutMs: 30 }), err => {
assert.equal(err.kind, SSH_ERROR.TIMEOUT)
return true
})
})
test('forward() issues -O forward with a loopback-bound -L spec', async () => {
const spawnFn = scriptedSpawn([{ code: 0 }])
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
await conn.forward(5000, 6000)
const args = spawnFn.calls[0]
assert.equal(args[0], '-O')
assert.equal(args[1], 'forward')
assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000'))
})
test('lifecycle logging passes through redaction', async () => {
const logs = []
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
const conn = new SshConnection(
{ host: 'box', user: 'me' },
{ spawnFn, controlDir: '/tmp/d', rememberLog: l => logs.push(l) }
)
await conn.open()
// none of the emitted log lines may carry a raw token-shaped secret
for (const line of logs) {
assert.ok(!/token=[^<]/.test(line))
}
assert.ok(logs.some(l => l.includes('[ssh]')))
})

View File

@@ -1,29 +0,0 @@
'use strict'
/**
* Retry-once policy for the desktop `--build-only` rebuild during self-update.
*
* The first rebuild can return nonzero on a still-settling post-update tree or a
* network-blocked Electron fetch that the installer's self-heal repaired mid-run.
* A second attempt then builds clean off the healed dist (the content-hash stamp
* makes it a near-no-op when the first actually succeeded). Without the retry the
* updater bails before the relaunch step — the app updates but doesn't restart.
*/
function shouldRetryRebuild(code) {
return code !== 0
}
/**
* Run `rebuild()` (async, resolves `{ code, ... }`), retrying once on failure.
* Returns the final result.
*/
async function runRebuildWithRetry(rebuild) {
let result = await rebuild(0)
if (shouldRetryRebuild(result.code)) {
result = await rebuild(1)
}
return result
}
module.exports = { shouldRetryRebuild, runRebuildWithRetry }

View File

@@ -1,55 +0,0 @@
/**
* Tests for electron/update-rebuild.cjs — the retry-once policy for the desktop
* `--build-only` rebuild during self-update.
*
* Run with: node --test electron/update-rebuild.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* Why this matters: a first rebuild can return nonzero on a still-settling tree
* or a self-healed (network-blocked) Electron download. Without a second attempt
* the updater bails before the relaunch step — the app updates but never restarts
* (the field report behind this fix). The retry must fire on failure, not on
* success, and must run at most twice.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { shouldRetryRebuild, runRebuildWithRetry } = require('./update-rebuild.cjs')
test('shouldRetryRebuild retries only on a non-success exit', () => {
assert.equal(shouldRetryRebuild(0), false)
assert.equal(shouldRetryRebuild(1), true)
assert.equal(shouldRetryRebuild(null), true)
})
test('a clean first rebuild runs once and does not retry', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: 0 })
})
assert.deepEqual(codes, [0])
assert.equal(result.code, 0)
})
test('a failed first rebuild retries once and succeeds', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: attempt === 0 ? 1 : 0 })
})
assert.deepEqual(codes, [0, 1])
assert.equal(result.code, 0)
})
test('a rebuild that keeps failing runs at most twice and reports the failure', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: 1, error: 'rebuild-failed' })
})
assert.deepEqual(codes, [0, 1])
assert.equal(result.code, 1)
assert.equal(result.error, 'rebuild-failed')
})

View File

@@ -21,7 +21,7 @@
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
"postbuild": "node scripts/assert-dist-built.cjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",
"dist:mac": "npm run build && npm run builder -- --mac",
@@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/ssh-connection.test.cjs electron/remote-lifecycle.test.cjs electron/ssh-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/windows-user-env.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -55,7 +55,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hermes/shared": "file:../shared",
"@icons-pack/react-simple-icons": "=13.11.1",
"@icons-pack/react-simple-icons": "^13.13.0",
"@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",
@@ -117,7 +117,7 @@
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^10.0.3",
"cross-env": "^10.1.0",
"electron": "40.10.2",
"electron": "^40.9.3",
"electron-builder": "^26.8.1",
"eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^5.9.0",
@@ -134,7 +134,8 @@
"wait-on": "^9.0.5"
},
"build": {
"electronVersion": "40.10.2",
"electronVersion": "40.9.3",
"electronDist": "../../node_modules/electron/dist",
"appId": "com.nousresearch.hermes",
"productName": "Hermes",
"executableName": "Hermes",

View File

@@ -24,11 +24,6 @@ const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes cop
if (!fs.existsSync(bundledElectronBinary)) {
const candidates = [
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
// npm may nest the workspace-only electron devDep under
// apps/desktop/node_modules (process.cwd() during pack), or hoist
// it to the repo root. Try the workspace-local install first, then
// the root hoist, so the fallback works under either layout.
path.join(process.cwd(), "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
];
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));

View File

@@ -1,57 +0,0 @@
"use strict"
// Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can
// re-unpack a broken Electron.app; reusing the installed dist dodges that.
// npm workspace hoisting is non-deterministic — require.resolve finds electron
// wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let
// electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR).
const fs = require("node:fs")
const path = require("node:path")
const { spawnSync } = require("node:child_process")
function electronDistDir() {
try {
return path.join(path.dirname(require.resolve("electron/package.json")), "dist")
} catch {
return null
}
}
function distBinary(dist) {
if (process.platform === "darwin") {
return path.join(dist, "Electron.app", "Contents", "MacOS", "Electron")
}
if (process.platform === "win32") {
return path.join(dist, "electron.exe")
}
return path.join(dist, "electron")
}
function electronBuilderCli() {
const pkgJson = require.resolve("electron-builder/package.json")
const bin = require(pkgJson).bin
const rel = typeof bin === "string" ? bin : bin["electron-builder"]
return path.join(path.dirname(pkgJson), rel)
}
const dist = electronDistDir()
const args = []
if (dist && fs.existsSync(distBinary(dist))) {
args.push(`-c.electronDist=${dist}`)
} else {
console.warn(
"[run-electron-builder] no local electron dist; electron-builder will fetch " +
"via @electron/get (electronVersion + ELECTRON_MIRROR)."
)
}
args.push(...process.argv.slice(2))
const result = spawnSync(process.execPath, [electronBuilderCli(), ...args], {
stdio: "inherit",
})
if (result.error) {
console.error(`[run-electron-builder] spawn failed: ${result.error.message}`)
process.exit(1)
}
process.exit(result.status == null ? 1 : result.status)

View File

@@ -357,7 +357,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
</button>
{visibleRows.length > 0 ? (
<div className="grid min-w-0 gap-1 pl-6" data-selectable-text="true">
<div className="grid min-w-0 gap-1 pl-6">
{visibleRows.map((entry, i) => (
<StreamLine
active={running && i === visibleRows.length - 1}
@@ -371,7 +371,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
) : null}
{open && fileLines.length > 0 ? (
<div className="grid min-w-0 gap-0.5 pl-6" data-selectable-text="true">
<div className="grid min-w-0 gap-0.5 pl-6">
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">
{t.agents.files}
</p>

View File

@@ -15,9 +15,7 @@ import { Backdrop } from '@/components/Backdrop'
import { PromptOverlays } from '@/components/prompt-overlays'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ErrorState } from '@/components/ui/error-state'
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import type { ChatMessage } from '@/lib/chat-messages'
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
@@ -40,7 +38,6 @@ import {
$lastVisibleMessageIsUser,
$messages,
$messagesEmpty,
$resumeExhaustedSessionId,
$selectedStoredSessionId,
$sessions,
sessionPinId
@@ -89,9 +86,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onRestoreToMessage?: (messageId: string) => Promise<void>
onRetryResume: (sessionId: string) => void
onTranscribeAudio?: (audio: Blob) => Promise<string>
onDismissError?: (messageId: string) => void
}
interface ChatHeaderProps {
@@ -277,12 +272,9 @@ export function ChatView({
onEdit,
onReload,
onRestoreToMessage,
onRetryResume,
onTranscribeAudio,
onDismissError
onTranscribeAudio
}: ChatViewProps) {
const location = useLocation()
const { t } = useI18n()
const activeSessionId = useStore($activeSessionId)
const awaitingResponse = useStore($awaitingResponse)
const busy = useStore($busy)
@@ -304,7 +296,6 @@ export function ChatView({
const messagesEmpty = useStore($messagesEmpty)
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
const selectedSessionId = useStore($selectedStoredSessionId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
const routedSessionId = routeSessionId(location.pathname)
const isRoutedSessionView = Boolean(routedSessionId)
@@ -324,21 +315,9 @@ export function ChatView({
// session exists — even if it has zero messages (a brand-new routed
// session). The flicker where `busy` flips true briefly during hydrate
// is handled by `threadLoadingState`'s last-visible-user gate.
//
// resumeExhausted: the bounded auto-retry in use-route-resume gave up on this
// routed session (gateway RPC + REST fallback failed through every attempt).
// Suppress the loader and show an explicit error + manual Retry instead of
// spinning forever. Gated on the route matching so a stale latch from another
// session can't blank the current one.
const resumeExhausted = isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
const loadingSession =
!resumeExhausted && isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
const loadingSession = isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser)
// Hide the composer in the exhausted error state too: there's no live runtime
// to send to until a retry rebinds one.
const showChatBar = !loadingSession && !resumeExhausted
const showChatBar = !loadingSession
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
@@ -453,7 +432,6 @@ export function ChatView({
loading={threadLoading}
onBranchInNewChat={onBranchInNewChat}
onCancel={onCancel}
onDismissError={onDismissError}
onRestoreToMessage={onRestoreToMessage}
sessionId={activeSessionId}
sessionKey={threadKey}
@@ -487,21 +465,6 @@ export function ChatView({
</Suspense>
)}
</ChatRuntimeBoundary>
{resumeExhausted && routedSessionId && (
<div className="absolute inset-0 z-10 grid place-items-center bg-(--ui-chat-surface-background) px-8 py-10">
<ErrorState
className="max-w-sm"
description={t.desktop.resumeStrandedBody}
title={t.desktop.resumeStrandedTitle}
>
<div className="grid justify-items-center">
<Button onClick={() => onRetryResume(routedSessionId)} size="sm" variant="outline">
{t.desktop.resumeRetry}
</Button>
</div>
</ErrorState>
</div>
)}
{showChatBar && <ScrollToBottomButton />}
<ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />

View File

@@ -395,7 +395,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
</div>
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
<Button onClick={() => void runSystemAction('restart')} size="xs" variant="text">
{cc.restartGateway}
{cc.restartMessaging}
</Button>
<Button onClick={() => void runSystemAction('update')} size="xs" variant="textStrong">
{cc.updateHermes}
@@ -426,10 +426,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
</span>
)}
</div>
<pre
className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.65rem] leading-relaxed text-(--ui-text-tertiary)"
data-selectable-text="true"
>
<pre className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.65rem] leading-relaxed text-(--ui-text-tertiary)">
{logs.length ? logs.join('\n') : cc.noLogs}
</pre>
</div>

View File

@@ -30,7 +30,6 @@ import {
Package,
Palette,
Plus,
RefreshCw,
Settings,
Settings2,
Sun,
@@ -42,7 +41,6 @@ import {
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { runGatewayRestart } from '@/store/system-actions'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
@@ -362,13 +360,6 @@ export function CommandPalette() {
keywords: ['command center', 'usage', 'tokens', 'cost'],
label: cc.sections.usage,
run: go(`${COMMAND_CENTER_ROUTE}?section=usage`)
},
{
icon: RefreshCw,
id: 'cc-restart-gateway',
keywords: ['gateway', 'restart', 'messaging', 'reconnect', 'system'],
label: cc.restartGateway,
run: () => void runGatewayRestart()
}
]
},

View File

@@ -13,8 +13,7 @@ import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import { storedSessionIdForNotification } from '../lib/session-ids'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import {
isMessagingSource,
LOCAL_SESSION_SOURCE_IDS,
@@ -53,10 +52,7 @@ import {
$currentCwd,
$freshDraftReady,
$gatewayState,
$messages,
$messagingSessions,
$resumeFailedSessionId,
$resumeExhaustedSessionId,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
@@ -203,8 +199,6 @@ export function DesktopController() {
const activeSessionId = useStore($activeSessionId)
const currentCwd = useStore($currentCwd)
const freshDraftReady = useStore($freshDraftReady)
const resumeFailedSessionId = useStore($resumeFailedSessionId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
const filePreviewTarget = useStore($filePreviewTarget)
const previewTarget = useStore($previewTarget)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
@@ -277,20 +271,16 @@ export function DesktopController() {
}
}, [])
// Notification click: the main process already focused the window; jump to its
// session. Notifications are tagged with the gateway *runtime* session id, but
// the chat route is keyed by the *stored* id — navigating with the runtime id
// resumes a non-existent stored session ("session not found") and strands the
// user. Translate runtime -> stored before navigating.
// Notification click: the main process already focused the window; jump to its session.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => {
if (sessionId) {
navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionIdRef.current)))
navigate(sessionRoute(sessionId))
}
})
return () => unsubscribe?.()
}, [navigate, runtimeIdByStoredSessionIdRef])
}, [navigate])
// Notification action button (Approve/Reject) — resolve in place, no navigation.
useEffect(() => {
@@ -746,49 +736,6 @@ export function DesktopController() {
[branchCurrentSession, refreshSessions]
)
// Clear a failed turn's red error banner from the transcript. Errors are
// renderer-local state (never persisted), so dismissing is purely a view +
// session-cache edit. A message that errored before emitting any visible
// text is a bare error placeholder → drop it entirely; one that streamed
// partial output then failed keeps its content and just sheds the error.
// Both the per-runtime cache AND the live $messages view must be updated:
// `preserveLocalAssistantErrors` re-grafts any still-errored message it
// finds in the view onto the next session.info flush, so clearing only the
// cache would let the heartbeat resurrect the banner.
const dismissError = useCallback(
(messageId: string) => {
const runtimeSessionId = activeSessionIdRef.current
if (!runtimeSessionId) {
return
}
const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] =>
messages.flatMap(message => {
if (message.id !== messageId || !message.error) {
return [message]
}
if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) {
return []
}
return [{ ...message, error: undefined, pending: false }]
})
// View first: the flush below reads $messages as the "current" baseline
// for error preservation, so the banner must be gone from it before the
// cache update triggers a re-sync.
setMessages(clearErrorIn($messages.get()))
updateSessionState(runtimeSessionId, state => ({
...state,
messages: clearErrorIn(state.messages)
}))
},
[activeSessionIdRef, updateSessionState]
)
const startSessionInWorkspace = useCallback(
(path: null | string) => {
startFreshSessionDraft()
@@ -898,8 +845,6 @@ export function DesktopController() {
gatewayState,
locationPathname: location.pathname,
resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
@@ -1049,7 +994,6 @@ export function DesktopController() {
void removeSession(selectedStoredSessionId)
}
}}
onDismissError={dismissError}
onEdit={editMessage}
onPasteClipboardImage={() => void composer.pasteClipboardImage()}
onPickFiles={() => void composer.pickContextPaths('file')}
@@ -1058,7 +1002,6 @@ export function DesktopController() {
onReload={reloadFromMessage}
onRemoveAttachment={id => void composer.removeAttachment(id)}
onRestoreToMessage={restoreToMessage}
onRetryResume={sessionId => void resumeSession(sessionId, true)}
onSteer={steerPrompt}
onSubmit={submitText}
onThreadMessagesChange={handleThreadMessagesChange}

View File

@@ -17,7 +17,6 @@ import { type Translations, useI18n } from '@/i18n'
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { runGatewayRestart } from '@/store/system-actions'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@@ -98,8 +97,6 @@ function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) {
const { t } = useI18n()
const m = t.messaging
// Both save/toggle toasts offer the same one-click restart.
const restartGatewayAction = { label: t.commandCenter.restartGateway, onClick: () => void runGatewayRestart() }
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
const [edits, setEdits] = useState<EditMap>({})
const [query, setQuery] = useState('')
@@ -200,8 +197,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
notify({
kind: 'success',
title: enabled ? m.platformEnabled(platform.name) : m.platformDisabled(platform.name),
message: m.restartToApply,
action: restartGatewayAction
message: m.restartToApply
})
} catch (err) {
notifyError(err, m.failedUpdate(platform.name))
@@ -226,8 +222,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
notify({
kind: 'success',
title: m.setupSaved(platform.name),
message: m.restartToReconnect,
action: restartGatewayAction
message: m.restartToReconnect
})
} catch (err) {
notifyError(err, m.failedSave(platform.name))

View File

@@ -13,7 +13,6 @@ import {
type GatewayEventPayload,
reasoningPart,
renderMediaTags,
textPart,
upsertToolPart
} from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
@@ -1081,32 +1080,6 @@ export function useMessageStream({
// completions / watch matches here — re-sync the status stack.
void refreshBackgroundProcesses(sessionId)
}
} else if (event.type === 'review.summary') {
// Self-improvement background review saved something to memory/skills
// and emitted a persistent summary (Python formats it as
// "💾 Self-improvement review: …"). The CLI prints this via
// prompt_toolkit and the Ink TUI renders it as a system line; the
// desktop has neither, so without this handler the skill/memory
// change happens silently. Surface it as a persistent system message
// in the transcript so the user is always informed — it must not be a
// transient toast that can be missed.
const text = coerceGatewayText(payload?.text).trim()
if (text && sessionId) {
flushQueuedDeltas(sessionId)
updateSessionState(sessionId, state => ({
...state,
messages: [
...state.messages,
{
id: `review-summary-${Date.now()}`,
role: 'system',
parts: [textPart(text)],
timestamp: Math.floor(Date.now() / 1000)
}
]
}))
}
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
@@ -1129,13 +1102,8 @@ export function useMessageStream({
if (looksLikeProviderSetup) {
requestDesktopOnboarding(errorMessage)
} else {
// Toast globally, not just when the failing thread is focused: a
// turn-ending error (e.g. out of funds) blocks every thread, so the
// inline error alone is too easy to miss. The stable id collapses the
// same error from multiple blocked threads into one toast.
} else if (isActiveEvent) {
notify({
id: `gateway-error:${errorMessage}`,
kind: 'error',
title: 'Hermes error',
message: errorMessage

View File

@@ -32,7 +32,6 @@ import {
clearComposerAttachments,
type ComposerAttachment,
setComposerAttachmentUploadState,
setComposerDraft,
terminalContextBlocksFromDraft,
updateComposerAttachment
} from '@/store/composer'
@@ -952,26 +951,8 @@ export function usePromptActions({
return
}
// send / prefill carry an optional `notice` (e.g. "⊙ Goal set …")
// that the backend wants shown as a system line before the message
// is acted on. Mirrors the TUI's createSlashHandler — without it a
// `/goal <text>` looked like it did nothing.
if ((dispatch.type === 'send' || dispatch.type === 'prefill') && dispatch.notice?.trim()) {
renderSlashOutput(dispatch.notice.trim())
}
const message = ('message' in dispatch ? dispatch.message : '')?.trim() ?? ''
// /undo returns a prefill directive: drop the backed-up message into
// the composer for editing instead of submitting it immediately.
if (dispatch.type === 'prefill') {
if (message) {
setComposerDraft(message)
}
return
}
if (!message) {
renderSlashOutput(
`/${name}: ${dispatch.type === 'skill' ? 'skill payload missing message' : 'empty message'}`

View File

@@ -2,8 +2,6 @@ import { cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $resumeExhaustedSessionId, setResumeExhaustedSessionId } from '@/store/session'
import { useRouteResume } from './use-route-resume'
interface HarnessProps {
@@ -15,8 +13,6 @@ interface HarnessProps {
gatewayState: string
locationPathname: string
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
resumeFailedSessionId?: null | string
resumeExhaustedSessionId?: null | string
routedSessionId: null | string
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: null | string
@@ -24,12 +20,8 @@ interface HarnessProps {
startFreshSessionDraft: (focus: boolean) => unknown
}
function RouteResumeHarness({
resumeFailedSessionId = null,
resumeExhaustedSessionId = null,
...props
}: HarnessProps) {
useRouteResume({ ...props, resumeExhaustedSessionId, resumeFailedSessionId })
function RouteResumeHarness(props: HarnessProps) {
useRouteResume(props)
return null
}
@@ -264,212 +256,3 @@ describe('useRouteResume', () => {
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
})
describe('useRouteResume bounded auto-retry after a failed resume', () => {
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
setResumeExhaustedSessionId(null)
})
// Common stranded-window props: gateway open, route on the session, no runtime
// yet, and the ref already synced to the route (resumeSession sets it at entry
// before failing) — the exact state that defeats the main effect's self-heal.
function strandedProps(resumeSession: (sid: string, focus: boolean) => Promise<unknown>) {
return {
activeSessionId: null,
activeSessionIdRef: { current: null } as MutableRefObject<null | string>,
creatingSessionRef: { current: false },
currentView: 'chat',
freshDraftReady: false,
gatewayState: 'open',
locationPathname: '/session-1',
resumeSession,
routedSessionId: 'session-1',
runtimeIdByStoredSessionIdRef: { current: new Map<string, string>() },
selectedStoredSessionId: 'session-1',
// Synced to the route by the failed resume's synchronous entry-write.
selectedStoredSessionIdRef: { current: 'session-1' } as MutableRefObject<null | string>,
startFreshSessionDraft: vi.fn()
}
}
it('retries the resume on backoff when the routed session is flagged as failed', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
render(<RouteResumeHarness {...strandedProps(resumeSession)} resumeFailedSessionId="session-1" />)
// The main effect fires one resume on mount (pathname-changed). Clear it so
// we assert purely the bounded-retry effect's scheduled retry below.
resumeSession.mockClear()
// No immediate fire — the retry is scheduled behind the backoff timer.
expect(resumeSession).not.toHaveBeenCalled()
// First backoff window (1s) elapses → one retry.
vi.advanceTimersByTime(1_000)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
it('does NOT retry a failed session that is not the routed one', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
// The failure flag points at a different session than the route.
render(<RouteResumeHarness {...strandedProps(resumeSession)} resumeFailedSessionId="other-session" />)
resumeSession.mockClear() // drop the mount resume
vi.advanceTimersByTime(10_000)
expect(resumeSession).not.toHaveBeenCalled()
})
it('skips the scheduled retry if the session already recovered when the timer fires', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear() // drop the mount resume
// A resume landed while we waited: runtime is now bound.
props.activeSessionIdRef.current = 'runtime-1'
vi.advanceTimersByTime(8_000)
expect(resumeSession).not.toHaveBeenCalled()
})
it('stops retrying after MAX_RESUME_RETRIES consecutive failures', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
// Model the real re-arm loop: resumeSession clears $resumeFailedSessionId at
// entry (null) and a repeat failure re-sets it ('session-1'). That null->id
// toggle is what re-runs the effect and advances the bounded counter. The
// routed session never changes, so the counter is NOT reset between cycles.
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear() // drop the mount resume; count only the retries
for (let i = 0; i < 8; i += 1) {
vi.advanceTimersByTime(8_000) // fire the scheduled retry (if any)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />) // cleared at entry
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />) // re-armed on failure
}
// Capped at MAX_RESUME_RETRIES (4): a persistently dead backend can't
// hot-loop the resume forever.
expect(resumeSession.mock.calls.length).toBe(4)
// Once auto-retry gives up, the exhausted latch is armed for the routed
// session so the chat view can swap the perpetual loader for an explicit
// error + manual Retry instead of spinning forever.
expect($resumeExhaustedSessionId.get()).toBe('session-1')
})
it('does not arm the exhausted latch while retries remain', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear()
// Two failure cycles — still under the 4-retry cap, so the latch must stay
// clear and the loader keeps spinning (auto-recovery hasn't given up yet).
for (let i = 0; i < 2; i += 1) {
vi.advanceTimersByTime(8_000)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
}
expect($resumeExhaustedSessionId.get()).toBeNull()
})
it('clears a stale exhausted latch when the route moves off the stranded session', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
// Pre-arm the latch as if this session had exhausted its retries.
setResumeExhaustedSessionId('session-1')
// Route is now on a different, healthy session that is not flagged as
// failed — the retry effect's "route moved off" branch clears the latch.
render(
<RouteResumeHarness
{...props}
activeSessionId="runtime-2"
activeSessionIdRef={{ current: 'runtime-2' }}
locationPathname="/session-2"
resumeFailedSessionId={null}
routedSessionId="session-2"
selectedStoredSessionId="session-2"
selectedStoredSessionIdRef={{ current: 'session-2' }}
/>
)
expect($resumeExhaustedSessionId.get()).toBeNull()
})
it('resets the retry counter for a fresh backoff cycle when the exhausted latch clears (manual retry, same session)', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
// Phase A — exhaust the bounded auto-retry (counter → MAX) like a dead
// backend. The resumeExhaustedSessionId prop stays null here: the hook sets
// the store, which doesn't feed back into the prop in this harness.
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear()
for (let i = 0; i < 8; i += 1) {
vi.advanceTimersByTime(8_000)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
}
expect(resumeSession.mock.calls.length).toBe(4) // capped
expect($resumeExhaustedSessionId.get()).toBe('session-1')
// Phase B — user clicks Retry on the SAME stranded session. resumeSession
// clears both latches at entry; the exhausted latch's armed->cleared edge
// must reset the attempt counter so a fresh bounded cycle runs, not a single
// one-shot attempt that immediately re-arms the error. Model the prop
// transitions: reflect the armed latch, then clear it (retry), then re-arm
// the failure latch on the fresh failure.
resumeSession.mockClear()
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId="session-1" resumeFailedSessionId="session-1" />)
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId={null} resumeFailedSessionId={null} />)
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId={null} resumeFailedSessionId="session-1" />)
// A real retry fires again instead of staying pinned at MAX (which would
// dispatch nothing). Without the reset the counter stays >= MAX and this
// advance dispatches zero resumes.
vi.advanceTimersByTime(8_000)
expect(resumeSession.mock.calls.length).toBeGreaterThan(0)
})
it('does not burn retry attempts on unrelated re-renders during the backoff window', () => {
vi.useFakeTimers()
const props = strandedProps(vi.fn())
// Mount schedules the first backoff timer. Then re-render repeatedly with a
// fresh resumeSession identity (referential instability — a real dep change
// for the retry effect) WITHOUT ever letting the timer fire. The old code
// incremented the attempt counter at schedule time, so >= MAX re-renders
// armed the exhausted error with zero resumes actually dispatched. The fix
// only advances the counter when a timer truly fires, so the latch stays
// clear no matter how many spurious re-renders happen mid-backoff.
const { rerender } = render(
<RouteResumeHarness {...props} resumeFailedSessionId="session-1" resumeSession={vi.fn(async () => undefined)} />
)
for (let j = 0; j < 8; j += 1) {
rerender(
<RouteResumeHarness {...props} resumeFailedSessionId="session-1" resumeSession={vi.fn(async () => undefined)} />
)
}
expect($resumeExhaustedSessionId.get()).toBeNull()
})
})

View File

@@ -1,7 +1,6 @@
import { type MutableRefObject, useEffect, useRef } from 'react'
import { isNewChatRoute } from '@/app/routes'
import { setResumeExhaustedSessionId } from '@/store/session'
interface RouteResumeOptions {
activeSessionId: string | null
@@ -12,17 +11,6 @@ interface RouteResumeOptions {
gatewayState: string | undefined
locationPathname: string
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
// Stored-session id whose most recent resume failed terminally (set by
// useSessionActions, mirrored from $resumeFailedSessionId). While this equals
// routedSessionId the window would otherwise latch on the loader forever, so
// the bounded-retry effect below re-attempts the resume.
resumeFailedSessionId: string | null
// Stored-session id whose bounded auto-retry has EXHAUSTED (mirrored from
// $resumeExhaustedSessionId). Only resumeSession clears this latch (manual
// Retry / reconnect / reselect) — the auto-retry loop never does — so its
// armed->cleared edge is an unambiguous "give me a fresh backoff cycle"
// signal the effect below uses to reset the attempt counter.
resumeExhaustedSessionId: string | null
routedSessionId: string | null
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: string | null
@@ -30,19 +18,6 @@ interface RouteResumeOptions {
startFreshSessionDraft: (focus: boolean) => unknown
}
// Bounded auto-retry for a stranded session window. A resume can fail terminally
// (gateway RPC reject + REST fallback failure) on a transiently wedged backend —
// dead provider key, a runaway turn hogging the dispatcher, flaky DNS. Without a
// retry the loader latches forever. We retry with backoff, capped, so a
// genuinely dead backend doesn't hot-loop the resume.
const MAX_RESUME_RETRIES = 4
const RESUME_RETRY_BASE_MS = 1_000
const RESUME_RETRY_MAX_MS = 8_000
function resumeRetryDelayMs(attempt: number): number {
return Math.min(RESUME_RETRY_MAX_MS, RESUME_RETRY_BASE_MS * 2 ** attempt)
}
// HashRouter boot edge case: pathname briefly reads `/` before the hash is
// parsed. If the hash references a real session, defer; resume picks it up
// next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states.
@@ -74,8 +49,6 @@ export function useRouteResume({
gatewayState,
locationPathname,
resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
@@ -85,16 +58,6 @@ export function useRouteResume({
const lastPathnameRef = useRef<string | null>(null)
const seenGatewayStateRef = useRef(false)
const wasGatewayOpenRef = useRef(false)
// Per-session retry bookkeeping for the bounded auto-retry effect below. Keyed
// by the session id we're retrying so switching chats resets the counter.
const retrySessionIdRef = useRef<string | null>(null)
const retryAttemptRef = useRef(0)
// Tracks the previous exhausted-latch value so we can detect its armed->cleared
// edge. resumeSession clears $resumeExhaustedSessionId on a manual Retry /
// reconnect / reselect; that transition is our cue to reset the attempt counter
// for a fresh backoff cycle on the SAME session (the auto-retry loop itself
// never touches this latch, so it can't spuriously trigger the reset).
const prevResumeExhaustedRef = useRef<string | null>(null)
useEffect(() => {
const gatewayOpen = gatewayState === 'open'
@@ -176,111 +139,4 @@ export function useRouteResume({
selectedStoredSessionIdRef,
startFreshSessionDraft
])
// Bounded auto-retry: when the routed session's resume failed terminally
// (resumeFailedSessionId matches the route), schedule a backoff retry so the
// window recovers on its own instead of latching the loader forever. This is
// the safety net the main effect above can't provide: after a failed resume,
// selectedStoredSessionIdRef.current already equals the route (resumeSession
// sets it synchronously at entry) and the pathname/gateway are unchanged, so
// none of stuckOnRoutedSession / pathnameChanged / gatewayBecameOpen fire
// again. resumeSession clears resumeFailedSessionId on its next attempt; a
// success keeps it clear (the effect's guard then no-ops), a repeat failure
// re-arms it and we back off further, capped at MAX_RESUME_RETRIES.
useEffect(() => {
// Detect the exhausted-latch armed->cleared edge for the current route. Only
// resumeSession clears $resumeExhaustedSessionId (manual Retry / reconnect /
// reselect) — the auto-retry loop never touches it — so this transition
// uniquely means "the user asked for another go." Reset the attempt counter
// for a fresh bounded backoff cycle on the SAME session. Without this,
// retryAttemptRef stays pinned at MAX after exhaustion (the !stranded reset
// below only fires on a route CHANGE to a different session), so a manual
// retry on the same stranded session would get exactly ONE attempt and then
// immediately re-arm the exhausted error — never the renewed backoff cycle
// the store/session.ts + use-session-actions.ts comments promise. (Point 2)
const wasExhausted = prevResumeExhaustedRef.current
prevResumeExhaustedRef.current = resumeExhaustedSessionId
if (wasExhausted && wasExhausted === routedSessionId && resumeExhaustedSessionId !== wasExhausted) {
retrySessionIdRef.current = routedSessionId
retryAttemptRef.current = 0
}
if (currentView !== 'chat' || gatewayState !== 'open') {
return
}
const stranded =
Boolean(routedSessionId) &&
resumeFailedSessionId === routedSessionId &&
!creatingSessionRef.current
if (!stranded) {
// Route moved off the stranded session (or it recovered) — reset the
// counter so a future failure on another session starts fresh, and clear
// any exhausted-latch armed for a session we're no longer viewing (never
// the current route: that's the error state we want to keep showing).
// resumeSession also clears it on a fresh attempt; this covers a plain
// route-change away from the stranded window.
if (retrySessionIdRef.current !== routedSessionId) {
retrySessionIdRef.current = null
retryAttemptRef.current = 0
setResumeExhaustedSessionId(current => (current && current !== routedSessionId ? null : current))
}
return
}
// New stranded session id → reset the attempt counter.
if (retrySessionIdRef.current !== routedSessionId) {
retrySessionIdRef.current = routedSessionId
retryAttemptRef.current = 0
}
if (retryAttemptRef.current >= MAX_RESUME_RETRIES) {
// Give up auto-retrying a persistently dead backend; the user can still
// reconnect / reselect (which resets the counter via the branch above).
// Surface an explicit error + manual Retry in the chat view instead of
// spinning the loader forever — resumeSession (manual Retry / reconnect /
// reselect) clears this latch and resets the counter for a fresh cycle.
setResumeExhaustedSessionId(routedSessionId)
return
}
const attempt = retryAttemptRef.current
const sessionId = routedSessionId as string
const timer = setTimeout(() => {
// Re-check liveness at fire time: a resume may have landed while we waited.
if (
creatingSessionRef.current ||
selectedStoredSessionIdRef.current !== sessionId ||
activeSessionIdRef.current !== null
) {
return
}
// Consume an attempt ONLY now that a resume is actually dispatching.
// Incrementing at schedule time (the old behavior) let unrelated dep
// changes during the 1s8s backoff window — a transient gatewayState
// flip, a non-referentially-stable resumeSession — clear the pending
// timer and re-run the effect, burning an attempt without any resume
// having fired. A flapping backend could then hit MAX in a couple of
// re-renders with far fewer than MAX real attempts. (Point 3)
retryAttemptRef.current += 1
void resumeSession(sessionId, true)
}, resumeRetryDelayMs(attempt))
return () => clearTimeout(timer)
}, [
activeSessionIdRef,
creatingSessionRef,
currentView,
gatewayState,
resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId,
selectedStoredSessionIdRef
])
}

View File

@@ -3,9 +3,8 @@ import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getSessionMessages } from '@/hermes'
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
import { $currentCwd, $messages, $resumeFailedSessionId, setMessages, setResumeFailedSessionId } from '@/store/session'
import { $currentCwd } from '@/store/session'
import type { ClientSessionState } from '../../types'
@@ -118,142 +117,3 @@ describe('createBackendSessionForSend profile routing', () => {
expect(params).toMatchObject({ profile: 'default' })
})
})
// ── Resume failure recovery (the "stuck loading session window" bug) ──────────
// When session.resume rejects AND the REST transcript fallback ALSO fails, the
// hook must (a) not throw out of the fallback (which stranded the loader), and
// (b) arm $resumeFailedSessionId so use-route-resume can retry. A resume that
// succeeds must NOT leave the flag armed.
function ResumeHarness({
onReady,
requestGateway
}: {
onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(null),
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState)
})
useEffect(() => {
onReady(actions.resumeSession)
}, [actions.resumeSession, onReady])
return null
}
describe('resumeSession failure recovery', () => {
afterEach(() => {
cleanup()
setResumeFailedSessionId(null)
setMessages([])
vi.restoreAllMocks()
})
async function runResume(
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
): Promise<void> {
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(<ResumeHarness onReady={r => (resume = r)} requestGateway={requestGateway} />)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-1', true)
}
it('arms $resumeFailedSessionId when resume RPC and REST fallback both fail', async () => {
// session.resume rejects (e.g. timeout against a wedged backend)...
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
throw new Error('request timed out: session.resume')
}
return {} as never
})
// ...and the REST transcript fallback also rejects (backend unreachable).
vi.mocked(getSessionMessages).mockRejectedValue(new Error('network down'))
await runResume(requestGateway)
// The window is no longer silently stranded: the failure latch is armed for
// the stored session, which use-route-resume consumes to retry.
expect($resumeFailedSessionId.get()).toBe('stored-1')
})
it('does NOT arm the failure latch when the resume RPC fails but the REST fallback paints history', async () => {
// session.resume rejects, but the REST transcript fallback succeeds and
// hydrates a readable transcript — the window is NOT stranded.
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
throw new Error('request timed out: session.resume')
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({
messages: [
{ content: 'hello', role: 'user', timestamp: 1 },
{ content: 'hi there', role: 'assistant', timestamp: 2 }
],
session_id: 'stored-1'
} as never)
await runResume(requestGateway)
// Arming here would auto-retry a window that already shows history and,
// on exhaustion, blank that transcript behind the error overlay — a
// regression vs. plain fallback-success. The latch must stay clear.
expect($resumeFailedSessionId.get()).toBeNull()
// The fallback transcript is visible.
expect($messages.get().length).toBeGreaterThan(0)
})
it('does NOT throw out of the fallback when REST also fails (no unhandled rejection)', async () => {
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
throw new Error('request timed out: session.resume')
}
return {} as never
})
vi.mocked(getSessionMessages).mockRejectedValue(new Error('network down'))
// resumeSession must resolve (swallow the fallback failure), not reject.
await expect(runResume(requestGateway)).resolves.toBeUndefined()
})
it('leaves the failure latch clear when resume succeeds', async () => {
// Pre-arm to prove a successful resume clears it (entry-clear path).
setResumeFailedSessionId('stored-1')
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.resume') {
return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)
await runResume(requestGateway)
expect($resumeFailedSessionId.get()).toBeNull()
})
})

View File

@@ -38,8 +38,6 @@ import {
setFreshDraftReady,
setIntroSeed,
setMessages,
setResumeExhaustedSessionId,
setResumeFailedSessionId,
setSelectedStoredSessionId,
setSessions,
setSessionStartedAt,
@@ -581,15 +579,6 @@ export function useSessionActions({
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
// Optimistically clear any prior resume-failure latch for this session:
// we're attempting a fresh resume, so the self-heal in use-route-resume
// must not keep treating it as stranded. It's re-armed below only if THIS
// attempt fails terminally (RPC reject + REST fallback failure).
setResumeFailedSessionId(current => (current === storedSessionId ? null : current))
// Also clear the exhausted-latch: a fresh attempt (manual Retry, reconnect,
// reselect) gives the bounded auto-retry counter a clean cycle, so the
// chat view drops the error state and shows the loader again.
setResumeExhaustedSessionId(current => (current === storedSessionId ? null : current))
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
@@ -780,41 +769,13 @@ export function useSessionActions({
return
}
// The gateway resume RPC failed. Try the REST transcript as a fallback
// so the window at least shows history. CRITICAL: this fallback must be
// wrapped in its own try — if it ALSO throws (wedged/unreachable backend,
// the common case when resume failed in the first place), an unguarded
// throw here skips setMessages AND leaves activeSessionId null with an
// empty transcript. That is the exact state the thread loader latches on
// forever (messagesEmpty && !activeSessionId) with no recovery path —
// the "open in new window stays stuck loading, even after a nap" bug.
try {
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
if (!isCurrentResume()) {
return
}
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
} catch {
// Fallback also failed: nothing to paint. Leave whatever messages are
// already shown and fall through to arm the resume-failure latch so
// use-route-resume re-attempts the resume on the next render / window
// focus / gateway reconnect instead of stranding the loader.
}
if (isCurrentResume() && $messages.get().length === 0) {
// Arm the self-heal ONLY when the window is still empty: the gateway
// resume rejected AND the REST fallback failed to paint a transcript.
// That is the exact stranded state the loader latches on
// (messagesEmpty && !activeSessionId), and matches $resumeFailedSessionId's
// documented contract. If the REST fallback DID paint history, the
// window is readable — arming here would needlessly auto-retry and,
// once retries exhaust, blank that visible transcript behind the
// exhausted-state error overlay (a regression vs. plain fallback success).
setResumeFailedSessionId(storedSessionId)
if (!isCurrentResume()) {
return
}
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
notifyError(err, copy.resumeFailed)
} finally {
if (isCurrentResume()) {

View File

@@ -2,14 +2,12 @@ import { act, cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
import {
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$currentServiceTier,
$messages,
$turnStartedAt,
setCurrentFastMode,
setCurrentModel,
@@ -215,113 +213,3 @@ describe('useSessionStateCache — per-session turn timer', () => {
expect($currentFastMode.get()).toBe(false)
})
})
function userMessage(id: string, text: string): ChatMessage {
return { id, role: 'user', parts: [{ type: 'text', text }] }
}
function assistantText(id: string, text: string): ChatMessage {
return { id, role: 'assistant', parts: [{ type: 'text', text }] }
}
function assistantError(id: string, error: string): ChatMessage {
return { id, role: 'assistant', parts: [], error, pending: false }
}
interface ViewHarnessProps {
activeSessionId: string | null
onReady: (cache: Cache) => void
}
function ViewHarness({ activeSessionId, onReady }: ViewHarnessProps) {
const busyRef: MutableRefObject<boolean> = { current: false }
const cache = useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId: null,
setAwaitingResponse: () => undefined,
setBusy: () => undefined,
// Wire the published view back into the real $messages atom the flush
// reads from, so the round-trip matches production.
setMessages: messages => $messages.set(messages)
})
onReady(cache)
return null
}
describe('useSessionStateCache — cross-thread error isolation', () => {
afterEach(() => {
cleanup()
$messages.set([])
})
it('does not leak a failed turn into another thread on switch', () => {
$messages.set([])
let cache!: Cache
const { rerender } = render(<ViewHarness activeSessionId="thread-A" onReady={c => (cache = c)} />)
// Thread A ends its turn with an out-of-funds error and is on screen.
act(() => {
cache.updateSessionState(
'thread-A',
state => ({
...state,
busy: false,
messages: [userMessage('user-a', 'do the thing'), assistantError('assistant-a-error', 'Out of funds')]
}),
'stored-A'
)
})
expect($messages.get().some(message => message.error === 'Out of funds')).toBe(true)
// Switch to thread B (which completed cleanly). Its cached state syncs to
// the view while $messages still holds thread A's transcript.
rerender(<ViewHarness activeSessionId="thread-B" onReady={c => (cache = c)} />)
act(() => {
cache.updateSessionState(
'thread-B',
state => ({
...state,
busy: false,
messages: [userMessage('user-b', 'hello'), assistantText('assistant-b', 'hi there')]
}),
'stored-B'
)
})
expect($messages.get().map(message => message.id)).toEqual(['user-b', 'assistant-b'])
expect($messages.get().some(message => message.error === 'Out of funds')).toBe(false)
})
it('still preserves a same-session local error a heartbeat dropped', () => {
$messages.set([])
let cache!: Cache
render(<ViewHarness activeSessionId="thread-A" onReady={c => (cache = c)} />)
// First paint establishes thread A as the on-screen session.
act(() => {
cache.updateSessionState(
'thread-A',
state => ({ ...state, busy: false, messages: [userMessage('user-a', 'do the thing')] }),
'stored-A'
)
})
// A local error lands in the view (e.g. failAssistantMessage wrote it).
$messages.set([userMessage('user-a', 'do the thing'), assistantError('assistant-a-error', 'OpenRouter 403')])
// A later same-session heartbeat carries cached state that lost the error.
act(() => {
cache.updateSessionState('thread-A', state => ({
...state,
busy: false,
messages: [userMessage('user-a', 'do the thing')]
}))
})
expect($messages.get().some(message => message.error === 'OpenRouter 403')).toBe(true)
})
})

View File

@@ -79,9 +79,6 @@ export function useSessionStateCache({
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null)
const viewSyncRafRef = useRef<number | null>(null)
// Runtime id whose transcript currently occupies `$messages` — lets the
// flush below tell a same-session refresh from a thread switch.
const viewSessionIdRef = useRef<string | null>(null)
useEffect(() => {
activeSessionIdRef.current = activeSessionId
@@ -145,22 +142,12 @@ export function useSessionStateCache({
// jerks the scroll position while the user is reading. Skip the publish when
// the merged result is content-identical to what's already on screen.
const currentMessages = $messages.get()
// On a thread switch `$messages` still holds the *previous* thread, so
// preserving its local errors would graft that thread's failed turn (e.g.
// an out-of-funds error) onto this one — then cascade it everywhere as the
// polluted view becomes the next switch's baseline. Only carry errors
// across a same-session refresh; our cached state already keeps its own.
const nextMessages =
viewSessionIdRef.current === pending.sessionId
? preserveLocalAssistantErrors(pending.state.messages, currentMessages)
: pending.state.messages
const nextMessages = preserveLocalAssistantErrors(pending.state.messages, currentMessages)
if (!sameMessageList(nextMessages, currentMessages)) {
setMessages(nextMessages)
}
viewSessionIdRef.current = pending.sessionId
syncRuntimeMetadataToView(pending.state)
setBusy(pending.state.busy)
setMutableRef(busyRef, pending.state.busy)

View File

@@ -23,7 +23,6 @@ import { fieldCopyForSchemaKey } from './field-copy'
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
import { ModelSettings } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
import { ProviderConfigPanel } from './provider-config-panel'
function ConfigField({
schemaKey,
@@ -369,9 +368,6 @@ export function ConfigSettings({
schemaKey={key}
value={getNested(config, key)}
/>
{key === 'memory.provider' && typeof getNested(config, key) === 'string' && getNested(config, key) ? (
<ProviderConfigPanel provider={String(getNested(config, key))} />
) : null}
</div>
))}
</div>

View File

@@ -239,7 +239,7 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'code_execution.mode': ['project', 'strict'],
'context.engine': ['compressor', 'default', 'custom'],
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'],
'memory.provider': ['', 'builtin', 'hindsight', 'honcho'],
'memory.provider': ['', 'builtin', 'honcho'],
// Terminal execution backends — kept in sync with the dispatch ladder in
// tools/terminal_tool.py::_create_environment (local/docker/singularity/
// modal/daytona/ssh). Remote backends need extra env (image, tokens, host).

View File

@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
import { useI18n } from '@/i18n'
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor, Network } from '@/lib/icons'
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $profiles, refreshActiveProfile } from '@/store/profile'
@@ -13,10 +13,9 @@ import { $profiles, refreshActiveProfile } from '@/store/profile'
import { CONTROL_TEXT } from './constants'
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
type Mode = 'local' | 'remote' | 'ssh'
type Mode = 'local' | 'remote'
type AuthMode = 'oauth' | 'token'
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
type SshTestStatus = 'idle' | 'testing' | 'ok' | 'error'
interface GatewaySettingsState {
envOverride: boolean
@@ -26,11 +25,6 @@ interface GatewaySettingsState {
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
sshHost: string
sshUser: string
sshPort: number | null
sshKeyPath: string
sshRemoteHermesPath: string
}
const EMPTY_STATE: GatewaySettingsState = {
@@ -40,12 +34,7 @@ const EMPTY_STATE: GatewaySettingsState = {
remoteOauthConnected: false,
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: '',
sshHost: '',
sshUser: '',
sshPort: null,
sshKeyPath: '',
sshRemoteHermesPath: ''
remoteUrl: ''
}
function ModeCard({
@@ -116,12 +105,6 @@ export function GatewaySettings() {
const [remoteToken, setRemoteToken] = useState('')
const [lastTest, setLastTest] = useState<null | string>(null)
// SSH-mode local UI state: the connection test result, ~/.ssh/config host
// suggestions, and the `ssh -G` resolution of the entered host.
const [sshTestStatus, setSshTestStatus] = useState<SshTestStatus>('idle')
const [sshTestMessage, setSshTestMessage] = useState<null | string>(null)
const [sshHostSuggestions, setSshHostSuggestions] = useState<string[]>([])
// Connection scope: null = the global/default connection (the original
// behavior); a profile name = that profile's per-profile remote override, so
// each profile can point at its own backend.
@@ -282,23 +265,6 @@ export function GatewaySettings() {
// per-profile scopes are the named, non-default profiles.
const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles])
// Load ~/.ssh/config host suggestions once SSH mode is active (read-only).
useEffect(() => {
if (state.mode !== 'ssh') return
const desktop = window.hermesDesktop
if (!desktop?.sshConfigHosts) return
let cancelled = false
desktop
.sshConfigHosts()
.then(result => {
if (!cancelled) setSshHostSuggestions(result.hosts || [])
})
.catch(() => {
if (!cancelled) setSshHostSuggestions([])
})
return () => void (cancelled = true)
}, [state.mode])
const oauthConnected = state.remoteOauthConnected
const canUseRemote = useMemo(() => {
@@ -441,7 +407,7 @@ export function GatewaySettings() {
remoteUrl: trimmedUrl
})
const message = g.connectedTo(result.baseUrl ?? trimmedUrl, result.version ?? undefined)
const message = g.connectedTo(result.baseUrl, result.version ?? undefined)
setLastTest(message)
notify({ kind: 'success', title: g.reachableTitle, message })
} catch (err) {
@@ -451,108 +417,6 @@ export function GatewaySettings() {
}
}
// --- SSH mode -------------------------------------------------------------
const canUseSsh = Boolean(state.sshHost.trim())
const sshPayload = () => ({
mode: 'ssh' as const,
profile: scope ?? undefined,
sshHost: state.sshHost.trim(),
sshUser: state.sshUser.trim() || undefined,
sshPort: state.sshPort ?? undefined,
sshKeyPath: state.sshKeyPath.trim() || undefined,
sshRemoteHermesPath: state.sshRemoteHermesPath.trim() || undefined
})
// Map an SSH test error kind to actionable copy.
const sshErrorMessage = (kind: string | null | undefined, raw: string | null | undefined): string => {
switch (kind) {
case 'auth-failed':
return g.sshErrAuth
case 'unreachable':
return g.sshErrUnreachable
case 'host-key-changed':
return g.sshErrHostKey
case 'hermes-not-found':
return g.sshErrNotInstalled
case 'unsupported-platform':
return g.sshErrPlatform
case 'timeout':
return g.sshErrTimeout
default:
return raw || g.sshErrUnknown
}
}
const sshTest = async () => {
if (!canUseSsh) {
notify({ kind: 'warning', title: g.incompleteTitle, message: g.sshIncompleteHost })
return
}
setSshTestStatus('testing')
setSshTestMessage(null)
try {
const result = await window.hermesDesktop.testConnectionConfig(sshPayload())
if (result.reachable) {
const message = g.sshReachable(result.host ?? state.sshHost, result.remotePlatform ?? '?')
setSshTestStatus('ok')
setSshTestMessage(message)
notify({ kind: 'success', title: g.reachableTitle, message })
} else {
const message = sshErrorMessage(result.sshError, result.error)
setSshTestStatus('error')
setSshTestMessage(message)
notify({ kind: 'warning', title: g.testFailed, message })
}
} catch (err) {
setSshTestStatus('error')
setSshTestMessage(err instanceof Error ? err.message : String(err))
notifyError(err, g.testFailed)
}
}
// Resolve the entered host via `ssh -G` and fill in any blank user/port the
// alias expands to (so the saved config matches what ssh will actually use).
const sshResolve = async () => {
const host = state.sshHost.trim()
if (!host || !window.hermesDesktop?.sshResolveHost) return
try {
const resolved = await window.hermesDesktop.sshResolveHost(host)
setState(current => ({
...current,
sshUser: current.sshUser.trim() || resolved.user || '',
sshPort: current.sshPort ?? (resolved.port && resolved.port !== 22 ? resolved.port : null),
sshKeyPath: current.sshKeyPath.trim() || resolved.identityFile || ''
}))
} catch {
// best-effort enrichment; leave the fields as entered
}
}
const sshSave = async (apply: boolean) => {
if (!canUseSsh) {
notify({ kind: 'warning', title: g.incompleteTitle, message: g.sshIncompleteHost })
return
}
setSaving(true)
try {
const next = apply
? await window.hermesDesktop.applyConnectionConfig(sshPayload())
: await window.hermesDesktop.saveConnectionConfig(sshPayload())
setState(next)
notify({
kind: 'success',
title: apply ? g.restartingTitle : g.savedTitle,
message: apply ? g.restartingMessage : g.savedMessage
})
} catch (err) {
notifyError(err, apply ? g.applyFailed : g.saveFailed)
} finally {
setSaving(false)
}
}
if (loading) {
return <LoadingState label={g.loading} />
}
@@ -613,7 +477,7 @@ export function GatewaySettings() {
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2">
<ModeCard
active={state.mode === 'local'}
description={g.localDesc}
@@ -630,32 +494,22 @@ export function GatewaySettings() {
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
title={g.remoteTitle}
/>
<ModeCard
active={state.mode === 'ssh'}
description={g.sshDesc}
disabled={state.envOverride}
icon={Network}
onSelect={() => setState(current => ({ ...current, mode: 'ssh' }))}
title={g.sshTitle}
/>
</div>
<div className="mt-5 grid gap-1">
{state.mode === 'remote' ? (
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))}
placeholder="https://gateway.example.com/hermes"
value={state.remoteUrl}
/>
}
description={g.remoteUrlDesc}
title={g.remoteUrlTitle}
/>
) : null}
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))}
placeholder="https://gateway.example.com/hermes"
value={state.remoteUrl}
/>
}
description={g.remoteUrlDesc}
title={g.remoteUrlTitle}
/>
{state.mode === 'remote' && probeStatus === 'probing' ? (
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
@@ -725,159 +579,28 @@ export function GatewaySettings() {
title={g.tokenTitle}
/>
) : null}
{/* SSH mode: connect via the box's SSH access; no token to copy. */}
{state.mode === 'ssh' ? (
<>
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
list="hermes-ssh-host-suggestions"
onBlur={() => void sshResolve()}
onChange={event => setState(current => ({ ...current, sshHost: event.target.value }))}
placeholder="user@mac-mini.local or mac-mini"
value={state.sshHost}
/>
}
description={g.sshHostDesc}
title={g.sshHostTitle}
/>
{sshHostSuggestions.length > 0 ? (
<datalist id="hermes-ssh-host-suggestions">
{sshHostSuggestions.map(host => (
<option key={host} value={host} />
))}
</datalist>
) : null}
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setState(current => ({ ...current, sshUser: event.target.value }))}
placeholder={g.sshUserPlaceholder}
value={state.sshUser}
/>
}
description={g.sshUserDesc}
title={g.sshUserTitle}
/>
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event =>
setState(current => ({
...current,
sshPort: event.target.value.trim() ? Number.parseInt(event.target.value, 10) || null : null
}))
}
placeholder="22"
value={state.sshPort != null ? String(state.sshPort) : ''}
/>
}
description={g.sshPortDesc}
title={g.sshPortTitle}
/>
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setState(current => ({ ...current, sshKeyPath: event.target.value }))}
placeholder="~/.ssh/id_ed25519"
value={state.sshKeyPath}
/>
}
description={g.sshKeyDesc}
title={g.sshKeyTitle}
/>
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setState(current => ({ ...current, sshRemoteHermesPath: event.target.value }))}
placeholder={g.sshHermesPathPlaceholder}
value={state.sshRemoteHermesPath}
/>
}
description={g.sshHermesPathDesc}
title={g.sshHermesPathTitle}
/>
{sshTestStatus !== 'idle' && sshTestMessage ? (
<div
className={cn(
'flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)]',
sshTestStatus === 'ok' ? 'text-primary' : 'text-(--ui-text-tertiary)'
)}
>
{sshTestStatus === 'testing' ? (
<Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" />
) : sshTestStatus === 'ok' ? (
<Check className="mt-0.5 size-4 shrink-0" />
) : (
<AlertCircle className="mt-0.5 size-4 shrink-0" />
)}
<span>{sshTestMessage}</span>
</div>
) : null}
</>
) : null}
</div>
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
{state.mode === 'ssh' ? (
<>
<Button
className="mr-auto"
disabled={state.envOverride || sshTestStatus === 'testing' || !canUseSsh}
onClick={() => void sshTest()}
size="sm"
variant="text"
>
{sshTestStatus === 'testing' ? <Loader2 className="animate-spin" /> : null}
{g.sshTestConnection}
</Button>
<Button
disabled={state.envOverride || saving}
onClick={() => void sshSave(false)}
size="sm"
variant="textStrong"
>
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving || !canUseSsh} onClick={() => void sshSave(true)} size="sm">
{saving ? <Loader2 className="animate-spin" /> : null}
{g.sshConnect}
</Button>
</>
) : (
<>
<Button
className="mr-auto"
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
size="sm"
variant="text"
>
{testing ? <Loader2 className="animate-spin" /> : null}
{g.testRemote}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="animate-spin" /> : null}
{g.saveAndReconnect}
</Button>
</>
)}
<Button
className="mr-auto"
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
size="sm"
variant="text"
>
{testing ? <Loader2 className="animate-spin" /> : null}
{g.testRemote}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="animate-spin" /> : null}
{g.saveAndReconnect}
</Button>
</div>
<div className="mt-6 grid gap-1">

View File

@@ -6,12 +6,6 @@ import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from
import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
describe('settings helpers', () => {
it('lists Hindsight as a built-in desktop memory provider option', () => {
const options = enumOptionsFor('memory.provider', '', {})
expect(options).toContain('hindsight')
})
describe('defineFieldCopy', () => {
it('flattens nested field copy paths', () => {
const copy = defineFieldCopy({

View File

@@ -1,142 +0,0 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MemoryProviderConfig } from '@/types/hermes'
const getMemoryProviderConfig = vi.fn()
const saveMemoryProviderConfig = vi.fn()
vi.mock('@/hermes', () => ({
getMemoryProviderConfig: (provider: string) => getMemoryProviderConfig(provider),
saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
}))
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
function hindsightSchema(overrides: Partial<MemoryProviderConfig['fields'][number]>[] = []): MemoryProviderConfig {
const fields: MemoryProviderConfig['fields'] = [
{
key: 'mode',
label: 'Mode',
kind: 'select',
value: 'cloud',
description: 'How Hermes connects to Hindsight.',
placeholder: '',
is_set: true,
options: [
{ value: 'cloud', label: 'Cloud', description: 'Hindsight Cloud API (lightweight, just needs an API key)' },
{ value: 'local_external', label: 'Local External', description: 'Connect to an existing Hindsight instance' }
]
},
{
key: 'api_key',
label: 'API key',
kind: 'secret',
value: '',
description: 'Used to authenticate with the Hindsight API.',
placeholder: 'Enter Hindsight API key',
is_set: false,
options: []
},
{
key: 'api_url',
label: 'API URL',
kind: 'text',
value: 'https://api.hindsight.vectorize.io',
description: '',
placeholder: '',
is_set: true,
options: []
},
{ key: 'bank_id', label: 'Bank ID', kind: 'text', value: 'hermes', description: '', placeholder: '', is_set: true, options: [] },
{
key: 'recall_budget',
label: 'Recall budget',
kind: 'select',
value: 'mid',
description: '',
placeholder: '',
is_set: true,
options: [
{ value: 'low', label: 'low', description: '' },
{ value: 'mid', label: 'mid', description: '' },
{ value: 'high', label: 'high', description: '' }
]
}
]
return {
name: 'hindsight',
label: 'Hindsight',
fields: fields.map((field, index) => ({ ...field, ...overrides[index] }))
}
}
beforeEach(() => {
getMemoryProviderConfig.mockResolvedValue(hindsightSchema())
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderPanel(provider = 'hindsight') {
const { ProviderConfigPanel } = await import('./provider-config-panel')
return render(<ProviderConfigPanel provider={provider} />)
}
describe('ProviderConfigPanel', () => {
it('renders the declared provider fields generically', async () => {
await renderPanel()
expect(await screen.findByDisplayValue('https://api.hindsight.vectorize.io')).toBeTruthy()
expect(screen.getByDisplayValue('hermes')).toBeTruthy()
expect(screen.getByText('Cloud')).toBeTruthy()
expect(screen.getAllByText('Hindsight Cloud API (lightweight, just needs an API key)').length).toBeGreaterThan(0)
expect(screen.getByText('mid')).toBeTruthy()
})
it('collapses and expands the fields', async () => {
await renderPanel()
expect(await screen.findByLabelText('API URL')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
expect(screen.queryByLabelText('API URL')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
expect(await screen.findByLabelText('API URL')).toBeTruthy()
})
it('saves edited values without requiring a secret replacement', async () => {
await renderPanel()
const apiUrl = await screen.findByLabelText('API URL')
fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } })
fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() =>
expect(saveMemoryProviderConfig).toHaveBeenCalledWith('hindsight', {
mode: 'cloud',
api_key: '',
api_url: 'http://localhost:8888',
bank_id: 'ben-bank',
recall_budget: 'mid'
})
)
})
it('renders nothing for a provider with no declared config surface', async () => {
getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', fields: [] })
const { container } = await renderPanel('builtin')
await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin'))
expect(container.querySelector('section')).toBeNull()
})
})

View File

@@ -1,182 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
import { Check, Loader2, Save } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { LoadingState, Pill } from './primitives'
/** Seed editable values from the schema: non-secret fields keep their current
* value, secret fields start blank (their value is never returned). */
function seedValues(config: MemoryProviderConfig): Record<string, string> {
return Object.fromEntries(
config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value])
)
}
function FieldControl({
field,
value,
onChange
}: {
field: MemoryProviderField
value: string
onChange: (value: string) => void
}) {
if (field.kind === 'select') {
const selected = field.options.find(option => option.value === value)
return (
<>
<Select onValueChange={onChange} value={value}>
<SelectTrigger className={CONTROL_TEXT}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{(selected?.description || field.description) && (
<span className="text-xs text-muted-foreground">{selected?.description || field.description}</span>
)}
</>
)
}
if (field.kind === 'secret') {
return (
<div className="flex flex-wrap items-center gap-2">
<Input
className="min-w-64 flex-1 font-mono"
onChange={event => onChange(event.target.value)}
placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder}
type="password"
value={value}
/>
{field.is_set && (
<Pill tone="primary">
<Check className="size-3" />
Set
</Pill>
)}
</div>
)
}
return (
<Input
className="font-mono"
onChange={event => onChange(event.target.value)}
placeholder={field.placeholder}
value={value}
/>
)
}
export function ProviderConfigPanel({ provider }: { provider: string }) {
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
const [values, setValues] = useState<Record<string, string>>({})
const [expanded, setExpanded] = useState(true)
const [saving, setSaving] = useState(false)
const refresh = useCallback(async () => {
try {
const next = await getMemoryProviderConfig(provider)
setConfig(next)
setValues(seedValues(next))
} catch (err) {
notifyError(err, 'Memory provider settings failed to load')
setConfig(null)
}
}, [provider])
useEffect(() => {
setConfig(null)
void refresh()
}, [refresh])
const save = useCallback(async () => {
if (!config) {
return
}
setSaving(true)
try {
await saveMemoryProviderConfig(provider, values)
notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' })
await refresh()
} catch (err) {
notifyError(err, `Failed to save ${config.label} settings`)
} finally {
setSaving(false)
}
}, [config, provider, refresh, values])
// Providers without a declared config surface (e.g. builtin) render nothing.
if (config && config.fields.length === 0) {
return null
}
if (!config) {
return <LoadingState label="Loading memory provider settings..." />
}
const secretFields = config.fields.filter(field => field.kind === 'secret')
return (
<section className="py-3">
<button
aria-expanded={expanded}
className="flex w-full items-center justify-between gap-3 rounded-lg bg-background/60 px-3 py-2 text-left hover:bg-accent/50"
onClick={() => setExpanded(open => !open)}
type="button"
>
<span className="flex min-w-0 items-center gap-2">
<DisclosureCaret open={expanded} />
<span className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{config.label} settings
</span>
{secretFields.map(field => (
<Pill key={field.key}>{field.is_set ? `${field.label} set` : `${field.label} not set`}</Pill>
))}
</span>
</button>
{expanded && (
<div className="mt-3 grid gap-4 rounded-xl bg-background/60 p-4">
{config.fields.map(field => (
<label className="grid gap-1.5" key={field.key}>
<span className="text-xs font-medium text-muted-foreground">{field.label}</span>
<FieldControl
field={field}
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
value={values[field.key] ?? ''}
/>
{field.kind !== 'select' && field.description && (
<span className="text-xs text-muted-foreground">{field.description}</span>
)}
</label>
))}
<div className="flex justify-end">
<Button disabled={saving} onClick={() => void save()} size="sm">
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
Save
</Button>
</div>
</div>
)}
</section>
)
}

View File

@@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
import type { OAuthProvider } from '@/types/hermes'
const listOAuthProviders = vi.fn()
const disconnectOAuthProvider = vi.fn()
@@ -36,25 +36,6 @@ function provider(id: string, loggedIn: boolean, patch: Partial<OAuthProvider> =
}
}
// One `/api/env` row (an EnvVarInfo) for the API-keys view. Mirrors the
// `provider()` factory above: a valid base + per-test overrides, typed against
// the real response shape so it can't drift from EnvVarInfo.
function keyVar(patch: Partial<EnvVarInfo> = {}): EnvVarInfo {
return {
advanced: false,
category: 'provider',
description: '',
is_password: true,
is_set: false,
provider: '',
provider_label: '',
redacted_value: null,
tools: [],
url: '',
...patch
}
}
beforeEach(() => {
onboarding.set({ manual: false })
getEnvVars.mockResolvedValue({})
@@ -116,56 +97,4 @@ describe('ProvidersSettings', () => {
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
expect(screen.getByText(/managed by its own CLI/)).toBeTruthy()
})
it('renders a Keys card for a backend-tagged provider with no PROVIDER_GROUPS prefix', async () => {
// A provider the backend catalog tags (provider/provider_label) but that has
// no desktop PROVIDER_GROUPS prefix row must still render its own card —
// this is the GUI/CLI drift fix: membership comes from the backend, not
// from the hand-maintained prefix list.
getEnvVars.mockResolvedValue({
WIDGETAI_API_KEY: keyVar({
provider: 'widgetai',
provider_label: 'WidgetAI',
url: 'https://widgetai.example/keys'
})
})
listOAuthProviders.mockResolvedValue({ providers: [] })
const { ProvidersSettings } = await import('./providers-settings')
render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />)
expect(await screen.findByText('WidgetAI')).toBeTruthy()
})
it('orders API-key providers by priority then name, and filters them via search', async () => {
// These three providers have no curated PROVIDER_GROUPS priority, so they
// share the default priority and fall back to alphabetical among themselves
// (Acme, Middle, Zebra) — exercising the name tiebreak of the priority sort.
getEnvVars.mockResolvedValue({
ZEBRA_API_KEY: keyVar({ provider: 'zebra', provider_label: 'Zebra' }),
ACME_API_KEY: keyVar({ provider: 'acme', provider_label: 'Acme' }),
MIDDLE_API_KEY: keyVar({ provider: 'middle', provider_label: 'Middle' })
})
listOAuthProviders.mockResolvedValue({ providers: [] })
const { ProvidersSettings } = await import('./providers-settings')
render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />)
// Equal priority → alphabetical tiebreak: Acme, Middle, Zebra.
await screen.findByText('Acme')
const labels = screen.getAllByText(/Acme|Middle|Zebra/).map(el => el.textContent)
expect(labels).toEqual(['Acme', 'Middle', 'Zebra'])
// Typing narrows the list to matching providers only.
const search = screen.getByPlaceholderText('Search providers…')
fireEvent.change(search, { target: { value: 'mid' } })
await waitFor(() => expect(screen.queryByText('Acme')).toBeNull())
expect(screen.getByText('Middle')).toBeTruthy()
expect(screen.queryByText('Zebra')).toBeNull()
// A non-matching query shows the empty-state copy.
fireEvent.change(search, { target: { value: 'nonesuch-xyz' } })
expect(await screen.findByText('No providers match your search.')).toBeTruthy()
})
})

View File

@@ -12,7 +12,6 @@ import {
sortProviders
} from '@/components/desktop-onboarding-overlay'
import { Button } from '@/components/ui/button'
import { SearchField } from '@/components/ui/search-field'
import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons'
@@ -46,17 +45,8 @@ export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
export type ProviderView = (typeof PROVIDER_VIEWS)[number]
// Group the env catalog by provider — one ListRow per vendor plus optional
// advanced overrides (base URL, region, etc.). Groups without a key field are
// skipped.
//
// Grouping key precedence:
// 1. Backend `provider_label` / `provider` (from the unified provider catalog
// in hermes_cli/provider_catalog.py) — the SAME provider identity
// `hermes model` uses. This is authoritative: a provider tagged by the
// backend always renders a card, even with no PROVIDER_GROUPS row.
// 2. Desktop prefix match (`providerGroup`) — legacy fallback for provider
// env vars that predate the backend tagging.
// Only entries that resolve to neither (the "Other" bucket) are skipped.
// advanced overrides (base URL, region, etc.). Groups without a key field and
// the "Other" bucket are skipped.
function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGroup[] {
const buckets = new Map<string, [string, EnvVarInfo][]>()
@@ -65,9 +55,7 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
continue
}
// Prefer the backend-supplied provider label/id so the Keys tab groups by
// the same identity the CLI picker uses; fall back to the prefix guess.
const name = info.provider_label?.trim() || info.provider?.trim() || providerGroup(key)
const name = providerGroup(key)
if (name === 'Other') {
continue
@@ -85,9 +73,6 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
continue
}
// Presentation overlay (priority, blurb, docs) is keyed by the prefix-based
// group name; when the backend introduced this provider it may have no
// overlay entry, so fall back to the backend/env metadata for display.
const meta = providerMeta(name)
groups.push({
@@ -146,7 +131,6 @@ function OAuthPicker({
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
// Keep connected accounts grouped and always visible; only the unconnected
// providers hide behind the disclosure, so the page leads with what's set up.
// Both lists preserve `sortProviders` order (curated priority, then name).
const connected = rest.filter(p => p.status?.logged_in)
const others = rest.filter(p => !p.status?.logged_in)
const collapsible = others.length > 0
@@ -300,8 +284,6 @@ export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSett
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
const [openProvider, setOpenProvider] = useState<null | string>(null)
const [disconnecting, setDisconnecting] = useState<null | string>(null)
// Free-text filter for the API-keys view (provider name / env-var key / desc).
const [keyQuery, setKeyQuery] = useState('')
// The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
// re-read connection state when the user finishes (or dismisses) a sign-in
// they launched from this page — otherwise the cards keep their stale status.
@@ -390,49 +372,20 @@ export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSett
const keyGroups = buildProviderKeyGroups(vars)
if (showApiKeys) {
const q = keyQuery.trim().toLowerCase()
const visibleGroups = q
? keyGroups.filter(group => {
const haystack = [
group.name,
group.description ?? '',
group.primary[0],
...group.advanced.map(([k]) => k)
]
return haystack.some(s => s.toLowerCase().includes(q))
})
: keyGroups
return (
<SettingsContent>
{keyGroups.length > 0 ? (
<div className="grid gap-3">
<SearchField
aria-label={t.settings.providers.searchKeys}
containerClassName="w-full"
onChange={setKeyQuery}
placeholder={t.settings.providers.searchKeys}
value={keyQuery}
/>
{visibleGroups.length > 0 ? (
<div className="grid gap-2">
{visibleGroups.map(group => (
<ProviderKeyRows
expanded={openProvider === group.name}
group={group}
key={group.name}
onExpand={() => setOpenProvider(group.name)}
onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))}
rowProps={rowProps}
/>
))}
</div>
) : (
<div className="grid min-h-24 place-items-center px-4 py-6 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
{t.settings.providers.noKeysMatch}
</div>
)}
<div className="grid gap-2">
{keyGroups.map(group => (
<ProviderKeyRows
expanded={openProvider === group.name}
group={group}
key={group.name}
onExpand={() => setOpenProvider(group.name)}
onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))}
rowProps={rowProps}
/>
))}
</div>
) : (
<NoProviderKeys />

View File

@@ -272,10 +272,7 @@ function PostSetupRunner({ toolset, postSetupKey, onComplete }: PostSetupRunnerP
</div>
{status && (status.lines.length > 0 || status.running) && (
<pre
className="max-h-48 overflow-y-auto rounded-md bg-background px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground whitespace-pre-wrap"
data-selectable-text="true"
>
<pre className="max-h-48 overflow-y-auto rounded-md bg-background px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground whitespace-pre-wrap">
{status.lines.length > 0 ? status.lines.join('\n') : copy.postSetupStarting}
</pre>
)}

View File

@@ -4,7 +4,6 @@ import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import {
Activity,
@@ -13,7 +12,6 @@ import {
Command,
Hash,
Loader2,
Network,
Sparkles,
Terminal,
Zap,
@@ -37,7 +35,6 @@ import {
setYoloActive
} from '@/store/session'
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
import { $gatewayRestarting } from '@/store/system-actions'
import {
$backendUpdateApply,
$backendUpdateStatus,
@@ -48,7 +45,7 @@ import {
} from '@/store/updates'
import type { StatusResponse } from '@/types/hermes'
import { CRON_ROUTE, SETTINGS_ROUTE } from '../../routes'
import { CRON_ROUTE } from '../../routes'
import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-controls'
interface StatusbarItemsOptions {
@@ -92,7 +89,6 @@ export function useStatusbarItems({
const busy = useStore($busy)
const currentUsage = useStore($currentUsage)
const desktopActionTasks = useStore($desktopActionTasks)
const gatewayRestarting = useStore($gatewayRestarting)
const previewServerRestartStatus = useStore($previewServerRestartStatus)
const sessionStartedAt = useStore($sessionStartedAt)
const turnStartedAt = useStore($turnStartedAt)
@@ -292,68 +288,8 @@ export function useStatusbarItems({
copy
])
// Connection-identity pill (VS Code's load-bearing "where am I?" cue). Shown
// only for remote connections; hidden in local mode (the unmarked default).
// SSH remotes read "SSH: user@host"; token/oauth remotes read "Remote: host"
// — closing the same gap for the existing remote modes. Clicking opens the
// gateway connection settings so the pill doubles as the switch/disconnect
// entry point.
const connectionItem = useMemo<StatusbarItem | null>(() => {
if (connection?.mode !== 'remote') {
return null
}
// Prefer the host main.cjs put on the descriptor; fall back to parsing the
// backend URL (never the 127.0.0.1 tunnel — that's only the SSH baseUrl,
// and SSH descriptors always carry remoteHost).
let host = connection.remoteHost ?? ''
if (!host && connection.baseUrl) {
try {
host = new URL(connection.baseUrl).host
} catch {
host = ''
}
}
if (!host) {
return null
}
const isSsh = connection.remoteKind === 'ssh'
const label = isSsh ? copy.connectionSsh(host) : copy.connectionRemote(host)
const baseTooltip = isSsh ? copy.connectionSshTooltip(host) : copy.connectionRemoteTooltip(host)
// Append the per-profile scope when this is a profile-scoped connection, so
// the pill discloses WHICH profile the host backs (not just the host).
const profile = connection.profile
const title = profile ? `${baseTooltip} · ${profile}` : baseTooltip
return {
// VS Code-style remote indicator: a solid colored block (not a muted
// pill) so "you are running on a remote host" is unmistakable, pinned to
// the FAR LEFT of the status bar. SSH gets the primary accent; a plain URL
// remote gets a calmer tint so the two are visually distinct.
className: cn(
'px-2 font-medium',
isSsh
? 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground'
: 'bg-accent text-accent-foreground hover:bg-accent/90 hover:text-accent-foreground'
),
icon: <Network className="size-3" />,
id: 'connection',
label,
title,
// Deep-link straight to the Gateway connection panel (the settings index
// reads ?tab=), so the pill lands the user where they manage/switch it.
// NB: default (button) variant — NOT 'link', which renders an <a href> and
// would swallow the in-app `to:` navigation.
to: `${SETTINGS_ROUTE}?tab=gateway`
}
}, [connection?.mode, connection?.remoteHost, connection?.remoteKind, connection?.baseUrl, connection?.profile, copy])
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
() => [
// Remote-connection indicator pinned to the far left (VS Code parity) —
// first thing in the bar so "where am I running" is the dominant cue.
// Absent in local mode.
...(connectionItem ? [connectionItem] : []),
{
className: `w-7 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
icon: <Command className="size-3.5" />,
@@ -363,15 +299,9 @@ export function useStatusbarItems({
variant: 'action'
},
{
className: gatewayRestarting ? undefined : gatewayClassName,
detail: gatewayRestarting ? copy.gatewayRestarting : gatewayDetail,
icon: gatewayRestarting ? (
<GlyphSpinner ariaLabel={copy.gatewayRestarting} className="size-3" />
) : inferenceReady ? (
<Activity className="size-3" />
) : (
<AlertCircle className="size-3" />
),
className: gatewayClassName,
detail: gatewayDetail,
icon: inferenceReady ? <Activity className="size-3" /> : <AlertCircle className="size-3" />,
id: 'gateway-health',
label: copy.gateway,
menuClassName: 'w-72',
@@ -420,12 +350,10 @@ export function useStatusbarItems({
bgFailed,
bgRunning,
commandCenterOpen,
connectionItem,
copy,
gatewayMenuContent,
gatewayClassName,
gatewayDetail,
gatewayRestarting,
inferenceReady,
inferenceStatus?.reason,
openAgents,

View File

@@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { createContext, useContext, useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
@@ -62,8 +62,6 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
const copy = t.shell.modelMenu
const closeMenu = useContext(ModelMenuCloseContext)
const [search, setSearch] = useState('')
const [refreshing, setRefreshing] = useState(false)
const queryClient = useQueryClient()
// Reactive session state is read from the stores here (not drilled in), so
// toggling effort/fast/model re-renders this panel in place without forcing
// the parent to rebuild the menu content (which would close the dropdown).
@@ -112,38 +110,6 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
// next session.create (see selectModel). The default lives in Settings → Model.
const switchTo = (model: string, provider: string) => onSelectModel({ model, provider })
// Explicit "Refresh Models": re-fetch the catalog with refresh:true so the
// backend busts its 1h provider-model disk cache and re-pulls each provider's
// live list. Fixes live-only models (e.g. OpenCode Zen free tier) vanishing
// when the cache expires and falls back to the curated static list.
const refreshModels = async () => {
if (refreshing) {
return
}
setRefreshing(true)
try {
const queryKey = ['model-options', activeSessionId || 'global']
const next =
gateway && activeSessionId
? await gateway.request<ModelOptionsResponse>('model.options', {
session_id: activeSessionId,
refresh: true
})
: await getGlobalModelOptions({ refresh: true })
queryClient.setQueryData<ModelOptionsResponse>(queryKey, next)
} catch {
// Network/backend hiccup — fall back to a plain invalidate so the next
// open re-fetches (still cached, but no worse than before).
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
} finally {
setRefreshing(false)
}
}
// Selecting a model row restores that model's remembered preset onto the
// session (effort/fast), gated by capability. Unset → Hermes defaults.
const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => {
@@ -302,18 +268,6 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
disabled={refreshing}
onSelect={event => {
event.preventDefault()
void refreshModels()
}}
>
<Codicon className={cn('mr-1.5', refreshing && 'animate-spin')} name="sync" size="0.75rem" />
{copy.refreshModels}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
onSelect={() => setModelVisibilityOpen(true)}

View File

@@ -106,13 +106,6 @@ export interface SkillCommandDispatchResponse {
export interface SendCommandDispatchResponse {
type: 'send'
message: string
notice?: string
}
export interface PrefillCommandDispatchResponse {
type: 'prefill'
message: string
notice?: string
}
export type CommandDispatchResponse =
@@ -120,7 +113,6 @@ export type CommandDispatchResponse =
| AliasCommandDispatchResponse
| SkillCommandDispatchResponse
| SendCommandDispatchResponse
| PrefillCommandDispatchResponse
export type SidebarNavId = 'artifacts' | 'command-center' | 'messaging' | 'new-session' | 'settings' | 'skills'

View File

@@ -1,129 +0,0 @@
// Lists and blockquotes have chrome beside the text (markers, the quote
// border) whose side is driven by the box's CSS direction, which the
// unicode-bidi:plaintext rules never touch. These tests pin the split of
// responsibilities: ul/ol/blockquote carry dir="auto" so the browser
// resolves their box direction from content, inline code carries dir="ltr"
// so it neither votes in that resolution nor reorders, and plain prose
// blocks stay attribute-free (the plaintext CSS owns them). jsdom does not
// resolve dir="auto", so the contract is asserted at the attribute level.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { Thread } from './thread'
const createdAt = new Date('2026-06-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'hi' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(text: string): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function Harness({ text }: { text: string }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage(text)],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('block-level direction chrome', () => {
it('lists carry dir="auto" so markers follow the resolved direction', async () => {
render(<Harness text={'מקומות:\n\n1. חוף גורדון\n2. שוק הכרמל\n\n- פריט\n- item'} />)
const item = await screen.findByText(/חוף גורדון/)
expect(item.closest('ol')?.getAttribute('dir')).toBe('auto')
const bullet = await screen.findByText(/פריט/)
expect(bullet.closest('ul')?.getAttribute('dir')).toBe('auto')
})
it('blockquotes carry dir="auto" so the border follows the resolved direction', async () => {
render(<Harness text={'> ציטוט קצר בעברית'} />)
const quote = await screen.findByText(/ציטוט קצר/)
expect(quote.closest('blockquote')?.getAttribute('dir')).toBe('auto')
})
it('inline code carries dir="ltr" so it does not vote in dir="auto" resolution', async () => {
render(<Harness text={'1. `npm install` מתקין תלויות'} />)
const code = await screen.findByText('npm install')
expect(code.tagName).toBe('CODE')
expect(code.getAttribute('dir')).toBe('ltr')
expect(code.closest('ol')?.getAttribute('dir')).toBe('auto')
})
it('plain prose blocks stay attribute-free (plaintext CSS owns them)', async () => {
render(<Harness text={'שלום לכולם'} />)
const paragraph = await screen.findByText(/שלום לכולם/)
expect(paragraph.closest('p')?.hasAttribute('dir')).toBe(false)
})
})

View File

@@ -322,29 +322,13 @@ function shortLabel(type: HermesRefType, id: string): string {
return tail || id
}
function safeEmbeddedImages(text: string) {
try {
return extractEmbeddedImages(text)
} catch {
return { cleanedText: text, images: [] as string[] }
}
}
function safeDirectiveSegments(text: string): Unstable_DirectiveSegment[] {
try {
return [...hermesDirectiveFormatter.parse(text)]
} catch {
return [{ kind: 'text', text }]
}
}
/**
* Renders text containing Hermes directives (`@file:...`, `@image:...`) as
* inline chips. Embedded MEDIA images render below as a thumbnail row.
*/
export function DirectiveContent({ text }: { text: string }) {
const { cleanedText, images } = useMemo(() => safeEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => safeDirectiveSegments(cleanedText), [cleanedText])
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText])
return (
<span className="whitespace-pre-line" data-slot="aui_directive-text">

View File

@@ -201,13 +201,4 @@ describe('preprocessMarkdown', () => {
expect(output).toContain('<https://example.com/a_b/c~d/page>')
})
it('handles a fenced block larger than V8 spread-argument limit', () => {
// A single huge code block (e.g. a logged minified bundle) used to throw
// `RangeError: Maximum call stack size exceeded` via `out.push(...lines)`.
const body = Array.from({ length: 200_000 }, (_, i) => `line ${i}`).join('\n')
const input = `\`\`\`js\n${body}\n\`\`\``
expect(() => preprocessMarkdown(input)).not.toThrow()
})
})

View File

@@ -19,9 +19,8 @@ import {
useState
} from 'react'
import { ExpandableBlock } from '@/components/chat/expandable-block'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
@@ -58,11 +57,7 @@ const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay
// module-scope so the prop identity is stable across renders.
function preprocessWithTailRepair(text: string): string {
try {
return tailBoundedRemend(preprocessMarkdown(text))
} catch {
return text
}
return tailBoundedRemend(preprocessMarkdown(text))
}
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full
@@ -458,35 +453,8 @@ const MARKDOWN_CONTAINER_CLASS_NAME = cn(
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)'
)
const MAX_MARKDOWN_CHARS = 200_000
function HugeTextFallback({ containerClassName, text }: { containerClassName?: string; text: string }) {
const chunks = useMemo(() => chunkByLines(text, 200), [text])
return (
<div
className={cn(
'aui-md w-full max-w-none overflow-hidden rounded-[0.625rem] border border-border font-mono text-[0.7rem] leading-relaxed text-foreground/90',
containerClassName
)}
>
<ExpandableBlock className="p-2">
{chunks.map((chunk, index) => (
<div
className="[content-visibility:auto]"
key={index}
style={{ containIntrinsicSize: `auto ${chunk.lines * 16}px` }}
>
{chunk.text}
</div>
))}
</ExpandableBlock>
</div>
)
}
function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) {
const { status, text } = useMessagePartText()
const { status } = useMessagePartText()
const isStreaming = status.type === 'running'
// Keep code parsing enabled while streaming so incomplete fenced blocks still
@@ -516,37 +484,19 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
),
a: MarkdownLink,
// Inline code must not vote when an ancestor resolves `dir="auto"`
// (HTML's algorithm skips descendants that carry their own dir),
// mirroring the CSS isolate that already keeps it out of the
// plaintext scan. Fenced code never reaches this override; it goes
// through the code plugin's CodeCard path.
inlineCode: ({ className, ...props }: ComponentProps<'code'>) => (
<code className={className} dir="ltr" {...props} />
),
// `---` as quiet spacing, not a heavy full-width rule.
hr: (_props: ComponentProps<'hr'>) => <div aria-hidden className="my-3" />,
// Lists and blockquotes have chrome that sits *beside* the text
// (markers, the quote border), and that side is driven by the CSS
// `direction` of the box, which `unicode-bidi: plaintext` never
// touches — an RTL list otherwise renders its numbers stranded at
// the far left. `dir="auto"` lets the browser resolve the box
// direction from content; the plaintext rules in styles.css keep
// owning per-line text direction. Inline code carries `dir="ltr"`
// (see the `code` override) so it doesn't vote here either, same
// contract as the CSS isolate.
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
<blockquote
className={cn('border-s-2 border-border ps-3 text-muted-foreground italic', className)}
dir="auto"
className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)}
{...props}
/>
),
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
<ul className={cn('my-1 gap-0', className)} dir="auto" {...props} />
<ul className={cn('my-1 gap-0', className)} {...props} />
),
ol: ({ className, ...props }: ComponentProps<'ol'>) => (
<ol className={cn('my-1 gap-0', className)} dir="auto" {...props} />
<ol className={cn('my-1 gap-0', className)} {...props} />
),
li: ({ className, ...props }: ComponentProps<'li'>) => (
<li className={cn('leading-(--dt-line-height)', className)} {...props} />
@@ -583,10 +533,6 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
[isStreaming]
)
if (text.length > MAX_MARKDOWN_CHARS) {
return <HugeTextFallback containerClassName={containerClassName} text={text} />
}
return (
<StreamdownTextPrimitive
components={components}

View File

@@ -378,20 +378,6 @@ function IntroHarness() {
)
}
function DismissibleErrorHarness({ onDismissError }: { onDismissError: (messageId: string) => void }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantErrorMessage('OpenRouter rejected the request (403).')],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread onDismissError={onDismissError} />
</AssistantRuntimeProvider>
)
}
describe('assistant-ui streaming renderer', () => {
beforeEach(() => {
resizeObservers.clear()
@@ -435,23 +421,6 @@ describe('assistant-ui streaming renderer', () => {
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
})
it('omits the dismiss control when no onDismissError handler is supplied', () => {
render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />)
expect(screen.queryByRole('button', { name: 'Dismiss error' })).toBeNull()
})
it('invokes onDismissError with the errored message id when the dismiss control is clicked', () => {
const onDismissError = vi.fn()
render(<DismissibleErrorHarness onDismissError={onDismissError} />)
const dismiss = screen.getByRole('button', { name: 'Dismiss error' })
fireEvent.click(dismiss)
expect(onDismissError).toHaveBeenCalledTimes(1)
expect(onDismissError).toHaveBeenCalledWith('assistant-error-1')
})
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
// by the use-stick-to-bottom library and covered by its own test suite. We
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real

View File

@@ -91,7 +91,7 @@ import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runti
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { LinkifiedText } from '@/lib/external-link'
import { triggerHaptic } from '@/lib/haptics'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
@@ -169,7 +169,6 @@ export const Thread: FC<{
loading?: ThreadLoadingState
onBranchInNewChat?: (messageId: string) => void
onCancel?: () => Promise<void> | void
onDismissError?: (messageId: string) => void
onRestoreToMessage?: (messageId: string) => Promise<void> | void
sessionId?: string | null
sessionKey?: string | null
@@ -181,19 +180,18 @@ export const Thread: FC<{
loading,
onBranchInNewChat,
onCancel,
onDismissError,
onRestoreToMessage,
sessionId = null,
sessionKey
}) => {
const messageComponents = useMemo(
() => ({
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} onDismissError={onDismissError} />,
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
SystemMessage,
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
}),
[cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, sessionId]
[cwd, gateway, onBranchInNewChat, onCancel, onRestoreToMessage, sessionId]
)
const emptyPlaceholder = intro ? (
@@ -247,13 +245,9 @@ const CenteredThreadSpinner: FC = () => {
)
}
const AssistantMessage: FC<{
onBranchInNewChat?: (messageId: string) => void
onDismissError?: (messageId: string) => void
}> = ({ onBranchInNewChat, onDismissError }) => {
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
const messageId = useAuiState(s => s.message.id)
const messageRuntime = useMessageRuntime()
const { t } = useI18n()
// PERF: this component must NOT subscribe to the streaming text. Every
// selector here returns a value that stays referentially stable across
@@ -312,20 +306,10 @@ const AssistantMessage: FC<{
)}
<MessagePrimitive.Error>
<ErrorPrimitive.Root
className="mt-1.5 flex items-start gap-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
className="mt-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
role="alert"
>
<ErrorPrimitive.Message className="min-w-0 flex-1" />
{onDismissError && (
<TooltipIconButton
className="-my-0.5 shrink-0 text-current opacity-70 hover:opacity-100"
onClick={() => onDismissError(messageId)}
side="top"
tooltip={t.assistant.thread.dismissError}
>
<XIcon className="size-3.5" />
</TooltipIconButton>
)}
<ErrorPrimitive.Message />
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
</div>
@@ -827,7 +811,7 @@ function StickyHumanMessageContainer({ attachments, children }: { attachments?:
// so without the carve-out, clicking a stuck bubble drags the window instead of
// opening the edit composer.
const USER_BUBBLE_BASE_CLASS =
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-y-auto rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
const USER_ACTION_ICON_BUTTON_CLASS =
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
@@ -859,10 +843,7 @@ const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => {
<summary className="cursor-pointer select-none text-muted-foreground/45 hover:text-muted-foreground/70">
output
</summary>
<pre
className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[0.625rem] leading-4 text-muted-foreground/55"
data-selectable-text="true"
>
<pre className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[0.625rem] leading-4 text-muted-foreground/55">
{detail}
</pre>
</details>

View File

@@ -14,11 +14,6 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: 'https://box:9119',
sshHost: '',
sshUser: '',
sshPort: null,
sshKeyPath: '',
sshRemoteHermesPath: '',
...overrides
}
}

View File

@@ -66,7 +66,7 @@ function CodeCardBody({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed',
'p-1.5 font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed',
className
)}
data-slot="code-card-body"

View File

@@ -1,52 +0,0 @@
'use client'
import { type ReactNode, useLayoutEffect, useRef, useState } from 'react'
import { ChevronDown } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface ExpandableBlockProps {
children: ReactNode
className?: string
}
export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
const innerRef = useRef<HTMLDivElement>(null)
const [expanded, setExpanded] = useState(false)
const [overflowing, setOverflowing] = useState(false)
useLayoutEffect(() => {
const el = innerRef.current
if (!el) {return}
const measure = () => setOverflowing(el.scrollHeight > 121)
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [])
return (
<div className="relative">
<div
className={cn('overflow-y-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)}
ref={innerRef}
>
{children}
</div>
{overflowing && (
<button
aria-expanded={expanded}
aria-label={expanded ? 'Collapse' : 'Expand'}
className="absolute inset-x-0 bottom-0 flex h-7 cursor-pointer items-end justify-center bg-linear-to-t from-(--ui-chat-surface-background) to-transparent pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={() => setExpanded(v => !v)}
type="button"
>
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
</button>
)}
</div>
)
}

View File

@@ -1,37 +0,0 @@
import { describe, expect, it } from 'vitest'
import { chunkByLines, exceedsHighlightBudget } from '@/components/chat/shiki-highlighter'
describe('exceedsHighlightBudget', () => {
it('highlights normal-sized blocks', () => {
expect(exceedsHighlightBudget('const x = 1\n'.repeat(100))).toBe(false)
})
it('skips highlighting past the line budget', () => {
expect(exceedsHighlightBudget('x\n'.repeat(5_000))).toBe(true)
})
it('skips highlighting past the char budget on few lines', () => {
expect(exceedsHighlightBudget('a'.repeat(200_000))).toBe(true)
})
it('short-circuits on char budget before line loop', () => {
expect(exceedsHighlightBudget('y\n'.repeat(250_000))).toBe(true)
})
})
describe('chunkByLines', () => {
it('keeps a small block as a single chunk', () => {
const code = 'a\nb\nc'
expect(chunkByLines(code, 200)).toEqual([{ text: code, lines: 3 }])
})
it('splits a large block and reconstructs it losslessly', () => {
const code = Array.from({ length: 1000 }, (_, i) => `line ${i}`).join('\n')
const chunks = chunkByLines(code, 200)
expect(chunks).toHaveLength(5)
expect(chunks.map(chunk => chunk.text).join('\n')).toBe(code)
expect(chunks.reduce((sum, chunk) => sum + chunk.lines, 0)).toBe(1000)
})
})

View File

@@ -1,7 +1,7 @@
'use client'
import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
import { type FC, useMemo } from 'react'
import type { FC } from 'react'
import ShikiHighlighter from 'react-shiki'
import {
@@ -12,7 +12,6 @@ import {
CodeCardSubtitle,
CodeCardTitle
} from '@/components/chat/code-card'
import { ExpandableBlock } from '@/components/chat/expandable-block'
import { CopyButton } from '@/components/ui/copy-button'
import { useI18n } from '@/i18n'
import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
@@ -44,74 +43,6 @@ const SHIKI_COLOR_REPLACEMENTS: Record<string, Record<string, string>> = {
'github-light-default': { '#6e7781': '#57606a' }
}
const MAX_HIGHLIGHT_CHARS = 150_000
const MAX_HIGHLIGHT_LINES = 3_000
const CHUNK_LINES = 200
const EST_LINE_PX = 16
export function exceedsHighlightBudget(code: string): boolean {
if (code.length > MAX_HIGHLIGHT_CHARS) {
return true
}
let lines = 1
let idx = code.indexOf('\n')
while (idx !== -1) {
if ((lines += 1) > MAX_HIGHLIGHT_LINES) {
return true
}
idx = code.indexOf('\n', idx + 1)
}
return false
}
interface CodeChunk {
text: string
lines: number
}
export function chunkByLines(code: string, perChunk: number): CodeChunk[] {
const lines = code.split('\n')
if (lines.length <= perChunk) {
return [{ text: code, lines: lines.length }]
}
const chunks: CodeChunk[] = []
for (let i = 0; i < lines.length; i += perChunk) {
const slice = lines.slice(i, i + perChunk)
chunks.push({ text: slice.join('\n'), lines: slice.length })
}
return chunks
}
const PlainCode: FC<{ code: string }> = ({ code }) => {
const chunks = useMemo(() => chunkByLines(code, CHUNK_LINES), [code])
if (chunks.length === 1) {
return <code className="block whitespace-pre">{code}</code>
}
return (
<>
{chunks.map((chunk, index) => (
<code
className="block whitespace-pre [content-visibility:auto]"
key={index}
style={{ containIntrinsicSize: `auto ${chunk.lines * EST_LINE_PX}px` }}
>
{chunk.text}
</code>
))}
</>
)
}
export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
components: { Pre },
language,
@@ -133,7 +64,6 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
const cleanLanguage = sanitizeLanguageTag(language || '')
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
const plain = defer || exceedsHighlightBudget(trimmed)
return (
<CodeCard data-streaming={defer ? 'true' : undefined}>
@@ -153,26 +83,24 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
/>
</CodeCardHeader>
<CodeCardBody>
<ExpandableBlock>
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
{plain ? (
<PlainCode code={trimmed} />
) : (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
colorReplacements={SHIKI_COLOR_REPLACEMENTS}
defaultColor="light-dark()"
delay={120}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{trimmed}
</ShikiHighlighter>
)}
</Pre>
</ExpandableBlock>
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
{defer ? (
<code className="block whitespace-pre">{trimmed}</code>
) : (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
colorReplacements={SHIKI_COLOR_REPLACEMENTS}
defaultColor="light-dark()"
delay={120}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{trimmed}
</ShikiHighlighter>
)}
</Pre>
</CodeCardBody>
</CodeCard>
)

View File

@@ -41,11 +41,7 @@ export function TerminalOutput({ className, text }: TerminalOutputProps) {
}, [text])
return (
<div
className={cn('max-h-16 overflow-auto overscroll-contain', className)}
data-selectable-text="true"
ref={ref}
>
<div className={cn('max-h-16 overflow-auto overscroll-contain', className)} ref={ref}>
<pre className="w-max min-w-full font-mono text-[0.5625rem] leading-[0.85rem] whitespace-pre text-muted-foreground/70">
{text}
</pre>

View File

@@ -154,10 +154,7 @@ function NotificationDetail({ detail }: { detail: string }) {
<details className="mt-2 text-xs text-muted-foreground">
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">{copy.details}</summary>
<div className="mt-1 rounded-md bg-background/65 p-2">
<pre
className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed"
data-selectable-text="true"
>
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
{detail}
</pre>
<CopyButton

View File

@@ -4,7 +4,6 @@ import { cn } from '@/lib/utils'
// Shared raw-log viewer: no bg, hairline border, tight padding, small mono.
// One style everywhere we surface logs. Pass a max-h-* via className.
// Selectable by default — logs exist to be read and copied.
export function LogView({ className, ...props }: ComponentProps<'div'>) {
return (
<div
@@ -12,7 +11,6 @@ export function LogView({ className, ...props }: ComponentProps<'div'>) {
'overflow-auto rounded-lg border border-(--ui-stroke-tertiary) px-2.5 py-1.5 font-mono text-[0.6875rem] leading-[1.5] whitespace-pre-wrap break-words text-(--ui-text-tertiary) [scrollbar-width:thin]',
className
)}
data-selectable-text="true"
{...props}
/>
)

View File

@@ -31,8 +31,6 @@ declare global {
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
sshConfigHosts: () => Promise<DesktopSshHostsResult>
sshResolveHost: (host: string) => Promise<DesktopSshResolveResult>
probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>
oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>
oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>
@@ -247,13 +245,6 @@ export interface HermesConnection {
isFullscreen: boolean
mode?: 'local' | 'remote'
authMode?: 'oauth' | 'token'
// Human-facing host for the statusbar connection pill. For SSH remotes this
// is the user@host the tunnel reaches; for token/oauth remotes it's the host
// parsed from the real backend URL. Absent in local mode.
remoteHost?: string
// Distinguishes an SSH-tunnelled remote ('ssh') from a direct URL remote
// ('url') so the pill can label it SSH: vs Remote:. Absent in local mode.
remoteKind?: 'ssh' | 'url'
nativeOverlayWidth: number
source?: 'env' | 'local' | 'settings'
token: string
@@ -284,66 +275,31 @@ export interface DesktopActiveProfile {
export interface DesktopConnectionConfig {
envOverride: boolean
mode: 'local' | 'remote' | 'ssh'
mode: 'local' | 'remote'
// The profile this config describes, or null for the global/default
// connection. Per-profile entries let a profile point at its own backend.
profile: null | string
// Remote-auth fields are always present (the sanitizer fills defaults even in
// local/ssh mode) so consumers can read them without optional-narrowing.
remoteAuthMode: 'oauth' | 'token'
remoteOauthConnected: boolean
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
// SSH mode fields. Always present on the contract (empty strings / null in
// local/remote mode, populated when mode === 'ssh') so the renderer never
// optional-narrows. No token is surfaced — the dashboard session token is an
// internal artifact reconciled at bootstrap.
sshHost: string
sshUser: string
sshPort: number | null
sshKeyPath: string
sshRemoteHermesPath: string
}
export interface DesktopConnectionConfigInput {
mode: 'local' | 'remote' | 'ssh'
mode: 'local' | 'remote'
// When set, the save/apply/test targets this profile's per-profile remote
// override instead of the global connection.
profile?: null | string
remoteAuthMode?: 'oauth' | 'token'
remoteToken?: string
remoteUrl?: string
// SSH mode input fields.
sshHost?: string
sshUser?: string
sshPort?: number | null
sshKeyPath?: string
sshRemoteHermesPath?: string
}
export interface DesktopConnectionTestResult {
baseUrl?: string
ok?: boolean
version?: string | null
// SSH-mode test result fields.
reachable?: boolean
sshError?: 'unreachable' | 'auth-failed' | 'host-key-changed' | 'hermes-not-found' | 'unsupported-platform' | 'timeout' | 'unknown' | null
error?: string | null
remotePlatform?: string
remoteHermesPath?: string
host?: string
}
export interface DesktopSshResolveResult {
hostname: string | null
user: string | null
port: number | null
identityFile: string | null
}
export interface DesktopSshHostsResult {
hosts: string[]
baseUrl: string
ok: boolean
version: string | null
}
export interface DesktopAuthProvider {

View File

@@ -17,7 +17,6 @@ import type {
HermesConfig,
HermesConfigRecord,
LogsResponse,
MemoryProviderConfig,
MessagingPlatformsResponse,
MessagingPlatformTestResponse,
MessagingPlatformUpdate,
@@ -72,7 +71,6 @@ export type {
HermesConfig,
HermesConfigRecord,
LogsResponse,
MemoryProviderConfig,
MessagingEnvVarInfo,
MessagingHomeChannel,
MessagingPlatformInfo,
@@ -341,23 +339,6 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool
})
}
export function getMemoryProviderConfig(provider: string): Promise<MemoryProviderConfig> {
return window.hermesDesktop.api<MemoryProviderConfig>({
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`
})
}
export function saveMemoryProviderConfig(
provider: string,
values: Record<string, string>
): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`,
method: 'PUT',
body: { values }
})
}
export function getEnvVars(): Promise<Record<string, EnvVarInfo>> {
return window.hermesDesktop.api<Record<string, EnvVarInfo>>({
...profileScoped(),
@@ -660,10 +641,10 @@ export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
})
}
export function getGlobalModelOptions(opts?: { refresh?: boolean }): Promise<ModelOptionsResponse> {
export function getGlobalModelOptions(): Promise<ModelOptionsResponse> {
return window.hermesDesktop.api<ModelOptionsResponse>({
...profileScoped(),
path: opts?.refresh ? '/api/model/options?refresh=1' : '/api/model/options'
path: '/api/model/options'
})
}

View File

@@ -497,36 +497,7 @@ export const en: Translations = {
signOutFailed: 'Sign-out failed',
testFailed: 'Remote gateway test failed',
applyFailed: 'Could not apply gateway settings',
saveFailed: 'Could not save gateway settings',
sshTitle: 'Connect via SSH',
sshDesc:
'Reach a remote Hermes backend over SSH — no exposed dashboard port, no token to copy. Hermes is bootstrapped on the remote and tunneled to this app.',
sshHostTitle: 'Host',
sshHostDesc: 'The SSH target, e.g. user@mac-mini.local or a Host alias from ~/.ssh/config.',
sshUserTitle: 'User',
sshUserDesc: 'SSH username. Leave blank to use ~/.ssh/config or your current user.',
sshUserPlaceholder: 'from ~/.ssh/config',
sshPortTitle: 'Port',
sshPortDesc: 'SSH port. Leave blank for 22 (or the port set in ~/.ssh/config).',
sshKeyTitle: 'Identity file',
sshKeyDesc: 'Optional private key path. Leave blank to use your ssh-agent or ~/.ssh/config.',
sshHermesPathTitle: 'Hermes path (optional)',
sshHermesPathDesc: 'Override where hermes is found on the remote. Leave blank to auto-detect.',
sshHermesPathPlaceholder: 'auto-detect',
sshTestConnection: 'Test SSH',
sshConnect: 'Connect',
sshReachable: (host, platform) => `Reachable: ${host} (${platform}) — Hermes found`,
sshIncompleteHost: 'Enter an SSH host before connecting.',
sshErrUnreachable: 'Could not reach that host over SSH. Check the host, port, and your network.',
sshErrAuth:
'SSH authentication failed. Load your key into the ssh-agent (ssh-add) or set an IdentityFile in ~/.ssh/config — Hermes runs ssh non-interactively.',
sshErrHostKey:
'The host key has CHANGED since you last connected. Verify this is expected, then run ssh-keygen -R <host> and reconnect.',
sshErrNotInstalled:
'Hermes is not installed on the remote host. Install it there (curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh) or set the Hermes path.',
sshErrPlatform: 'Unsupported remote platform. Hermes Desktop SSH mode supports Linux and macOS remote hosts only.',
sshErrTimeout: 'The SSH connection timed out. The host may be unreachable or asleep.',
sshErrUnknown: 'SSH connection failed.'
saveFailed: 'Could not save gateway settings'
},
keys: {
loading: 'Loading API keys and credentials...',
@@ -610,8 +581,6 @@ export const en: Translations = {
removedMessage: provider => `${provider} was removed.`,
failedRemove: provider => `Could not remove ${provider}`,
noProviderKeys: 'No provider API keys available.',
searchKeys: 'Search providers…',
noKeysMatch: 'No providers match your search.',
loading: 'Loading providers...'
},
sessions: {
@@ -792,8 +761,7 @@ export const en: Translations = {
gatewayRunning: 'Messaging gateway running',
gatewayStopped: 'Messaging gateway stopped',
hermesActiveSessions: (version, count) => `Hermes ${version} · Active sessions ${count}`,
restartGateway: 'Restart gateway',
gatewayRestartFailed: 'Gateway restart failed.',
restartMessaging: 'Restart messaging',
updateHermes: 'Update Hermes',
actionRunning: 'running',
actionDone: 'done',
@@ -862,9 +830,9 @@ export const en: Translations = {
disableAria: name => `Disable ${name}`,
platformEnabled: name => `${name} enabled`,
platformDisabled: name => `${name} disabled`,
restartToApply: 'This change takes effect after a gateway restart.',
restartToApply: 'Restart the gateway for this change to take effect.',
setupSaved: name => `${name} setup saved`,
restartToReconnect: 'New credentials take effect after a gateway restart.',
restartToReconnect: 'Restart the gateway to reconnect with the new credentials.',
keyCleared: key => `${key} cleared`,
setupUpdated: name => `${name} setup was updated.`,
failedUpdate: name => `Failed to update ${name}`,
@@ -1564,7 +1532,6 @@ export const en: Translations = {
search: 'Search models',
noModels: 'No models found',
editModels: 'Edit Models…',
refreshModels: 'Refresh Models',
fast: 'Fast',
medium: 'Med'
},
@@ -1607,10 +1574,6 @@ export const en: Translations = {
backendVersion: version => `Backend v${version}`,
clientLabel: version => `client v${version}`,
backendLabel: version => `backend v${version}`,
connectionSsh: host => `SSH: ${host}`,
connectionRemote: host => `Remote: ${host}`,
connectionSshTooltip: host => `Connected over SSH to ${host} · click to manage`,
connectionRemoteTooltip: host => `Connected to remote backend ${host} · click to manage`,
commit: sha => `commit ${sha}`,
branch: branch => `branch ${branch}`,
closeCommandCenter: 'Close Command Center',
@@ -1623,7 +1586,6 @@ export const en: Translations = {
gatewayChecking: 'checking',
gatewayConnecting: 'connecting',
gatewayOffline: 'offline',
gatewayRestarting: 'restarting…',
gatewayTitle: 'Hermes inference gateway status',
agents: 'Agents',
closeAgents: 'Close agents',
@@ -1771,7 +1733,6 @@ export const en: Translations = {
refresh: 'Refresh',
moreActions: 'More actions',
branchNewChat: 'Branch in new chat',
dismissError: 'Dismiss error',
readAloudFailed: 'Read aloud failed',
preparingAudio: 'Preparing audio...',
stopReading: 'Stop reading',
@@ -1881,9 +1842,6 @@ export const en: Translations = {
regenerateFailed: 'Regenerate failed',
editFailed: 'Edit failed',
resumeFailed: 'Resume failed',
resumeStrandedTitle: "Couldn't load this session",
resumeStrandedBody: 'The connection to this session failed and automatic retries gave up. Check that the gateway is running, then try again.',
resumeRetry: 'Retry',
nothingToBranch: 'Nothing to branch',
branchNeedsChat: 'Start or resume a chat before branching.',
sessionBusy: 'Session busy',

View File

@@ -624,36 +624,7 @@ export const ja = defineLocale({
signOutFailed: 'サインアウトに失敗しました',
testFailed: 'リモートゲートウェイのテストに失敗しました',
applyFailed: 'ゲートウェイ設定を適用できませんでした',
saveFailed: 'ゲートウェイ設定を保存できませんでした',
sshTitle: 'SSH で接続',
sshDesc:
'SSH 経由でリモートの Hermes バックエンドに接続します。ダッシュボードポートの公開もトークンのコピーも不要です。リモート側で Hermes を起動し、このアプリにトンネルします。',
sshHostTitle: 'ホスト',
sshHostDesc: 'SSH の接続先。例: user@mac-mini.local、または ~/.ssh/config の Host エイリアス。',
sshUserTitle: 'ユーザー',
sshUserDesc: 'SSH ユーザー名。空欄の場合は ~/.ssh/config または現在のユーザーを使用します。',
sshUserPlaceholder: '~/.ssh/config から',
sshPortTitle: 'ポート',
sshPortDesc: 'SSH ポート。空欄の場合は 22または ~/.ssh/config の設定)。',
sshKeyTitle: '鍵ファイル',
sshKeyDesc: '秘密鍵のパス(任意)。空欄の場合は ssh-agent または ~/.ssh/config を使用します。',
sshHermesPathTitle: 'Hermes パス(任意)',
sshHermesPathDesc: 'リモート上の hermes の場所を上書きします。空欄の場合は自動検出します。',
sshHermesPathPlaceholder: '自動検出',
sshTestConnection: 'SSH をテスト',
sshConnect: '接続',
sshReachable: (host, platform) => `接続可能: ${host}${platform})— Hermes を検出`,
sshIncompleteHost: '接続する前に SSH ホストを入力してください。',
sshErrUnreachable: 'SSH でそのホストに到達できませんでした。ホスト、ポート、ネットワークを確認してください。',
sshErrAuth:
'SSH 認証に失敗しました。鍵を ssh-agent に読み込むssh-addか、~/.ssh/config に IdentityFile を設定してください。Hermes は非対話的に ssh を実行します。',
sshErrHostKey:
'前回の接続以降、ホスト鍵が変更されています。想定どおりか確認し、ssh-keygen -R <host> を実行してから再接続してください。',
sshErrNotInstalled:
'リモートホストに Hermes がインストールされていません。リモートでインストールするcurl -fsSL https://hermes-agent.nousresearch.com/install.sh | shか、Hermes パスを設定してください。',
sshErrPlatform: 'サポートされていないリモートプラットフォームです。Hermes Desktop の SSH モードは Linux と macOS のリモートホストのみ対応しています。',
sshErrTimeout: 'SSH 接続がタイムアウトしました。ホストが到達不能、またはスリープ中の可能性があります。',
sshErrUnknown: 'SSH 接続に失敗しました。'
saveFailed: 'ゲートウェイ設定を保存できませんでした'
},
keys: {
loading: 'API キーと認証情報を読み込み中...',
@@ -729,8 +700,6 @@ export const ja = defineLocale({
removedMessage: provider => `${provider} を削除しました。`,
failedRemove: provider => `${provider} を削除できませんでした`,
noProviderKeys: '利用可能なプロバイダー API キーがありません。',
searchKeys: 'プロバイダーを検索…',
noKeysMatch: '一致するプロバイダーがありません。',
loading: 'プロバイダーを読み込み中...'
},
sessions: {
@@ -912,8 +881,7 @@ export const ja = defineLocale({
gatewayRunning: 'メッセージングゲートウェイが実行中',
gatewayStopped: 'メッセージングゲートウェイが停止中',
hermesActiveSessions: (version, count) => `Hermes ${version} · アクティブセッション ${count}`,
restartGateway: 'ゲートウェイを再起動',
gatewayRestartFailed: 'ゲートウェイの再起動に失敗しました。',
restartMessaging: 'メッセージングを再起動',
updateHermes: 'Hermes を更新',
actionRunning: '実行中',
actionDone: '完了',
@@ -983,9 +951,9 @@ export const ja = defineLocale({
disableAria: name => `${name} を無効にする`,
platformEnabled: name => `${name} を有効にしました`,
platformDisabled: name => `${name} を無効にしました`,
restartToApply: 'この変更はゲートウェイ再起動後に有効になります。',
restartToApply: 'この変更を有効にするにはゲートウェイ再起動してください。',
setupSaved: name => `${name} の設定を保存しました`,
restartToReconnect: '新しい認証情報はゲートウェイ再起動後に有効になります。',
restartToReconnect: '新しい認証情報で再接続するにはゲートウェイ再起動してください。',
keyCleared: key => `${key} をクリアしました`,
setupUpdated: name => `${name} の設定が更新されました。`,
failedUpdate: name => `${name} の更新に失敗しました`,
@@ -1694,7 +1662,6 @@ export const ja = defineLocale({
search: 'モデルを検索',
noModels: 'モデルが見つかりません',
editModels: 'モデルを編集…',
refreshModels: 'モデルを更新',
fast: '高速',
medium: '中'
},
@@ -1737,10 +1704,6 @@ export const ja = defineLocale({
backendVersion: version => `バックエンド v${version}`,
clientLabel: version => `クライアント v${version}`,
backendLabel: version => `バックエンド v${version}`,
connectionSsh: host => `SSH: ${host}`,
connectionRemote: host => `リモート: ${host}`,
connectionSshTooltip: host => `SSH 経由で ${host} に接続中 · クリックして管理`,
connectionRemoteTooltip: host => `リモートバックエンド ${host} に接続中 · クリックして管理`,
commit: sha => `コミット ${sha}`,
branch: branch => `ブランチ ${branch}`,
closeCommandCenter: 'コマンドセンターを閉じる',
@@ -1753,7 +1716,6 @@ export const ja = defineLocale({
gatewayChecking: '確認中',
gatewayConnecting: '接続中',
gatewayOffline: 'オフライン',
gatewayRestarting: '再起動中…',
gatewayTitle: 'Hermes 推論ゲートウェイのステータス',
agents: 'エージェント',
closeAgents: 'エージェントを閉じる',
@@ -1902,7 +1864,6 @@ export const ja = defineLocale({
refresh: '更新',
moreActions: 'その他のアクション',
branchNewChat: '新しいチャットでブランチ',
dismissError: 'エラーを閉じる',
readAloudFailed: '読み上げに失敗しました',
preparingAudio: '音声を準備中...',
stopReading: '読み上げを停止',
@@ -2012,9 +1973,6 @@ export const ja = defineLocale({
regenerateFailed: '再生成に失敗しました',
editFailed: '編集に失敗しました',
resumeFailed: '再開に失敗しました',
resumeStrandedTitle: 'このセッションを読み込めませんでした',
resumeStrandedBody: 'このセッションへの接続に失敗し、自動再試行も停止しました。ゲートウェイが実行中か確認してから、もう一度お試しください。',
resumeRetry: '再試行',
nothingToBranch: 'ブランチするものがありません',
branchNeedsChat: 'ブランチする前にチャットを開始または再開してください。',
sessionBusy: 'セッションが使用中',

View File

@@ -390,31 +390,6 @@ export interface Translations {
testFailed: string
applyFailed: string
saveFailed: string
sshTitle: string
sshDesc: string
sshHostTitle: string
sshHostDesc: string
sshUserTitle: string
sshUserDesc: string
sshUserPlaceholder: string
sshPortTitle: string
sshPortDesc: string
sshKeyTitle: string
sshKeyDesc: string
sshHermesPathTitle: string
sshHermesPathDesc: string
sshHermesPathPlaceholder: string
sshTestConnection: string
sshConnect: string
sshReachable: (host: string, platform: string) => string
sshIncompleteHost: string
sshErrUnreachable: string
sshErrAuth: string
sshErrHostKey: string
sshErrNotInstalled: string
sshErrPlatform: string
sshErrTimeout: string
sshErrUnknown: string
}
keys: {
loading: string
@@ -487,8 +462,6 @@ export interface Translations {
removedMessage: (provider: string) => string
failedRemove: (provider: string) => string
noProviderKeys: string
searchKeys: string
noKeysMatch: string
loading: string
}
sessions: {
@@ -652,8 +625,7 @@ export interface Translations {
gatewayRunning: string
gatewayStopped: string
hermesActiveSessions: (version: string, count: number) => string
restartGateway: string
gatewayRestartFailed: string
restartMessaging: string
updateHermes: string
actionRunning: string
actionDone: string
@@ -1202,7 +1174,6 @@ export interface Translations {
search: string
noModels: string
editModels: string
refreshModels: string
fast: string
medium: string
}
@@ -1245,10 +1216,6 @@ export interface Translations {
backendVersion: (version: string) => string
clientLabel: (version: string) => string
backendLabel: (version: string) => string
connectionSsh: (host: string) => string
connectionRemote: (host: string) => string
connectionSshTooltip: (host: string) => string
connectionRemoteTooltip: (host: string) => string
commit: (sha: string) => string
branch: (branch: string) => string
closeCommandCenter: string
@@ -1261,7 +1228,6 @@ export interface Translations {
gatewayChecking: string
gatewayConnecting: string
gatewayOffline: string
gatewayRestarting: string
gatewayTitle: string
agents: string
closeAgents: string
@@ -1407,7 +1373,6 @@ export interface Translations {
refresh: string
moreActions: string
branchNewChat: string
dismissError: string
readAloudFailed: string
preparingAudio: string
stopReading: string
@@ -1515,9 +1480,6 @@ export interface Translations {
regenerateFailed: string
editFailed: string
resumeFailed: string
resumeStrandedTitle: string
resumeStrandedBody: string
resumeRetry: string
nothingToBranch: string
branchNeedsChat: string
sessionBusy: string

View File

@@ -604,36 +604,7 @@ export const zhHant = defineLocale({
signOutFailed: '登出失敗',
testFailed: '遠端閘道測試失敗',
applyFailed: '無法套用閘道設定',
saveFailed: '無法儲存閘道設定',
sshTitle: '透過 SSH 連線',
sshDesc:
'透過 SSH 連線到遠端 Hermes 後端——無需公開儀表板連接埠也無需複製權杖。Hermes 會在遠端主機上啟動並透過通道連線到本應用程式。',
sshHostTitle: '主機',
sshHostDesc: 'SSH 目標,例如 user@mac-mini.local或 ~/.ssh/config 中的 Host 別名。',
sshUserTitle: '使用者',
sshUserDesc: 'SSH 使用者名稱。留空則使用 ~/.ssh/config 或目前使用者。',
sshUserPlaceholder: '來自 ~/.ssh/config',
sshPortTitle: '連接埠',
sshPortDesc: 'SSH 連接埠。留空則為 22或 ~/.ssh/config 中設定的連接埠)。',
sshKeyTitle: '金鑰檔案',
sshKeyDesc: '選用的私密金鑰路徑。留空則使用 ssh-agent 或 ~/.ssh/config。',
sshHermesPathTitle: 'Hermes 路徑(選用)',
sshHermesPathDesc: '覆寫遠端主機上 hermes 的位置。留空則自動偵測。',
sshHermesPathPlaceholder: '自動偵測',
sshTestConnection: '測試 SSH',
sshConnect: '連線',
sshReachable: (host, platform) => `可連線:${host}${platform})——已找到 Hermes`,
sshIncompleteHost: '連線前請輸入 SSH 主機。',
sshErrUnreachable: '無法透過 SSH 連線到該主機。請檢查主機、連接埠和網路。',
sshErrAuth:
'SSH 驗證失敗。請將金鑰載入 ssh-agentssh-add或在 ~/.ssh/config 中設定 IdentityFile——Hermes 以非互動方式執行 ssh。',
sshErrHostKey:
'自上次連線以來主機金鑰已變更。請確認這是預期的,然後執行 ssh-keygen -R <host> 並重新連線。',
sshErrNotInstalled:
'遠端主機上未安裝 Hermes。請在遠端安裝curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh或設定 Hermes 路徑。',
sshErrPlatform: '不支援的遠端平台。Hermes Desktop 的 SSH 模式僅支援 Linux 和 macOS 遠端主機。',
sshErrTimeout: 'SSH 連線逾時。主機可能無法存取或處於睡眠狀態。',
sshErrUnknown: 'SSH 連線失敗。'
saveFailed: '無法儲存閘道設定'
},
keys: {
loading: '正在載入 API 金鑰和憑證...',
@@ -706,8 +677,6 @@ export const zhHant = defineLocale({
removedMessage: provider => `${provider} 已移除。`,
failedRemove: provider => `無法移除 ${provider}`,
noProviderKeys: '沒有可用的提供方 API 金鑰。',
searchKeys: '搜尋提供方…',
noKeysMatch: '沒有符合的提供方。',
loading: '正在載入提供方...'
},
sessions: {
@@ -885,8 +854,7 @@ export const zhHant = defineLocale({
gatewayRunning: '訊息閘道執行中',
gatewayStopped: '訊息閘道已停止',
hermesActiveSessions: (version, count) => `Hermes ${version} · 活躍工作階段 ${count}`,
restartGateway: '重新啟動閘道',
gatewayRestartFailed: '閘道重新啟動失敗。',
restartMessaging: '重新啟動訊息服務',
updateHermes: '更新 Hermes',
actionRunning: '執行中',
actionDone: '完成',
@@ -955,9 +923,9 @@ export const zhHant = defineLocale({
disableAria: name => `停用 ${name}`,
platformEnabled: name => `${name} 已啟用`,
platformDisabled: name => `${name} 已停用`,
restartToApply: '此變更將在閘道重新啟動後生效。',
restartToApply: '重新啟動閘道後此變更才會生效。',
setupSaved: name => `${name} 設定已儲存`,
restartToReconnect: '新憑證將在閘道重新啟動後生效。',
restartToReconnect: '重新啟動閘道以使用新憑證重新連線。',
keyCleared: key => `${key} 已清除`,
setupUpdated: name => `${name} 設定已更新。`,
failedUpdate: name => `更新 ${name} 失敗`,
@@ -1638,7 +1606,6 @@ export const zhHant = defineLocale({
search: '搜尋模型',
noModels: '找不到模型',
editModels: '編輯模型…',
refreshModels: '重新整理模型',
fast: '快速',
medium: '中'
},
@@ -1681,10 +1648,6 @@ export const zhHant = defineLocale({
backendVersion: version => `後端 v${version}`,
clientLabel: version => `用戶端 v${version}`,
backendLabel: version => `後端 v${version}`,
connectionSsh: host => `SSH: ${host}`,
connectionRemote: host => `遠端: ${host}`,
connectionSshTooltip: host => `已透過 SSH 連線到 ${host} · 點擊管理`,
connectionRemoteTooltip: host => `已連線到遠端後端 ${host} · 點擊管理`,
commit: sha => `提交 ${sha}`,
branch: branch => `分支 ${branch}`,
closeCommandCenter: '關閉命令中心',
@@ -1697,7 +1660,6 @@ export const zhHant = defineLocale({
gatewayChecking: '檢查中',
gatewayConnecting: '連線中',
gatewayOffline: '離線',
gatewayRestarting: '重新啟動中…',
gatewayTitle: 'Hermes 推論閘道狀態',
agents: '代理',
closeAgents: '關閉代理',
@@ -1844,7 +1806,6 @@ export const zhHant = defineLocale({
refresh: '重新整理',
moreActions: '更多動作',
branchNewChat: '在新聊天中分支',
dismissError: '关闭错误',
readAloudFailed: '朗讀失敗',
preparingAudio: '正在準備音訊...',
stopReading: '停止朗讀',
@@ -1952,9 +1913,6 @@ export const zhHant = defineLocale({
regenerateFailed: '重新生成失敗',
editFailed: '編輯失敗',
resumeFailed: '繼續失敗',
resumeStrandedTitle: '無法載入此工作階段',
resumeStrandedBody: '與此工作階段的連線失敗,自動重試已停止。請確認閘道正在執行,然後重試。',
resumeRetry: '重試',
nothingToBranch: '沒有可分支的內容',
branchNeedsChat: '分支前請先開始或繼續一個聊天。',
sessionBusy: '工作階段忙碌中',

View File

@@ -692,36 +692,7 @@ export const zh: Translations = {
signOutFailed: '退出登录失败',
testFailed: '远程网关测试失败',
applyFailed: '无法应用网关设置',
saveFailed: '无法保存网关设置',
sshTitle: '通过 SSH 连接',
sshDesc:
'通过 SSH 连接到远程 Hermes 后端——无需暴露面板端口也无需复制令牌。Hermes 会在远程主机上启动并通过隧道连接到本应用。',
sshHostTitle: '主机',
sshHostDesc: 'SSH 目标,例如 user@mac-mini.local或 ~/.ssh/config 中的 Host 别名。',
sshUserTitle: '用户',
sshUserDesc: 'SSH 用户名。留空则使用 ~/.ssh/config 或当前用户。',
sshUserPlaceholder: '来自 ~/.ssh/config',
sshPortTitle: '端口',
sshPortDesc: 'SSH 端口。留空则为 22或 ~/.ssh/config 中设置的端口)。',
sshKeyTitle: '密钥文件',
sshKeyDesc: '可选的私钥路径。留空则使用 ssh-agent 或 ~/.ssh/config。',
sshHermesPathTitle: 'Hermes 路径(可选)',
sshHermesPathDesc: '覆盖远程主机上 hermes 的位置。留空则自动检测。',
sshHermesPathPlaceholder: '自动检测',
sshTestConnection: '测试 SSH',
sshConnect: '连接',
sshReachable: (host, platform) => `可连接:${host}${platform})——已找到 Hermes`,
sshIncompleteHost: '连接前请输入 SSH 主机。',
sshErrUnreachable: '无法通过 SSH 连接到该主机。请检查主机、端口和网络。',
sshErrAuth:
'SSH 认证失败。请将密钥加载到 ssh-agentssh-add或在 ~/.ssh/config 中设置 IdentityFile——Hermes 以非交互方式运行 ssh。',
sshErrHostKey:
'自上次连接以来主机密钥已更改。请确认这是预期的,然后运行 ssh-keygen -R <host> 并重新连接。',
sshErrNotInstalled:
'远程主机上未安装 Hermes。请在远程安装curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh或设置 Hermes 路径。',
sshErrPlatform: '不支持的远程平台。Hermes Desktop 的 SSH 模式仅支持 Linux 和 macOS 远程主机。',
sshErrTimeout: 'SSH 连接超时。主机可能无法访问或处于休眠状态。',
sshErrUnknown: 'SSH 连接失败。'
saveFailed: '无法保存网关设置'
},
keys: {
loading: '正在加载 API 密钥和凭据...',
@@ -803,8 +774,6 @@ export const zh: Translations = {
removedMessage: provider => `${provider} 已移除。`,
failedRemove: provider => `无法移除 ${provider}`,
noProviderKeys: '没有可用的提供方 API 密钥。',
searchKeys: '搜索提供方…',
noKeysMatch: '没有匹配的提供方。',
loading: '正在加载提供方...'
},
sessions: {
@@ -982,8 +951,7 @@ export const zh: Translations = {
gatewayRunning: '消息网关运行中',
gatewayStopped: '消息网关已停止',
hermesActiveSessions: (version, count) => `Hermes ${version} · 活跃会话 ${count}`,
restartGateway: '重启网关',
gatewayRestartFailed: '网关重启失败。',
restartMessaging: '重启消息服务',
updateHermes: '更新 Hermes',
actionRunning: '运行中',
actionDone: '完成',
@@ -1052,9 +1020,9 @@ export const zh: Translations = {
disableAria: name => `禁用 ${name}`,
platformEnabled: name => `${name} 已启用`,
platformDisabled: name => `${name} 已禁用`,
restartToApply: '此更改将在网关重启后生效。',
restartToApply: '重启网关后此更改才会生效。',
setupSaved: name => `${name} 设置已保存`,
restartToReconnect: '新凭据将在网关重启后生效。',
restartToReconnect: '重启网关以使用新凭据重新连接。',
keyCleared: key => `${key} 已清除`,
setupUpdated: name => `${name} 设置已更新。`,
failedUpdate: name => `更新 ${name} 失败`,
@@ -1744,7 +1712,6 @@ export const zh: Translations = {
search: '搜索模型',
noModels: '未找到模型',
editModels: '编辑模型…',
refreshModels: '刷新模型',
fast: '快速',
medium: '中'
},
@@ -1787,10 +1754,6 @@ export const zh: Translations = {
backendVersion: version => `后端 v${version}`,
clientLabel: version => `客户端 v${version}`,
backendLabel: version => `后端 v${version}`,
connectionSsh: host => `SSH: ${host}`,
connectionRemote: host => `远程: ${host}`,
connectionSshTooltip: host => `已通过 SSH 连接到 ${host} · 点击管理`,
connectionRemoteTooltip: host => `已连接到远程后端 ${host} · 点击管理`,
commit: sha => `提交 ${sha}`,
branch: branch => `分支 ${branch}`,
closeCommandCenter: '关闭命令中心',
@@ -1803,7 +1766,6 @@ export const zh: Translations = {
gatewayChecking: '检查中',
gatewayConnecting: '连接中',
gatewayOffline: '离线',
gatewayRestarting: '重启中…',
gatewayTitle: 'Hermes 推理网关状态',
agents: '代理',
closeAgents: '关闭代理',
@@ -1950,7 +1912,6 @@ export const zh: Translations = {
refresh: '刷新',
moreActions: '更多操作',
branchNewChat: '在新对话中分支',
dismissError: '关闭错误',
readAloudFailed: '朗读失败',
preparingAudio: '正在准备音频...',
stopReading: '停止朗读',
@@ -2059,9 +2020,6 @@ export const zh: Translations = {
regenerateFailed: '重新生成失败',
editFailed: '编辑失败',
resumeFailed: '恢复失败',
resumeStrandedTitle: '无法加载此会话',
resumeStrandedBody: '与此会话的连接失败,自动重试已停止。请确认网关正在运行,然后重试。',
resumeRetry: '重试',
nothingToBranch: '没有可分支的内容',
branchNeedsChat: '分支前请先开始或恢复一个对话。',
sessionBusy: '会话忙碌中',

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { ComposerAttachment } from '@/store/composer'
import { coerceThinkingText, optimisticAttachmentRef, parseCommandDispatch } from './chat-runtime'
import { coerceThinkingText, optimisticAttachmentRef } from './chat-runtime'
const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
@@ -52,31 +52,3 @@ describe('coerceThinkingText', () => {
).toBe('')
})
})
describe('parseCommandDispatch', () => {
it('keeps the notice on a send directive (e.g. /goal set)', () => {
// The backend's /goal set returns {type:send, notice:"⊙ Goal set …", message}.
// Dropping the notice made /goal look like it did nothing in the desktop app.
const parsed = parseCommandDispatch({ type: 'send', notice: '⊙ Goal set', message: 'do the thing' })
expect(parsed).toEqual({ type: 'send', message: 'do the thing', notice: '⊙ Goal set' })
})
it('keeps message-only send directives working (no notice)', () => {
expect(parseCommandDispatch({ type: 'send', message: 'hi' })).toEqual({
type: 'send',
message: 'hi',
notice: undefined
})
})
it('parses a prefill directive with its notice (e.g. /undo)', () => {
const parsed = parseCommandDispatch({ type: 'prefill', notice: 'backed up 1 turn', message: 'edit me' })
expect(parsed).toEqual({ type: 'prefill', message: 'edit me', notice: 'backed up 1 turn' })
})
it('rejects a prefill directive missing its message', () => {
expect(parseCommandDispatch({ type: 'prefill', notice: 'x' })).toBeNull()
})
})

View File

@@ -238,12 +238,7 @@ export function parseCommandDispatch(raw: unknown): CommandDispatchResponse | nu
return typeof row.name === 'string' ? { type: 'skill', name: row.name, message: str(row.message) } : null
case 'send':
return typeof row.message === 'string' ? { type: 'send', message: row.message, notice: str(row.notice) } : null
case 'prefill':
return typeof row.message === 'string'
? { type: 'prefill', message: row.message, notice: str(row.notice) }
: null
return typeof row.message === 'string' ? { type: 'send', message: row.message } : null
default:
return null

View File

@@ -5,7 +5,6 @@ import { $connection } from '@/store/session'
import {
desktopDefaultCwd,
desktopGitRoot,
desktopFsCacheKey,
readDesktopDir,
readDesktopFileDataUrl,
readDesktopFileText,
@@ -114,25 +113,4 @@ describe('desktop filesystem facade', () => {
expect(remoteSelect).not.toHaveBeenCalled()
expect(selectPaths).not.toHaveBeenCalled()
})
it('cache key distinguishes two SSH hosts that share the same local forwarded port', () => {
// Both remotes resolve to the same loopback tunnel baseUrl (the local
// forwarded port is reusable across remotes). Without the remoteHost in the
// identity these collide and one host's cached fs reads serve the other.
$connection.set({ mode: 'remote', baseUrl: 'http://127.0.0.1:50001', remoteHost: 'jonny@mac-mini' } as never)
const keyA = desktopFsCacheKey()
$connection.set({ mode: 'remote', baseUrl: 'http://127.0.0.1:50001', remoteHost: 'jonny@ubuntu-box' } as never)
const keyB = desktopFsCacheKey()
expect(keyA).not.toBe(keyB)
expect(keyA).toContain('mac-mini')
expect(keyB).toContain('ubuntu-box')
})
it('cache key falls back to baseUrl when no remoteHost is present', () => {
$connection.set({ mode: 'remote', baseUrl: 'https://box.tail1234.ts.net' } as never)
expect(desktopFsCacheKey()).toContain('box.tail1234.ts.net')
$connection.set(null)
expect(desktopFsCacheKey()).toBe('local:')
})
})

View File

@@ -21,14 +21,7 @@ function connectionCacheKey(connection: HermesConnection | null) {
if (!connection) {
return 'local:'
}
// The remote host is part of the cache identity, NOT just the baseUrl. Local
// forwarded ports are reusable across different remotes, so two SSH hosts
// that happen to map to the same 127.0.0.1:<localPort> would otherwise
// collide — serving one host's cached fs reads for the other. remoteHost is
// the user@host (SSH) or the real backend host (token/oauth); fall back to
// baseUrl for safety.
const host = connection.remoteHost || connection.baseUrl || ''
return `${connection.mode || 'local'}:${connection.profile || ''}:${host}:${connection.baseUrl || ''}`
return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`
}
export function desktopFsCacheKey() {

View File

@@ -61,7 +61,6 @@ import {
IconDots as MoreHorizontal,
IconDots as MoreHorizontalIcon,
IconDotsVertical as MoreVertical,
IconServer as Network,
IconNotebook as NotebookTabs,
IconPackage as Package,
IconPalette as Palette,
@@ -164,7 +163,6 @@ export {
MoreHorizontal,
MoreHorizontalIcon,
MoreVertical,
Network,
NotebookTabs,
Package,
Palette,

View File

@@ -151,18 +151,12 @@ function normalizeVisibleProse(text: string): string {
.join('')
}
function extend(out: string[], lines: string[]) {
for (const line of lines) {
out.push(line)
}
}
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
if (info) {
out.push(`${indent}${info}`.trimEnd())
}
extend(out, lines)
out.push(...lines)
}
function findClosingFence(lines: string[], start: number, marker: string): number {
@@ -247,7 +241,7 @@ function normalizeFenceBlocks(text: string): string {
}
if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) {
extend(out, bodyLines)
out.push(...bodyLines)
index = closeIndex + 1
continue
@@ -270,10 +264,10 @@ function normalizeFenceBlocks(text: string): string {
// any literal `$$` characters in the body don't collide with
// an outer math wrapper. No close emitted yet — streaming.
out.push(`${indent}${marker}math`)
extend(out, bodyLines)
out.push(...bodyLines)
} else {
out.push(`${indent}${marker}${language}`)
extend(out, bodyLines)
out.push(...bodyLines)
}
break
@@ -294,7 +288,7 @@ function normalizeFenceBlocks(text: string): string {
// colliding with our wrapper. Without this rewrite the block
// would render as a syntax-highlighted "latex" code listing.
out.push(`${indent}${marker}math`)
extend(out, bodyLines)
out.push(...bodyLines)
out.push(`${indent}${marker}`)
index = closeIndex + 1
@@ -302,7 +296,7 @@ function normalizeFenceBlocks(text: string): string {
}
out.push(`${indent}${marker}${language}`)
extend(out, bodyLines)
out.push(...bodyLines)
out.push(`${indent}${marker}`)
index = closeIndex + 1
}

View File

@@ -1,44 +0,0 @@
import { describe, expect, it } from 'vitest'
import { storedSessionIdForNotification } from './session-ids'
describe('storedSessionIdForNotification', () => {
it('translates a runtime id back to its stored id', () => {
// The route is keyed by the stored id, but notifications carry the runtime
// id. Resolving runtime -> stored keeps notification-click navigation from
// resuming a non-existent stored session ("session not found").
const map = new Map([['stored-abc', 'runtime-123']])
expect(storedSessionIdForNotification('runtime-123', map)).toBe('stored-abc')
})
it('returns the id unchanged when no mapping is known', () => {
// A notification for a session this window never opened may already carry a
// stored id; let the resume/REST lookup handle it as-is.
const map = new Map([['stored-abc', 'runtime-123']])
expect(storedSessionIdForNotification('stored-xyz', map)).toBe('stored-xyz')
})
it('returns the id unchanged for an empty map', () => {
expect(storedSessionIdForNotification('runtime-123', new Map())).toBe('runtime-123')
})
it('resolves the correct stored id among several sessions', () => {
const map = new Map([
['stored-1', 'runtime-1'],
['stored-2', 'runtime-2'],
['stored-3', 'runtime-3']
])
expect(storedSessionIdForNotification('runtime-2', map)).toBe('stored-2')
})
it('does not treat a stored id as a runtime id (keys are not matched)', () => {
// The map is stored -> runtime. A value that only appears as a *key* must
// not be rewritten, otherwise an already-stored id could be mangled.
const map = new Map([['stored-1', 'runtime-1']])
expect(storedSessionIdForNotification('stored-1', map)).toBe('stored-1')
})
})

View File

@@ -1,26 +0,0 @@
// The gateway tags every event — and therefore every native notification —
// with the *runtime* session id (the key under which the session lives in the
// gateway's in-memory `_sessions` map). The chat route, however, is keyed by
// the *stored* session id (`stored_session_id`), which is a different value:
// a brand-new chat gets a runtime id immediately but its stored id is assigned
// when the first turn persists. Navigating to a runtime id therefore tries to
// resume a stored session that does not exist ("session not found") and
// strands the user, who experiences it as the running session being destroyed.
//
// `runtimeIdByStoredSessionId` maps stored -> runtime; this resolves the
// reverse so notification-click navigation lands on the real route. The id is
// returned unchanged when no mapping is known — it may already be a stored id
// (e.g. a notification for a session this window never opened), in which case
// the normal resume/REST lookup handles it.
export function storedSessionIdForNotification(
id: string,
runtimeIdByStoredSessionId: ReadonlyMap<string, string>
): string {
for (const [storedId, runtimeId] of runtimeIdByStoredSessionId) {
if (runtimeId === id) {
return storedId
}
}
return id
}

Some files were not shown because too many files have changed in this diff Show More