Compare commits

..

1 Commits

Author SHA1 Message Date
teknium1
7c4cde9e82 feat(plugins): let plugins register Telegram PTB handlers via ctx.register_telegram_handler
Mirrors the Slack precedent (register_slack_action_handler): plugins queue
a factory at register() time; the Telegram adapter invokes each factory
with (application, adapter) at connect() time, before the core handlers
register, so pattern-scoped plugin handlers take precedence for their own
updates while everything else falls through unchanged. Factories are
isolated — a raising plugin cannot prevent Telegram from connecting.

Unblocks standalone plugins that need PTB update types the core adapter
doesn't route (Telegram Business API secretary bots, custom callback
prefixes, chat-member events) without touching core files.
2026-07-05 14:15:35 -07:00
666 changed files with 5980 additions and 53303 deletions

2
.envrc
View File

@@ -1,4 +1,4 @@
watch_file pyproject.toml uv.lock hermes
watch_file pyproject.toml uv.lock
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix nix/hermes-agent.nix nix/desktop.nix

View File

@@ -221,17 +221,10 @@ jobs:
- name: Save baseline cache (main only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
# Degraded runs (API rate-limited) produce no ci-timings.json —
# skip rather than fail, and never cache an empty baseline.
if [ -f ci-timings.json ]; then
cp ci-timings.json ci-timings-baseline.json
else
echo "No timings JSON this run — skipping baseline update"
fi
run: cp ci-timings.json ci-timings-baseline.json
- name: Upload baseline to cache (main only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('ci-timings-baseline.json') != ''
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ci-timings-baseline.json

1
.gitignore vendored
View File

@@ -8,7 +8,6 @@ __pycache__/
.venv
.vscode/
.env
.op.env
.env.local
.env.development.local
.env.test.local

View File

@@ -491,7 +491,7 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes
### Electron Desktop Chat App (`apps/desktop/`)
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared``JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared``JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:

View File

@@ -74,7 +74,7 @@ Esto no es una barra de calidad — es una decisión de acoplamiento y mantenimi
| Requisito | Notas |
|-----------|-------|
| **Git** | Con la extensión `git-lfs` instalada |
| **Python 3.113.13** | uv lo instalará si falta |
| **Python 3.11+** | uv lo instalará si falta |
| **uv** | Gestor de paquetes Python rápido ([instalar](https://docs.astral.sh/uv/)) |
| **Node.js 20+** | Opcional — necesario para herramientas de navegador y puente WhatsApp (coincide con los engines de `package.json` raíz) |

View File

@@ -109,7 +109,7 @@ A well-built third-party-product plugin can clear automated review and still be
| Requirement | Notes |
|-------------|-------|
| **Git** | With the `git-lfs` extension installed |
| **Python 3.113.13** | uv will install it if missing |
| **Python 3.11+** | uv will install it if missing |
| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) |
| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) |

View File

@@ -1,7 +1,7 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.18.2",
"version": "0.18.0",
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
"repository": "https://github.com/NousResearch/hermes-agent",
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
@@ -9,7 +9,7 @@
"license": "MIT",
"distribution": {
"uvx": {
"package": "hermes-agent[acp]==0.18.2",
"package": "hermes-agent[acp]==0.18.0",
"args": ["hermes-acp"]
}
}

View File

@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, Optional
import httpx
from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token
from hermes_cli.auth import AuthError, _read_codex_tokens, resolve_codex_runtime_credentials
from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials
from hermes_cli.runtime_provider import resolve_runtime_provider
if TYPE_CHECKING:
@@ -436,78 +436,20 @@ def _resolve_codex_usage_url(base_url: str) -> str:
return normalized + "/api/codex/usage"
def _resolve_codex_usage_credentials(
base_url: Optional[str],
api_key: Optional[str],
) -> tuple[str, str, Optional[str]]:
"""Resolve Codex quota credentials from the native runtime path.
Prefer explicit live-agent credentials, then the legacy singleton OAuth
state, then the credential pool. Hermes's native OAuth setup now stores
device-code logins in the pool, so quota diagnostics must not depend only
on the older singleton store.
"""
explicit_key = str(api_key or "").strip()
if explicit_key:
return explicit_key, str(base_url or "").strip(), None
# Tier 2: the native runtime resolver. It ALREADY falls back to the
# credential pool when the singleton is empty (see
# ``resolve_codex_runtime_credentials`` — issue #32992), so in a pool-only
# setup this returns a usable ``source="credential_pool"`` token.
#
# Only ``AuthError`` ("no creds" / rate-limited) is caught so tier 3 can
# run: a broad ``except Exception`` would (a) mask a transient refresh /
# network failure and silently hand back a DIFFERENT pool account's usage,
# and (b) hide genuine programming errors. A refresh/network error must
# propagate — the outer ``fetch_account_usage`` guard fails open (shows
# nothing this turn) rather than reporting the wrong account.
#
# The ``account_id`` (for the ``ChatGPT-Account-Id`` header) is read
# best-effort: a partial/missing singleton token store must not sink an
# otherwise-usable resolver credential and force a header-less pool fallback.
try:
creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
account_id: Optional[str] = None
try:
token_data = _read_codex_tokens()
tokens = token_data.get("tokens") or {}
account_id = str(tokens.get("account_id", "") or "").strip() or None
except AuthError:
# Pool-only creds carry no singleton account_id; header is optional.
logger.debug("codex ▸ /usage account_id read failed (best-effort)", exc_info=True)
return creds["api_key"], str(creds.get("base_url", "") or "").strip(), account_id
except AuthError:
logger.debug("codex ▸ /usage runtime resolver returned no creds; trying pool", exc_info=True)
# Tier 3: direct pool select. Reached only when the resolver itself raises
# AuthError (e.g. singleton missing AND its own pool read found nothing at
# resolve time, but a pool entry is usable now). Pool credentials have no
# account_id concept, so the ChatGPT-Account-Id header is intentionally
# omitted here.
from agent.credential_pool import load_pool
pool = load_pool("openai-codex")
entry = pool.select()
if entry is None:
raise RuntimeError("No available openai-codex credential in credential pool")
return entry.runtime_api_key, str(entry.runtime_base_url or base_url or "").strip(), None
def _fetch_codex_account_usage(
base_url: Optional[str] = None,
api_key: Optional[str] = None,
) -> Optional[AccountUsageSnapshot]:
token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key)
def _fetch_codex_account_usage() -> Optional[AccountUsageSnapshot]:
creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
token_data = _read_codex_tokens()
tokens = token_data.get("tokens") or {}
account_id = str(tokens.get("account_id", "") or "").strip() or None
headers = {
"Authorization": f"Bearer {token}",
"Authorization": f"Bearer {creds['api_key']}",
"Accept": "application/json",
"User-Agent": "codex-cli",
}
if account_id:
headers["ChatGPT-Account-Id"] = account_id
with httpx.Client(timeout=15.0) as client:
response = client.get(_resolve_codex_usage_url(resolved_base_url), headers=headers)
response = client.get(_resolve_codex_usage_url(creds.get("base_url", "")), headers=headers)
response.raise_for_status()
payload = response.json() or {}
rate_limit = payload.get("rate_limit") or {}
@@ -686,7 +628,7 @@ def fetch_account_usage(
return None
try:
if normalized == "openai-codex":
return _fetch_codex_account_usage(base_url=base_url, api_key=api_key)
return _fetch_codex_account_usage()
if normalized == "anthropic":
return _fetch_anthropic_account_usage()
if normalized == "openrouter":

View File

@@ -68,118 +68,24 @@ def _ra():
return run_agent
def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str:
"""Build the one-time notice shown when Codex gpt-5.x raises compaction.
def _build_codex_gpt55_autoraise_notice(autoraise: Dict[str, float]) -> str:
"""Build the one-time notice shown when Codex gpt-5.5 raises compaction.
``autoraise`` is ``{"model": <slug>, "from": <old_ratio>, "to": <new_ratio>}``.
The same text is printed inline for CLI users and replayed via
``status_callback`` for gateway users, so it must be self-contained and
include the exact opt-back-out command.
``autoraise`` is ``{"from": <old_ratio>, "to": <new_ratio>}``. The same
text is printed inline for CLI users and replayed via ``status_callback``
for gateway users, so it must be self-contained and include the exact
opt-back-out command.
"""
model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1]
# gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5/5.6 family
# is capped at 272K by the Codex OAuth backend.
cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K"
from_pct = int(round(autoraise["from"] * 100))
to_pct = int(round(autoraise["to"] * 100))
return (
f" Codex {model} caps context at {cap}, so auto-compaction was raised "
f" Codex gpt-5.5 caps context at 272K, so auto-compaction was raised "
f"to {to_pct}% (from {from_pct}%) to use more of the window before "
f"summarizing.\n"
f" Opt back out: hermes config set compression.codex_gpt55_autoraise false"
)
def _resolve_compression_threshold(
global_threshold: float,
model_cthresh: Optional[float],
*,
model: Optional[str] = None,
is_codex_autoraise: bool,
) -> tuple[float, Optional[Dict[str, Any]]]:
"""Combine the user's global compaction threshold with a per-model override.
Returns ``(effective_threshold, autoraise_notice)``. ``autoraise_notice`` is
``{"model": <slug>, "from": <old>, "to": <new>}`` only when a Codex
autoraise (gpt-5.4/5.5 272K family or gpt-5.3-codex-spark) actually raises
the threshold, otherwise ``None``.
The Codex overrides are *autoraises*: they must never LOWER a higher
user-configured threshold. A user who already set ``compression.threshold``
above the raised value deliberately keeps more raw context, and silently
dropping them would both waste usable window and contradict the feature's
purpose (use more of the window). Other overrides (e.g. Arcee Trinity)
keep their existing unconditional behaviour.
"""
if model_cthresh is None:
return global_threshold, None
if is_codex_autoraise:
if model_cthresh <= global_threshold + 1e-9:
# Autoraise never lowers; keep the user's higher/equal threshold.
return global_threshold, None
return model_cthresh, {
"model": model,
"from": global_threshold,
"to": model_cthresh,
}
return model_cthresh, None
def _codex_gpt55_autoraise_notice_marker():
"""Path to the per-profile marker recording that the autoraise notice ran.
Lives under ``$HERMES_HOME`` (which is profile-scoped) alongside the other
internal markers like ``.container-mode`` — so it is not a user-facing config
key, and every profile tracks its own notice state independently.
"""
return get_hermes_home() / ".codex_gpt55_autoraise_notice"
def _codex_gpt55_autoraise_notice_state(autoraise: Dict[str, Any]) -> str:
"""Stable identity for one autoraise notice, keyed on what it displays.
Uses the model slug plus the same from→to percentages the notice text
shows, so an unchanged threshold stays silent across restarts while a
later change (the user edits their global ``threshold``, or switches to a
different autoraised Codex model) re-notifies once.
"""
model = str(autoraise.get("model") or "").strip().lower().rsplit("/", 1)[-1]
from_pct = int(round(float(autoraise["from"]) * 100))
to_pct = int(round(float(autoraise["to"]) * 100))
return f"{model}:{from_pct}:{to_pct}"
def _codex_gpt55_autoraise_notice_seen(autoraise: Dict[str, Any]) -> bool:
"""True if this exact autoraise notice was already shown for this profile.
A missing/unreadable marker (or one recording a different threshold) reads
as unseen, so the notice shows.
"""
try:
current = _codex_gpt55_autoraise_notice_state(autoraise)
return _codex_gpt55_autoraise_notice_marker().read_text(
encoding="utf-8"
).strip() == current
except (OSError, KeyError, TypeError, ValueError):
return False
def _record_codex_gpt55_autoraise_notice(autoraise: Dict[str, Any]) -> None:
"""Persist that the autoraise notice was shown for this profile/config state.
Best-effort: a read-only or missing ``$HERMES_HOME`` just means the notice
may show again next init, which is preferable to breaking agent init.
"""
try:
marker = _codex_gpt55_autoraise_notice_marker()
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text(
_codex_gpt55_autoraise_notice_state(autoraise), encoding="utf-8"
)
except (OSError, KeyError, TypeError, ValueError):
pass
def _normalized_custom_base_url(value: Any) -> str:
if not isinstance(value, str):
return ""
@@ -1457,17 +1363,6 @@ def init_agent(
# line). Useful for users on exotic setups where the probe heuristics
# are noisy.
agent._environment_probe = bool(_agent_section.get("environment_probe", True))
# Warm the probe off-thread: it shells out to python3/pip (~0.5s of
# subprocess round-trips) and its result lands in the FIRST system
# prompt build, which sits on the time-to-first-token critical path.
# The warm runs during agent init (network/credential setup dominates),
# so by the time the first prompt is built the line is already cached.
if agent._environment_probe:
try:
from tools.env_probe import warm_environment_probe_async
warm_environment_probe_async()
except Exception:
pass
# Per-platform prompt-hint overrides (config.yaml → platform_hints).
# Lets an enterprise admin append to or replace Hermes' built-in
@@ -1503,14 +1398,14 @@ def init_agent(
if not isinstance(_compression_cfg, dict):
_compression_cfg = {}
compression_threshold = float(_compression_cfg.get("threshold", 0.50))
# Per-model/route compaction-threshold override. Codex gpt-5.4 / gpt-5.5
# raise to 85% (the Codex backend caps both families at 272K, so the
# default 50% would compact at ~136K — half the usable context). Gated by
# an opt-out config flag so the user can fall back to the global threshold;
# when the override fires we stash a one-time notification (replayed on the
# first turn) that tells the user what changed and how to revert. The
# notice has its own display gate so users can keep the threshold
# autoraise without getting the banner on gateway turns.
# Per-model/route compaction-threshold override. Codex gpt-5.5 raises to
# 85% (the Codex backend caps the window at 272K, so the default 50% would
# compact at ~136K — half the usable context). Gated by an opt-out config
# flag so the user can fall back to the global threshold; when the override
# fires we stash a one-time notification (replayed on the first turn) that
# tells the user what changed and how to revert. The notice has its own
# display gate so users can keep the threshold autoraise without getting
# the banner on gateway turns.
_codex_gpt55_autoraise = str(
_compression_cfg.get("codex_gpt55_autoraise", True)
).lower() in {"true", "1", "yes"}
@@ -1521,30 +1416,28 @@ def init_agent(
try:
from agent.auxiliary_client import (
_compression_threshold_for_model as _cthresh_fn,
_is_codex_gpt54_or_gpt55 as _is_codex_gpt54_or_gpt55_fn,
_is_codex_spark as _is_codex_spark_fn,
_is_codex_gpt55 as _is_codex_gpt55_fn,
)
_model_cthresh = _cthresh_fn(
agent.model,
agent.provider,
allow_codex_gpt55_autoraise=_codex_gpt55_autoraise,
)
# The Codex autoraises (gpt-5.4/5.5 272K family and gpt-5.3-codex-spark)
# apply only when they RAISE (never lower a user's higher global
# threshold). The notice is populated only when it actually fires, and
# carries the model slug so the banner names the right family. Arcee
# Trinity keeps its long-standing unconditional behaviour.
compression_threshold, agent._compression_threshold_autoraised = (
_resolve_compression_threshold(
compression_threshold,
_model_cthresh,
model=agent.model,
is_codex_autoraise=(
_is_codex_gpt54_or_gpt55_fn(agent.model, agent.provider)
or _is_codex_spark_fn(agent.model, agent.provider)
),
)
)
if _model_cthresh is not None:
_prev_threshold = compression_threshold
compression_threshold = _model_cthresh
# Notify only for the Codex gpt-5.5 autoraise (the Arcee Trinity
# override is a long-standing silent default). Skip the notice when
# the user's global threshold already meets/exceeds the raised
# value, since nothing actually changed for them.
if (
_is_codex_gpt55_fn(agent.model, agent.provider)
and _model_cthresh > _prev_threshold + 1e-9
):
agent._compression_threshold_autoraised = {
"from": _prev_threshold,
"to": _model_cthresh,
}
except Exception:
pass
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"}
@@ -1570,16 +1463,6 @@ def init_agent(
compression_in_place = is_truthy_value(
_compression_cfg.get("in_place"), default=False
)
codex_app_server_auto_compaction = str(
_compression_cfg.get("codex_app_server_auto", "native") or "native"
).lower()
if codex_app_server_auto_compaction not in {"native", "hermes", "off"}:
_ra().logger.warning(
"Invalid compression.codex_app_server_auto=%r; using 'native'. "
"Valid values are: native, hermes, off.",
codex_app_server_auto_compaction,
)
codex_app_server_auto_compaction = "native"
# Read optional explicit context_length override for the auxiliary
# compression model. Custom endpoints often cannot report this via
@@ -1783,12 +1666,6 @@ def init_agent(
if _selected_engine is not None:
agent.context_compressor = _selected_engine
# External engines own compaction policy: the host compression
# threshold (including the Codex gpt-5.5 autoraise above) only
# configures the built-in ContextCompressor and never reaches the
# plugin, so the autoraise notice would announce a change that does
# not apply. Drop it. (#44439)
agent._compression_threshold_autoraised = None
# Resolve context_length for plugin engines — mirrors switch_model() path
from agent.model_metadata import get_model_context_length
_plugin_ctx_len = get_model_context_length(
@@ -1834,7 +1711,6 @@ def init_agent(
pass
agent.compression_enabled = compression_enabled
agent.compression_in_place = compression_in_place
agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction
# Reject models whose context window is below the minimum required
# for reliable tool-calling workflows (64K tokens).
@@ -2012,53 +1888,29 @@ def init_agent(
agent._ollama_num_ctx,
)
# Codex gpt-5.x autoraise notice: show at most once per profile/config
# state. Without the persisted marker the notice re-fires on every agent
# init — and the gateway rebuilds the agent per inbound message, so Discord
# etc. saw it repeatedly (#54432). A change in the raised threshold (or the
# autoraised model) updates the marker state and re-notifies once. The
# config display gate (compression.codex_gpt55_autoraise_notice) still
# suppresses the banner entirely without disabling the threshold autoraise.
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
_show_autoraise_notice = (
bool(_autoraise)
and compression_enabled
and _codex_gpt55_autoraise_notice
and not _codex_gpt55_autoraise_notice_seen(_autoraise)
)
if not agent.quiet_mode:
if compression_enabled:
# Report the active engine's own threshold — for a plugin engine
# the host compression_threshold is not in effect, and mixing the
# two printed a percent that contradicted the token count. (#44439)
_active_threshold_pct = getattr(
agent.context_compressor, "threshold_percent", compression_threshold
)
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,})")
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(compression_threshold*100)}% = {agent.context_compressor.threshold_tokens:,})")
else:
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)")
# Notice with the exact opt-back-out command. Printed inline at startup
# for CLI users; gateway users get the same text replayed via
# _compression_warning on turn 1 (set below).
if _show_autoraise_notice:
print(_build_codex_gpt5_autoraise_notice(_autoraise))
# One-time notice when the Codex gpt-5.5 autoraise kicked in, with the
# exact opt-back-out command. Printed inline at startup for CLI users;
# gateway users get the same text replayed via _compression_warning on
# turn 1 (set below, after the warning slot is initialized).
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
if _autoraise and compression_enabled and _codex_gpt55_autoraise_notice:
print(_build_codex_gpt55_autoraise_notice(_autoraise))
# Check immediately so CLI users see the warning at startup.
# Gateway status_callback is not yet wired, so any warning is stored
# in _compression_warning and replayed in the first run_conversation().
agent._compression_warning = None
# Gateway parity for the Codex gpt-5.x autoraise notice: the startup print
# Gateway parity for the Codex gpt-5.5 autoraise notice: the startup print
# above only reaches the CLI, so stash the same text here to be replayed
# through status_callback on the first turn (Telegram/Discord/Slack/etc.).
if _show_autoraise_notice:
agent._compression_warning = _build_codex_gpt5_autoraise_notice(_autoraise)
# Mark shown so repeated inits in this profile (e.g. every gateway message)
# stay silent. Recorded once, whether the notice went to the CLI print or
# the gateway replay slot.
if _show_autoraise_notice:
_record_codex_gpt55_autoraise_notice(_autoraise)
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
if _autoraise and compression_enabled and _codex_gpt55_autoraise_notice:
agent._compression_warning = _build_codex_gpt55_autoraise_notice(_autoraise)
# Lazy feasibility check: deferred to the first turn that approaches the
# compression threshold. Running it eagerly here costs ~400ms cold (network
# probe of the auxiliary provider chain + /models lookup) on every agent

View File

@@ -729,14 +729,7 @@ def recover_with_credential_pool(
# that seeded the pool.
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
# Guard: skip credential pool recovery when the pool is scoped to a
# different provider than the agent. Only guard when the pool has a
# known provider — an empty pool provider means "unscoped" (applies to
# any provider). An empty agent provider is treated as a mismatch
# because swapping the pool's credentials would set base_url/api_key
# without fixing the empty provider field, leaving the agent in a
# corrupted state (provider="" model="").
if pool_provider and current_provider != pool_provider:
if current_provider and pool_provider and current_provider != pool_provider:
# Custom endpoints use two naming conventions for the SAME provider:
# the agent carries the generic ``custom`` label while the pool is
# keyed ``custom:<name>`` (see CUSTOM_POOL_PREFIX). A literal string
@@ -1275,12 +1268,6 @@ def restore_primary_runtime(agent) -> bool:
agent._fallback_activated = False
agent._fallback_index = 0
# Reset the stale-call circuit breaker (#58962): the streak measured
# the FALLBACK provider we're leaving; the restored primary deserves
# a fresh stream attempt before the breaker can trip again.
from agent.chat_completion_helpers import _reset_stale_streak
_reset_stale_streak(agent)
# Undo the fallback's identity rewrite so the prompt is
# byte-identical to the stored copy again (prefix cache match).
from agent.chat_completion_helpers import rewrite_prompt_model_identity
@@ -1564,17 +1551,6 @@ def anthropic_prompt_cache_policy(
model_lower = eff_model.lower()
provider_lower = eff_provider.lower()
is_claude = "claude" in model_lower
# Kimi / Moonshot family via OpenRouter: same cache_control wire format
# as Claude on OpenRouter (envelope layout). Without this branch
# moonshotai/kimi-k2.6 falls through to (False, False), serving ~1%
# cache hits on 64K-token prompts and re-billing the full prompt on
# every turn. Observed within-turn progression with cache enabled:
# 1% → 67% → 84% → 97% (#25970). Reuses the canonical family matcher
# (covers bare k1./k2./k25 release slugs the substring check missed).
from agent.anthropic_adapter import _model_name_is_kimi_family
is_kimi = (
_model_name_is_kimi_family(eff_model) or "moonshot" in model_lower
)
is_openrouter = base_url_host_matches(eff_base_url, "openrouter.ai")
# Nous Portal proxies to OpenRouter behind the scenes — identical
# OpenAI-wire envelope cache_control semantics. Treat it as an
@@ -1588,7 +1564,7 @@ def anthropic_prompt_cache_policy(
if is_native_anthropic:
return True, True
if (is_openrouter or is_nous_portal) and (is_claude or is_kimi):
if (is_openrouter or is_nous_portal) and is_claude:
return True, False
# Nous Portal Qwen (e.g. qwen3.6-plus) takes the same envelope-layout
# cache_control path as Portal Claude. Portal proxies to OpenRouter
@@ -1816,30 +1792,13 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# ── Swap core runtime fields ──
agent.model = new_model
agent.provider = new_provider
# Use the new base_url when provided. When it's empty AND the
# provider is actually changing, do NOT fall back to the current
# (old provider's) URL — that silently pairs the new provider label
# with the previous provider's endpoint (e.g. new_provider=minimax
# paired with the leftover api.githubcopilot.com URL), and every
# request after the switch 400s at the wrong host. This mismatched
# pair also gets snapshotted into _primary_runtime below, so it
# keeps re-applying on every subsequent turn until a full restart.
# Fail loud instead: the caller (model_switch.switch_model())
# already resolves base_url for every real provider, so an empty
# value here means resolution failed upstream, not that the
# provider genuinely has none. Re-selecting the SAME provider with
# an empty base_url (e.g. a credential-only refresh) is still fine
# to keep the current URL. See #47828.
old_norm_provider = (old_provider or "").strip().lower()
new_norm_provider = (new_provider or "").strip().lower()
# Use new base_url when provided; only fall back to current when the
# new provider genuinely has no endpoint (e.g. native SDK providers).
# Without this guard the old provider's URL (e.g. Ollama's localhost
# address) would persist silently after switching to a cloud provider
# that returns an empty base_url string.
if base_url:
agent.base_url = base_url
elif old_norm_provider != new_norm_provider:
raise ValueError(
f"switch_model: no base_url resolved for provider "
f"'{new_provider}' (switching from '{old_provider}'); "
"refusing to keep the previous provider's endpoint"
)
agent.api_mode = api_mode
# Invalidate transport cache — new api_mode may need a different transport
if hasattr(agent, "_transport_cache"):
@@ -1953,11 +1912,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
_sm_timeout = get_provider_request_timeout(agent.provider, agent.model)
if _sm_timeout is not None:
agent._client_kwargs["timeout"] = _sm_timeout
# Reapply provider-specific headers (e.g. OpenRouter HTTP-Referer,
# X-Title) that were lost when _client_kwargs was rebuilt from
# scratch. Without this, model switches clear attribution headers
# and OpenRouter logs show "Unknown" for subsequent requests.
agent._apply_client_headers_for_base_url(effective_base)
agent.client = agent._create_openai_client(
dict(agent._client_kwargs),
reason="switch_model",
@@ -2031,14 +1985,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# ── Invalidate cached system prompt so it rebuilds next turn ──
agent._cached_system_prompt = None
# ── Reset the cross-turn stale-call circuit breaker (#58962) ──
# The breaker's error text tells the user to "switch models ... then
# retry"; without this reset the streak stays latched and the freshly
# selected (healthy) provider would keep short-circuiting before any
# stream is even attempted.
from agent.chat_completion_helpers import _reset_stale_streak
_reset_stale_streak(agent)
# ── Update _primary_runtime so the change persists across turns ──
_cc = agent.context_compressor if hasattr(agent, "context_compressor") and agent.context_compressor else None
agent._primary_runtime = {
@@ -2148,12 +2094,12 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
except Exception as _mw_err:
logger.debug("tool_request middleware error: %s", _mw_err)
# Check plugin hooks for a block or approval directive before executing.
# Check plugin hooks for a block directive before executing anything.
block_message: Optional[str] = None
if not pre_tool_block_checked:
try:
from hermes_cli.plugins import resolve_pre_tool_block
block_message = resolve_pre_tool_block(
from hermes_cli.plugins import get_pre_tool_call_block_message
block_message = get_pre_tool_call_block_message(
function_name,
function_args,
task_id=effective_task_id or "",
@@ -2164,7 +2110,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
middleware_trace=list(_tool_middleware_trace),
)
except Exception:
block_message = None
pass
if block_message is not None:
result = json.dumps({"error": block_message}, ensure_ascii=False)
try:
@@ -2442,41 +2388,6 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
filtered.append(msg)
messages = filtered
# --- Drop empty / malformed tool_calls arrays on assistant messages ---
# An assistant message carrying ``tool_calls: []`` (an empty array) — or a
# non-list value under the key — is semantically identical to an assistant
# message with no tool calls, but strict OpenAI-compatible providers reject
# the empty array outright: DeepSeek v4 returns HTTP 400 "Invalid
# 'messages[N].tool_calls': empty array. Expected an array with minimum
# length 1, but got an empty array instead." (#58755, follow-up to #56980).
# Empty arrays reach here from session resume, host-fed histories, or the
# consecutive-assistant merge in ``repair_message_sequence`` (which
# preserves a pre-existing ``[]`` on the surviving turn). This is the final
# pre-API chokepoint, so normalize defensively — and, per the #56980
# review, do it HERE on the per-call copy rather than in
# ``repair_message_sequence``, which would destructively rewrite the
# persisted trajectory. Shallow-copy the message before dropping the key so
# stored history (and prompt caching) stays byte-stable.
normalized: List[Dict[str, Any]] = []
dropped_empty_tool_calls = 0
for msg in messages:
if (
isinstance(msg, dict)
and msg.get("role") == "assistant"
and "tool_calls" in msg
and not (isinstance(msg["tool_calls"], list) and msg["tool_calls"])
):
msg = {k: v for k, v in msg.items() if k != "tool_calls"}
dropped_empty_tool_calls += 1
normalized.append(msg)
if dropped_empty_tool_calls:
messages = normalized
_ra().logger.debug(
"Pre-call sanitizer: dropped empty/invalid tool_calls on %d "
"assistant message(s)",
dropped_empty_tool_calls,
)
# --- Repair tool_calls whose function.name is empty/missing ---
# Some providers (and partially-streamed responses) emit a tool_call with
# id="call_xxx" but function.name="". Downstream Responses-API adapters

View File

@@ -1390,8 +1390,7 @@ _OAUTH_TOKEN_URL = _OAUTH_TOKEN_URLS[0]
_OAUTH_TOKEN_USER_AGENT = "axios/1.7.9"
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
def _get_hermes_oauth_file() -> Path:
return get_hermes_home() / ".anthropic_oauth.json"
_HERMES_OAUTH_FILE = get_hermes_home() / ".anthropic_oauth.json"
def _generate_pkce() -> tuple:
@@ -1539,10 +1538,9 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:
"""Read Hermes-managed OAuth credentials from ~/.hermes/.anthropic_oauth.json."""
oauth_file = _get_hermes_oauth_file()
if oauth_file.exists():
if _HERMES_OAUTH_FILE.exists():
try:
data = json.loads(oauth_file.read_text(encoding="utf-8"))
data = json.loads(_HERMES_OAUTH_FILE.read_text(encoding="utf-8"))
if data.get("accessToken"):
return data
except (json.JSONDecodeError, OSError, IOError) as e:

View File

@@ -314,72 +314,33 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool:
return bare == "trinity-large-thinking"
# Context window enforced by ChatGPT's Codex OAuth backend for the
# gpt-5.4 / gpt-5.5 / gpt-5.6 families. The raw OpenAI API and OpenRouter
# expose 1.05M for the same slugs, but the Codex backend hard-caps at 272K
# (verified live for 5.4/5.5: a ~330K-token request to
# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.5.
# The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the
# Codex backend hard-caps at 272K (verified live: a ~330K-token request to
# chatgpt.com/backend-api/codex/responses is rejected with
# ``context_length_exceeded`` while ~250K succeeds; gpt-5.6 shares the same
# 272K Codex cap — see _CODEX_OAUTH_CONTEXT_FALLBACK in model_metadata.py).
# With a 272K ceiling the default 50% compaction trigger fires at ~136K —
# wasteful, since the model can hold far more raw context before
# summarization actually buys anything. We raise the trigger to 85% (~231K)
# on this exact route so Codex gpt-5.4 / gpt-5.5 / gpt-5.6 sessions use the
# window they actually have.
_CODEX_GPT54_GPT55_COMPACTION_THRESHOLD = 0.85
# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a
# native 128K context window. The default 50% compaction trigger fires at
# ~64K — wasting half the usable window, often before the session has enough
# turns to summarize meaningfully. We raise the trigger to 70% (~90K) so
# spark sessions use more of the window before summarization, while still
# leaving ~38K headroom for the summary and continued conversation before
# the 128K hard limit.
_CODEX_SPARK_COMPACTION_THRESHOLD = 0.70
# ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the
# default 50% compaction trigger fires at ~136K — wasteful, since the model
# can hold far more raw context before summarization actually buys anything.
# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.5
# sessions use the window they actually have.
_CODEX_GPT55_COMPACTION_THRESHOLD = 0.85
def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
"""True for gpt-5.4 / gpt-5.5 / gpt-5.6 on the ChatGPT Codex OAuth backend.
def _is_codex_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
"""True for gpt-5.5 accessed through the ChatGPT Codex OAuth backend.
Matches only the Codex OAuth route (provider ``openai-codex``), not the
direct OpenAI API, OpenRouter, or GitHub Copilot paths — those expose a
larger context window for the same slug and must keep the user's default
compaction threshold. ``-pro`` variants and dated snapshots are matched
via prefix so the override tracks every 272K-capped family (5.4, 5.5,
5.6 sol/terra/luna incl. their ``-pro`` modes) without re-listing every
variant. (Name kept for backward compatibility with the
``compression.codex_gpt55_autoraise`` config key.)
compaction threshold. ``gpt-5.5-pro`` and dated snapshots
(``gpt-5.5-2026-04-23``) are matched via prefix so the override tracks the
family without re-listing every variant.
"""
prov = (provider or "").strip().lower()
if prov != "openai-codex":
return False
bare = (model or "").strip().lower().rsplit("/", 1)[-1]
return (
bare == "gpt-5.4"
or bare.startswith("gpt-5.4-")
or bare.startswith("gpt-5.4.")
or bare == "gpt-5.5"
or bare.startswith("gpt-5.5-")
or bare.startswith("gpt-5.5.")
or bare == "gpt-5.6"
or bare.startswith("gpt-5.6-")
or bare.startswith("gpt-5.6.")
)
def _is_codex_spark(model: Optional[str], provider: Optional[str] = None) -> bool:
"""True for ``gpt-5.3-codex-spark`` on the ChatGPT Codex OAuth backend.
The model is Codex-OAuth-only (ChatGPT Pro entitlement) with a native
128K context window. Only the Codex OAuth route (provider
``openai-codex``) is matched — the slug is not available on other
routes.
"""
prov = (provider or "").strip().lower()
if prov != "openai-codex":
return False
bare = (model or "").strip().lower().rsplit("/", 1)[-1]
return bare == "gpt-5.3-codex-spark"
return bare == "gpt-5.5" or bare.startswith("gpt-5.5-") or bare.startswith("gpt-5.5.")
def _fixed_temperature_for_model(
@@ -418,27 +379,18 @@ def _compression_threshold_for_model(
Per-model/route overrides:
- Arcee Trinity Large Thinking → 0.75 (preserve reasoning context).
- gpt-5.4 / gpt-5.5 / gpt-5.6 on the Codex OAuth route → 0.85, because
Codex caps all three families at 272K and the default 50% trigger
would compact at ~136K. Gated by ``allow_codex_gpt55_autoraise``
(historical config-key name kept for backward compatibility) so the
user can opt back down to the global default (the caller passes the
config flag through here).
- gpt-5.3-codex-spark on the Codex OAuth route → 0.70, because the model
has a native 128K window and the default 50% trigger would compact at
~64K — wasting half the usable context. Not gated by the gpt-5.5
opt-out flag: 128K is the model's native window, so the raise is
unambiguously correct.
- gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps the window
at 272K and the default 50% trigger would compact at ~136K. Gated by
``allow_codex_gpt55_autoraise`` so the user can opt back down to the
global default (the caller passes the config flag through here).
Returns a float in (0, 1] to override the global ``compression.threshold``
config value, or ``None`` to leave the user's config value unchanged.
"""
if _is_arcee_trinity_thinking(model):
return 0.75
if allow_codex_gpt55_autoraise and _is_codex_gpt54_or_gpt55(model, provider):
return _CODEX_GPT54_GPT55_COMPACTION_THRESHOLD
if _is_codex_spark(model, provider):
return _CODEX_SPARK_COMPACTION_THRESHOLD
if allow_codex_gpt55_autoraise and _is_codex_gpt55(model, provider):
return _CODEX_GPT55_COMPACTION_THRESHOLD
return None
# Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
@@ -1365,96 +1317,6 @@ class AsyncAnthropicAuxiliaryClient:
self._real_client = sync_wrapper._real_client
class _BedrockCompletionsAdapter:
"""Translates ``chat.completions.create(**kwargs)`` into Bedrock Converse."""
def __init__(self, region: str, model: str):
self._region = region
self._model = model
def create(self, **kwargs) -> Any:
from agent.bedrock_adapter import call_converse
messages = kwargs.get("messages", [])
model = kwargs.get("model", self._model)
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens")
# OpenAI accepts ``stop`` as str or list; Converse requires a list.
stop = kwargs.get("stop")
if isinstance(stop, str):
stop = [stop]
if kwargs.get("tool_choice") is not None:
# Converse's toolChoice isn't wired through call_converse();
# no in-tree auxiliary caller passes tool_choice today. Surface
# the drop instead of silently ignoring it.
logger.debug(
"BedrockAuxiliaryClient: tool_choice=%r not supported by the "
"Converse shim — ignored.", kwargs.get("tool_choice"),
)
if kwargs.get("stream"):
# Converse streaming isn't wired through this shim. Return a
# complete response instead — call_llm's streaming consumer
# detects a final object and downgrades to non-live output.
logger.debug(
"BedrockAuxiliaryClient: stream=True requested for %s"
"returning a complete response (Converse shim does not "
"stream); caller downgrades to non-streaming.",
model,
)
return call_converse(
region=self._region,
model=model,
messages=messages,
tools=kwargs.get("tools"),
max_tokens=int(max_tokens) if max_tokens else 4096,
temperature=kwargs.get("temperature"),
top_p=kwargs.get("top_p"),
stop_sequences=stop,
)
class _BedrockChatShim:
def __init__(self, adapter: "_BedrockCompletionsAdapter"):
self.completions = adapter
class BedrockAuxiliaryClient:
"""OpenAI-client-compatible wrapper over AWS Bedrock Converse API."""
def __init__(self, region: str, model: str):
self._region = region
self._model = model
adapter = _BedrockCompletionsAdapter(region, model)
self.chat = _BedrockChatShim(adapter)
self.api_key = "aws-sdk"
self.base_url = f"https://bedrock-runtime.{region}.amazonaws.com"
def close(self):
pass
class _AsyncBedrockCompletionsAdapter:
def __init__(self, sync_adapter: _BedrockCompletionsAdapter):
self._sync = sync_adapter
async def create(self, **kwargs) -> Any:
import asyncio
return await asyncio.to_thread(self._sync.create, **kwargs)
class _AsyncBedrockChatShim:
def __init__(self, adapter: _AsyncBedrockCompletionsAdapter):
self.completions = adapter
class AsyncBedrockAuxiliaryClient:
def __init__(self, sync_wrapper: "BedrockAuxiliaryClient"):
sync_adapter = sync_wrapper.chat.completions
async_adapter = _AsyncBedrockCompletionsAdapter(sync_adapter)
self.chat = _AsyncBedrockChatShim(async_adapter)
self.api_key = sync_wrapper.api_key
self.base_url = sync_wrapper.base_url
def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
"""True if the endpoint at ``base_url`` speaks the Anthropic Messages
protocol instead of OpenAI chat.completions.
@@ -1510,8 +1372,6 @@ def _maybe_wrap_anthropic(
# Already wrapped — don't double-wrap.
if _safe_isinstance(client_obj, AnthropicAuxiliaryClient):
return client_obj
if _safe_isinstance(client_obj, BedrockAuxiliaryClient):
return client_obj
# Other specialized adapters we should never re-dispatch.
if _safe_isinstance(client_obj, CodexAuxiliaryClient):
return client_obj
@@ -2143,76 +2003,6 @@ def _read_main_provider() -> str:
return ""
def _read_main_api_key() -> str:
"""Read the user's main model API key from the runtime override or config.
Mirrors ``_read_main_model`` / ``_read_main_provider``: checks the
process-local ``_RUNTIME_MAIN_API_KEY`` override first (set by
``set_runtime_main`` when an AIAgent is active), then falls back to
``model.api_key`` in config.yaml.
Used by the ``custom`` provider fallback chain so that auxiliary tasks
configured with an explicit ``base_url`` but empty ``api_key`` inherit
the main model's credentials instead of falling to ``no-key-required``
(issue #9318).
"""
override = _RUNTIME_MAIN_API_KEY
if isinstance(override, str) and override.strip():
return override.strip()
try:
from hermes_cli.config import load_config
cfg = load_config()
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
key = model_cfg.get("api_key", "")
if isinstance(key, str) and key.strip():
return key.strip()
except Exception:
pass
return ""
def _read_main_base_url() -> str:
"""Read the main model's base_url from the runtime override or config.
Same override-then-config pattern as ``_read_main_api_key``.
"""
override = _RUNTIME_MAIN_BASE_URL
if isinstance(override, str) and override.strip():
return override.strip()
try:
from hermes_cli.config import load_config
cfg = load_config()
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
base = model_cfg.get("base_url", "")
if isinstance(base, str) and base.strip():
return base.strip()
except Exception:
pass
return ""
def _read_main_api_key_if_same_host(aux_base_url: str) -> str:
"""Return the main api_key only when *aux_base_url* points at the same
host as the main model's base_url.
The #9318 use case is an auxiliary task sharing the main model's
self-hosted gateway (same host, different model) with an empty per-task
api_key. Inheriting unconditionally would send the main credential to
ANY host a misconfigured aux base_url names — a cross-host credential
leak. A host mismatch keeps the previous fail-safe behavior
(``no-key-required`` → 401).
"""
aux_host = base_url_hostname(aux_base_url)
if not aux_host:
return ""
main_host = base_url_hostname(_read_main_base_url())
if not main_host or aux_host != main_host:
return ""
return _read_main_api_key()
# Process-local override set by AIAgent at session/turn start. Single-threaded
# per turn — no lock needed. Cleared by ``clear_runtime_main()``.
_RUNTIME_MAIN_PROVIDER: str = ""
@@ -3489,21 +3279,6 @@ def _refresh_provider_credentials(provider: str) -> bool:
"""Refresh short-lived credentials for OAuth-backed auxiliary providers."""
normalized = _normalize_aux_provider(provider)
try:
if normalized == "copilot":
from hermes_cli.copilot_auth import (
_jwt_cache,
_token_fingerprint,
exchange_copilot_token,
resolve_copilot_token,
)
raw_token, _source = resolve_copilot_token()
if not str(raw_token or "").strip():
return False
_jwt_cache.pop(_token_fingerprint(raw_token), None)
exchange_copilot_token(raw_token)
_evict_cached_clients(normalized)
return True
if normalized == "openai-codex":
from hermes_cli.auth import resolve_codex_runtime_credentials
@@ -3558,152 +3333,6 @@ def _refresh_provider_credentials(provider: str) -> bool:
return False
def _auth_refresh_provider_for_route(
resolved_provider: Optional[str],
client_base_url: str,
) -> str:
"""Return the provider whose short-lived credentials should be refreshed.
Auto-routed auxiliary calls keep ``resolved_provider == "auto"`` even
after _get_cached_client() selects a concrete backend. Infer the backend
from the selected client's base URL so auth refresh works for auto →
Copilot/Codex/Anthropic/Nous routes too. (#20832)
"""
normalized = _normalize_aux_provider(resolved_provider)
if normalized and normalized != "auto":
return normalized
if base_url_host_matches(client_base_url, "api.githubcopilot.com"):
return "copilot"
if base_url_host_matches(client_base_url, "chatgpt.com"):
return "openai-codex"
if base_url_host_matches(client_base_url, "api.anthropic.com"):
return "anthropic"
if base_url_host_matches(client_base_url, "inference-api.nousresearch.com"):
return "nous"
return normalized
def _call_fallback_candidate_sync(
fb_client: Any,
fb_model: Optional[str],
fb_label: str,
*,
task: Optional[str],
messages: list,
temperature: Optional[float],
max_tokens: Optional[int],
tools: Optional[list],
effective_timeout: float,
effective_extra_body: dict,
) -> Optional[Any]:
"""Call one fallback candidate with stale-credential recovery.
A fallback candidate can itself carry a stale credential (e.g. an expired
``ANTHROPIC_TOKEN`` picked up by ``_try_anthropic``). Before this helper,
such a 401 propagated out of the fallback site and aborted the auxiliary
task (for compression: a 60s cooldown + context marker) even though other
healthy candidates remained. Live case: a Codex-timeout → Anthropic
fallback 401-looped five times in one session (mattalachia debug dump,
Jul 2026).
On an auth error: refresh the candidate's provider credentials and retry
once with a rebuilt client; if the retry also auth-fails (non-refreshable
expired token), mark the provider unhealthy and return ``None`` so the
caller can continue to the next fallback layer. Non-auth errors raise.
"""
fb_base = str(getattr(fb_client, "base_url", "") or "")
fb_kwargs = _build_call_kwargs(
fb_label, fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body, base_url=fb_base)
try:
return _validate_llm_response(
fb_client.chat.completions.create(**fb_kwargs), task)
except Exception as fb_err:
if not _is_auth_error(fb_err):
raise
fb_provider = _auth_refresh_provider_for_route(fb_label, fb_base)
if fb_provider not in {"auto", "", None} and _refresh_provider_credentials(fb_provider):
retry_client, retry_model = _get_cached_client(fb_provider, fb_model)
if retry_client is not None:
retry_kwargs = _build_call_kwargs(
fb_provider, retry_model or fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body,
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
try:
return _validate_llm_response(
retry_client.chat.completions.create(**retry_kwargs), task)
except Exception as retry_err:
if not _is_auth_error(retry_err):
raise
# Refresh unavailable or the refreshed credential still 401s —
# the token is dead (expired setup token with no refresh token).
# Quarantine the candidate so subsequent chain walks skip it, and
# let the caller move on instead of aborting the whole task.
_mark_provider_unhealthy(fb_provider or fb_label)
logger.warning(
"Auxiliary %s: fallback candidate %s has a stale/unrefreshable "
"credential (%s) — skipping to next fallback",
task or "call", fb_label, fb_err,
)
return None
async def _call_fallback_candidate_async(
fb_client: Any,
fb_model: Optional[str],
fb_label: str,
*,
task: Optional[str],
messages: list,
temperature: Optional[float],
max_tokens: Optional[int],
tools: Optional[list],
effective_timeout: float,
effective_extra_body: dict,
) -> Optional[Any]:
"""Async mirror of :func:`_call_fallback_candidate_sync`."""
fb_base = str(getattr(fb_client, "base_url", "") or "")
fb_kwargs = _build_call_kwargs(
fb_label, fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body, base_url=fb_base)
try:
return _validate_llm_response(
await fb_client.chat.completions.create(**fb_kwargs), task)
except Exception as fb_err:
if not _is_auth_error(fb_err):
raise
fb_provider = _auth_refresh_provider_for_route(fb_label, fb_base)
if fb_provider not in {"auto", "", None} and _refresh_provider_credentials(fb_provider):
retry_client, retry_model = _get_cached_client(
fb_provider, fb_model, async_mode=True)
if retry_client is not None:
retry_kwargs = _build_call_kwargs(
fb_provider, retry_model or fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body,
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
try:
return _validate_llm_response(
await retry_client.chat.completions.create(**retry_kwargs), task)
except Exception as retry_err:
if not _is_auth_error(retry_err):
raise
_mark_provider_unhealthy(fb_provider or fb_label)
logger.warning(
"Auxiliary %s (async): fallback candidate %s has a stale/unrefreshable "
"credential (%s) — skipping to next fallback",
task or "call", fb_label, fb_err,
)
return None
def _try_payment_fallback(
failed_provider: str,
task: str = None,
@@ -4305,8 +3934,6 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
return AsyncCodexAuxiliaryClient(sync_client), model
if isinstance(sync_client, AnthropicAuxiliaryClient):
return AsyncAnthropicAuxiliaryClient(sync_client), model
if isinstance(sync_client, BedrockAuxiliaryClient):
return AsyncBedrockAuxiliaryClient(sync_client), model
try:
from agent.gemini_native_adapter import GeminiNativeClient, AsyncGeminiNativeClient
@@ -4454,15 +4081,7 @@ def resolve_provider_client(
# main_model also empty), the branches still hit their own
# missing-credentials returns and ``_resolve_auto`` falls through to
# the Step-2 chain as before.
#
# Prefer explicit caller model, then provider-scoped aux model, then main model.
# Do NOT pre-fill a blank ``auto`` request from the config/main default here.
# ``auto`` has its own main-runtime resolver below; pre-filling first can pair
# a stale configured model with a live fallback provider (e.g. Claude model
# sent to Codex after the main lane fell back to gpt-5.5). Let _resolve_auto()
# return the actual current runtime model when the caller did not explicitly
# request one. (# compression-current-model)
if not model and provider != "auto":
if not model:
model = _get_aux_model_for_provider(provider) or _read_main_model() or model
def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool:
@@ -4618,14 +4237,11 @@ def resolve_provider_client(
# ── Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) ───────────
if provider == "custom":
custom_base = ""
custom_key = ""
if explicit_base_url:
custom_base = _to_openai_base_url(explicit_base_url).strip()
custom_key = (
(explicit_api_key or "").strip()
or os.getenv("OPENAI_API_KEY", "").strip()
or _read_main_api_key_if_same_host(custom_base)
or "no-key-required" # local servers don't need auth
)
if not custom_base:
@@ -4634,19 +4250,6 @@ def resolve_provider_client(
"but base_url is empty"
)
return None, None
elif main_runtime:
# When main_runtime carries a concrete base_url + api_key for a
# named custom provider (custom:<name>), use it directly instead
# of re-resolving from the bare "custom" provider name.
# Re-resolution loses the provider name and falls back to
# OpenRouter or a wrong API-key provider — the main agent already
# solved this, we just need to reuse its answer. (#45472)
_main_base = str(main_runtime.get("base_url") or "").strip().rstrip("/")
_main_key = str(main_runtime.get("api_key") or "").strip()
if _main_base and _main_key:
custom_base = _main_base
custom_key = _main_key
if custom_base and custom_key:
final_model = _normalize_resolved_model(
model or (main_runtime.get("model") if main_runtime else None) or "gpt-4o-mini",
provider,
@@ -5046,14 +4649,10 @@ def resolve_provider_client(
else (client, final_model))
elif pconfig.auth_type == "aws_sdk":
# AWS SDK providers (Bedrock) — Claude models use the Anthropic Bedrock
# SDK (prompt caching, thinking); non-Claude models use Converse API.
# AWS SDK providers (Bedrock) — use the Anthropic Bedrock client via
# boto3's credential chain (IAM roles, SSO, env vars, instance metadata).
try:
from agent.bedrock_adapter import (
has_aws_credentials,
is_anthropic_bedrock_model,
resolve_bedrock_region,
)
from agent.bedrock_adapter import has_aws_credentials, resolve_bedrock_region
from agent.anthropic_adapter import build_anthropic_bedrock_client
except ImportError:
logger.warning("resolve_provider_client: bedrock requested but "
@@ -5068,26 +4667,17 @@ def resolve_provider_client(
region = resolve_bedrock_region()
default_model = "anthropic.claude-haiku-4-5-20251001-v1:0"
final_model = _normalize_resolved_model(model or default_model, provider)
base_url = f"https://bedrock-runtime.{region}.amazonaws.com"
if is_anthropic_bedrock_model(final_model):
try:
real_client = build_anthropic_bedrock_client(region)
except ImportError as exc:
logger.warning("resolve_provider_client: cannot create Bedrock "
"client: %s", exc)
return None, None
client = AnthropicAuxiliaryClient(
real_client, final_model, api_key="aws-sdk",
base_url=base_url,
)
logger.debug("resolve_provider_client: bedrock anthropic (%s, %s)",
final_model, region)
else:
client = BedrockAuxiliaryClient(region, final_model)
logger.debug("resolve_provider_client: bedrock converse (%s, %s)",
final_model, region)
try:
real_client = build_anthropic_bedrock_client(region)
except ImportError as exc:
logger.warning("resolve_provider_client: cannot create Bedrock "
"client: %s", exc)
return None, None
client = AnthropicAuxiliaryClient(
real_client, final_model, api_key="aws-sdk",
base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
)
logger.debug("resolve_provider_client: bedrock (%s, %s)", final_model, region)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
@@ -5942,18 +5532,6 @@ def _resolve_task_provider_model(
if cfg_provider:
cfg_provider, cfg_base_url = _expand_direct_api_alias(cfg_provider, cfg_base_url)
# An explicit provider arg without an explicit base_url must not bypass
# the task's configured endpoint: adopt auxiliary.<task>.base_url/api_key
# when the config targets the same provider (or names none), so the
# early `if provider:` return below carries the configured endpoint
# instead of falling through to main-runtime resolution (#58515).
# An explicit "auto" is excluded — it means "inherit / auto-detect" and
# must keep flowing through the existing auto-resolution chain.
if provider and provider != "auto" and not base_url and cfg_base_url and cfg_provider in (None, provider):
base_url = cfg_base_url
if not api_key:
api_key = cfg_api_key
if base_url and _preserve_provider_with_base_url(provider):
return provider, resolved_model, base_url, api_key, resolved_api_mode
if base_url:
@@ -5981,17 +5559,6 @@ def _resolve_task_provider_model(
_DEFAULT_AUX_TIMEOUT = 30.0
# Compression summarises large conversation histories; a reasoning auxiliary
# model (e.g. Codex / GPT-5.5) can legitimately take longer than the default
# ``auxiliary.compression.timeout`` (120 s), causing the stream to time out and
# the compressor to fall back to the deterministic context marker (#54915).
# This is a bounded *floor* applied only to config-derived compression timeouts
# — it does not affect other auxiliary tasks and does not override an explicit
# per-call ``timeout=``. A floor is harmless for fast compression models
# (they finish before the deadline) and is a minimum, so a higher config value
# is kept unchanged.
_COMPRESSION_TIMEOUT_FLOOR_SECONDS = 300.0
def _get_auxiliary_task_config(task: str) -> Dict[str, Any]:
"""Return the config dict for auxiliary.<task>, or {} when unavailable.
@@ -6051,23 +5618,6 @@ def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float
return default
def _effective_aux_timeout(task: str, timeout: Optional[float]) -> float:
"""Resolve the effective timeout for an auxiliary LLM call.
Uses the caller-provided ``timeout`` when given; otherwise reads
``auxiliary.{task}.timeout`` from config via :func:`_get_task_timeout`.
For the ``compression`` task only, applies a bounded floor so a reasoning
model summarising a large context is not cut off by the default timeout
(#54915). The floor is intentionally skipped when the caller passes an
explicit ``timeout=`` — explicit per-call deadlines are always honoured —
and it is a minimum (``max``), so a config value already above it is kept.
"""
effective = timeout if timeout is not None else _get_task_timeout(task)
if timeout is None and task == "compression":
effective = max(effective, _COMPRESSION_TIMEOUT_FLOOR_SECONDS)
return effective
def _get_task_extra_body(task: str) -> Dict[str, Any]:
"""Read auxiliary.<task>.extra_body and return a shallow copy when valid."""
task_config = _get_auxiliary_task_config(task)
@@ -6502,7 +6052,7 @@ def call_llm(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
f"Run: hermes setup")
effective_timeout = _effective_aux_timeout(task, timeout)
effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
# Log what we're about to do — makes auxiliary operations visible
_base_info = str(getattr(client, "base_url", resolved_base_url) or "")
@@ -6741,24 +6291,18 @@ def call_llm(
refreshed_client.chat.completions.create(**kwargs), task)
# ── Auth refresh retry ───────────────────────────────────────
auth_refresh_provider = _auth_refresh_provider_for_route(
resolved_provider, _base_info)
if (_is_auth_error(first_err)
and auth_refresh_provider not in {"auto", "", None}
and resolved_provider not in {"auto", "", None}
and not client_is_nous):
if _refresh_provider_credentials(auth_refresh_provider):
if auth_refresh_provider != _normalize_aux_provider(resolved_provider):
# The stale client is cached under the route label
# (e.g. "auto"), not the concrete backend we refreshed.
_evict_cached_clients(resolved_provider)
if _refresh_provider_credentials(resolved_provider):
logger.info(
"Auxiliary %s: refreshed %s credentials after auth error, retrying",
task or "call", auth_refresh_provider,
task or "call", resolved_provider,
)
return _retry_same_provider_sync(
task=task,
resolved_provider=auth_refresh_provider,
resolved_model=resolved_model or final_model,
resolved_provider=resolved_provider,
resolved_model=resolved_model,
resolved_base_url=resolved_base_url,
resolved_api_key=resolved_api_key,
resolved_api_mode=resolved_api_mode,
@@ -6928,28 +6472,14 @@ def call_llm(
resolved_provider, task, reason=reason)
if fb_client is not None:
fb_resp = _call_fallback_candidate_sync(
fb_client, fb_model, fb_label,
task=task, messages=messages,
fb_kwargs = _build_call_kwargs(
fb_label, fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body)
if fb_resp is not None:
return fb_resp
# The candidate had a stale/unrefreshable credential and was
# quarantined — walk the discovery chain once more; unhealthy
# entries are skipped so the next viable candidate serves.
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason="stale fallback credential")
if fb_client is not None:
fb_resp = _call_fallback_candidate_sync(
fb_client, fb_model, fb_label,
task=task, messages=messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body)
if fb_resp is not None:
return fb_resp
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body,
base_url=str(getattr(fb_client, "base_url", "") or ""))
return _validate_llm_response(
fb_client.chat.completions.create(**fb_kwargs), task)
# All fallback layers exhausted — emit a single user-visible
# warning so the operator knows aux task is about to fail.
# (#26882) The error itself is re-raised below.
@@ -7111,7 +6641,7 @@ async def async_call_llm(
f"No LLM provider configured for task={task} provider={resolved_provider}. "
f"Run: hermes setup")
effective_timeout = _effective_aux_timeout(task, timeout)
effective_timeout = timeout if timeout is not None else _get_task_timeout(task)
# Pass the client's actual base_url (not just resolved_base_url) so
# endpoint-specific temperature overrides can distinguish
@@ -7289,24 +6819,18 @@ async def async_call_llm(
await refreshed_client.chat.completions.create(**kwargs), task)
# ── Auth refresh retry (mirrors sync call_llm) ───────────────
auth_refresh_provider = _auth_refresh_provider_for_route(
resolved_provider, _client_base)
if (_is_auth_error(first_err)
and auth_refresh_provider not in {"auto", "", None}
and resolved_provider not in {"auto", "", None}
and not client_is_nous):
if _refresh_provider_credentials(auth_refresh_provider):
if auth_refresh_provider != _normalize_aux_provider(resolved_provider):
# The stale client is cached under the route label
# (e.g. "auto"), not the concrete backend we refreshed.
_evict_cached_clients(resolved_provider)
if _refresh_provider_credentials(resolved_provider):
logger.info(
"Auxiliary %s (async): refreshed %s credentials after auth error, retrying",
task or "call", auth_refresh_provider,
task or "call", resolved_provider,
)
return await _retry_same_provider_async(
task=task,
resolved_provider=auth_refresh_provider,
resolved_model=resolved_model or final_model,
resolved_provider=resolved_provider,
resolved_model=resolved_model,
resolved_base_url=resolved_base_url,
resolved_api_key=resolved_api_key,
resolved_api_mode=resolved_api_mode,
@@ -7433,34 +6957,20 @@ async def async_call_llm(
resolved_provider, task, reason=reason)
if fb_client is not None:
fb_kwargs = _build_call_kwargs(
fb_label, fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body,
base_url=str(getattr(fb_client, "base_url", "") or ""))
# Convert sync fallback client to async
async_fb, async_fb_model = _to_async_client(
fb_client, fb_model or "", is_vision=(task == "vision")
)
fb_resp = await _call_fallback_candidate_async(
async_fb, async_fb_model or fb_model, fb_label,
task=task, messages=messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body)
if fb_resp is not None:
return fb_resp
# Stale/unrefreshable candidate credential — quarantined; walk
# the discovery chain once more (unhealthy entries skipped).
fb_client, fb_model, fb_label = _try_payment_fallback(
resolved_provider, task, reason="stale fallback credential")
if fb_client is not None:
async_fb, async_fb_model = _to_async_client(
fb_client, fb_model or "", is_vision=(task == "vision")
)
fb_resp = await _call_fallback_candidate_async(
async_fb, async_fb_model or fb_model, fb_label,
task=task, messages=messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body)
if fb_resp is not None:
return fb_resp
if async_fb_model and async_fb_model != fb_kwargs.get("model"):
fb_kwargs["model"] = async_fb_model
return _validate_llm_response(
await async_fb.chat.completions.create(**fb_kwargs), task)
# All fallback layers exhausted — warn before re-raising. (#26882)
logger.warning(
"Auxiliary %s (async): %s on %s and all fallbacks exhausted "

View File

@@ -449,21 +449,10 @@ def summarize_background_review_actions(
data = json.loads(msg.get("content", "{}"))
except (json.JSONDecodeError, TypeError):
continue
# ``data`` may not be a dict — some memory/skill tool responses in
# older codepaths or wrapper MCP servers return a top-level JSON
# list (e.g. ``[{"success": true, ...}]``) or a scalar. The original
# isinstance check below silently skips non-dict payloads, which
# is correct, but ``data.get("_change")`` further down can still
# hand back a list and break ``change.get("description", "")``.
# Defensively normalize everything through a dict-typed alias so
# the rest of the function can stay terse without per-call
# ``isinstance`` guards (#59437).
if not isinstance(data, dict) or not data.get("success"):
continue
message = data.get("message", "")
detail = call_details.get(tcid) or {}
if not isinstance(detail, dict):
detail = {}
detail = call_details.get(tcid, {})
target = data.get("target", "") or detail.get("target", "")
is_skill = detail.get("tool") == "skill_manage"
@@ -491,30 +480,12 @@ def summarize_background_review_actions(
content = detail.get("content", "")
old_text = detail.get("old_text", "")
skill_name = detail.get("name", "")
# ``operations`` may be anything callable put into the JSON
# arguments. Anything non-iterable that isn't a list[str]
# of dicts becomes unusable here, so coerce defensively.
ops_raw = detail.get("operations")
operations: list = (
ops_raw if isinstance(ops_raw, list) else []
)
operations = detail.get("operations") or []
max_preview = 120
if is_skill:
# ``_change`` is a free-form dict the skill tool leaves in
# the response. Older / wrapper MCP backends return it
# as a list, an int, or a JSON-shaped scalar — normalize
# to a dict so the .get() calls downstream don't
# AttributeError (#59437).
change_raw = data.get("_change")
change: dict = (
change_raw if isinstance(change_raw, dict) else {}
)
old_string = (
change.get("old", "") or detail.get("old_string", "")
)
new_string = (
change.get("new", "") or detail.get("new_string", "")
)
change = data.get("_change", {})
old_string = change.get("old", "") or detail.get("old_string", "")
new_string = change.get("new", "") or detail.get("new_string", "")
description = change.get("description", "")
if action == "patch" and (old_string or new_string):
old_preview = old_string[:80].replace("\n", " ") + (
@@ -535,13 +506,7 @@ def summarize_background_review_actions(
actions.append(f"📝 {message}" if message else f"Skill {action}")
elif operations:
for op in operations:
# Each element must be a dict-of-fields; some
# legacy codepaths serialize the entry as a bare
# string and the message dict doesn't exist. Skip
# non-dict items defensively — they have no
# actionable fields anyway (#59437).
if not isinstance(op, dict):
continue
op = op or {}
op_act = op.get("action", "")
op_content = (op.get("content") or "")
op_old = (op.get("old_text") or "")
@@ -854,29 +819,11 @@ def _run_review_in_thread(
# the review agent inherits that history and would otherwise
# re-surface stale "created"/"updated" messages from the prior
# conversation as if they just happened (issue #14944).
#
# Wrapped in try/except: a buggy/legacy tool response shape
# (e.g. ``_change`` returned as a list instead of a dict, #59437)
# must NOT take down the whole review with an AttributeError,
# since the caller's outer except logs only "Background
# memory/skill review failed" and discards every successful
# action the fork DID complete before the crash. Coerce an
# exception into an empty actions list so the partial valid
# actions from earlier in the messages are returned instead.
try:
actions = summarize_background_review_actions(
review_messages,
messages_snapshot,
notification_mode=getattr(agent, "memory_notifications", "on"),
)
except Exception as e:
logger.warning(
"summarize_background_review_actions returned partial results "
"after exception (treating as empty); suppressing AttributeError "
"that previously aborted the entire review (#59437): %s",
e,
)
actions = []
actions = summarize_background_review_actions(
review_messages,
messages_snapshot,
notification_mode=getattr(agent, "memory_notifications", "on"),
)
if actions:
summary = " · ".join(dict.fromkeys(actions))

View File

@@ -171,52 +171,6 @@ def _env_float(name: str, default: float) -> float:
return default
# ── Cross-turn stale-call circuit breaker (#58962) ─────────────────────
# A session wedged against an unresponsive provider hits the stale detector
# on every call and loops forever (observed: 494 consecutive failures over
# 3+ days, each burning the full stale timeout × retries with no response).
# The agent carries ``_consecutive_stale_streams``: incremented on every
# stale kill, reset only when a call actually completes (or when the
# provider is swapped — switch_model / try_activate_fallback /
# restore_primary_runtime — since the streak measured the OLD provider).
# Past the give-up threshold, calls abort immediately with an actionable
# error instead of re-waiting out the stale timeout.
def _stale_streak(agent) -> int:
try:
return int(getattr(agent, "_consecutive_stale_streams", 0) or 0)
except Exception:
return 0
def _bump_stale_streak(agent) -> None:
try:
agent._consecutive_stale_streams = _stale_streak(agent) + 1
except Exception:
pass
def _reset_stale_streak(agent) -> None:
try:
agent._consecutive_stale_streams = 0
except Exception:
pass
def _check_stale_giveup(agent) -> None:
"""Raise immediately when the consecutive-stale streak is past the
give-up threshold — no network attempt, no stale-timeout wait."""
_giveup = env_int("HERMES_STREAM_STALE_GIVEUP", 5)
_streak = _stale_streak(agent)
if _giveup > 0 and _streak >= _giveup:
raise RuntimeError(
"Provider has been unresponsive (no response received) for "
f"{_streak} consecutive stale attempts — aborting this call to "
"avoid an indefinite stall. Switch models or start a new "
"session, then retry."
)
def interruptible_api_call(agent, api_kwargs: dict):
"""
Run the API call in a background thread so the main conversation loop
@@ -232,13 +186,6 @@ def interruptible_api_call(agent, api_kwargs: dict):
provider fallback.
"""
result = {"response": None, "error": None}
# Cross-turn stale-call circuit breaker (#58962) — non-streaming sibling
# of the guard in interruptible_streaming_api_call. Quiet-mode /
# subagent / no-stream-consumer sessions take THIS path, and a wedged
# unattended session here has the same infinite stale-retry class.
_check_stale_giveup(agent)
request_client_holder = {"client": None, "owner_tid": None}
request_client_lock = threading.Lock()
# Request-local cancellation flag. Distinct from agent._interrupt_requested
@@ -610,9 +557,6 @@ def interruptible_api_call(agent, api_kwargs: dict):
_close_request_client_once("stale_call_kill")
except Exception:
pass
# Circuit breaker (#58962): count the stale kill. See the
# canonical comment block above ``_stale_streak()``.
_bump_stale_streak(agent)
agent._touch_activity(
f"stale non-streaming call killed after {int(_elapsed)}s"
)
@@ -655,10 +599,6 @@ def interruptible_api_call(agent, api_kwargs: dict):
raise InterruptedError("Agent interrupted during API call")
if result["error"] is not None:
raise result["error"]
# Success — clear the circuit breaker (#58962): the provider proved
# responsive. See the canonical comment block above ``_stale_streak()``.
if result["response"] is not None:
_reset_stale_streak(agent)
return result["response"]
@@ -1399,7 +1339,6 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
fb_api_mode = "bedrock_converse"
old_model = agent.model
old_provider = agent.provider
# Clear the per-config context_length override so the fallback
# model's actual context window is resolved instead of inheriting
@@ -1545,25 +1484,10 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
f"🔄 Primary model failed — switching to fallback: "
f"{fb_model} via {fb_provider}"
)
# The buffered line above is dropped on successful recovery, but a
# provider/model switch is a durable state change operators must see
# even when the fallback succeeds. Record a one-shot notice that the
# success path surfaces exactly once via _emit_pending_fallback_notice
# (see run_agent.py); it is discarded on terminal failure since the
# buffered line is flushed instead. See fallback-observability fix.
agent._pending_fallback_notice = (
f"🔄 Switched to fallback model: {old_model} via {old_provider} "
f"{fb_model} via {fb_provider}"
)
logger.info(
"Fallback activated: %s%s (%s)",
old_model, fb_model, fb_provider,
)
# Reset the stale-call circuit breaker (#58962): the streak measured
# the OLD provider's unresponsiveness. Carrying it over would
# short-circuit the freshly activated fallback before it gets a
# single stream attempt.
_reset_stale_streak(agent)
return True
except Exception as e:
if fb_provider == "nous":
@@ -1601,10 +1525,8 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
# hand-builds messages and calls chat.completions.create() directly,
# bypassing the transport — so mirror that sanitization here:
# tool_name (SQLite FTS bookkeeping), the codex_* reasoning carriers,
# timestamp (preserved on gateway user replay entries for the
# stale-confirmation expiry check — #47868 rejection class),
# and every Hermes-internal underscore-prefixed scaffolding key.
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items", "timestamp"):
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items"):
api_msg.pop(schema_foreign, None)
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
api_msg.pop(internal_key, None)
@@ -1957,27 +1879,11 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
t.join(timeout=0.3)
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted during Bedrock API call")
# Worker exited before the poll loop observed the interrupt flag. The
# Bedrock stream callback breaks out and returns a PARTIAL response
# without raising on interrupt (see bedrock_adapter.py
# stream_converse_with_callbacks / on_interrupt_check), so result[
# "response"] is populated with error=None and the in-loop raise above
# never fires. Re-check here so /stop is not silently swallowed on the
# Bedrock path — mirrors the post-worker guard on the main streaming
# loop. (#59999 area)
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted during Bedrock API call (post-worker)")
if result["error"] is not None:
raise result["error"]
return result["response"]
result = {"response": None, "error": None, "partial_tool_names": []}
# Cross-turn stale-stream circuit breaker (#58962) — see the canonical
# comment block above ``_stale_streak()``. Raises past the give-up
# threshold instead of burning another stale-timeout×retries cycle.
_check_stale_giveup(agent)
request_client_holder = {"client": None, "diag": None, "owner_tid": None}
request_client_lock = threading.Lock()
# Request-local cancellation flag — see interruptible_api_call for the full
@@ -2955,9 +2861,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_close_request_client_once("stale_stream_kill")
except Exception:
pass
# Circuit breaker (#58962): count the stale kill. See the
# canonical comment block above ``_stale_streak()``.
_bump_stale_streak(agent)
# Rebuild the primary client too — its connection pool
# may hold dead sockets from the same provider outage.
if agent.api_mode == "anthropic_messages":
@@ -2997,13 +2900,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
except Exception:
pass
raise InterruptedError("Agent interrupted during streaming API call")
# Worker thread exited before the main thread's poll loop could check
# the interrupt flag. If the worker returned early due to an interrupt
# (e.g. _call_anthropic() detected _interrupt_requested and returned
# None), the InterruptedError above was never raised. Re-check the
# flag here so /stop is not silently swallowed. (#59999 area)
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted during streaming API call (post-worker)")
if result["error"] is not None:
if deltas_were_sent["yes"]:
# Streaming failed AFTER some tokens were already delivered to
@@ -3087,16 +2983,8 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
)
if _content_filter_terminated:
_stub._content_filter_terminated = True
# Partial-stream stub: chunks WERE received (deltas fired), so
# the provider is demonstrably responsive — clear the circuit
# breaker (#58962) just like the full-success return below.
_reset_stale_streak(agent)
return _stub
raise result["error"]
# Success — clear the circuit breaker (#58962): the provider proved
# responsive. See the canonical comment block above ``_stale_streak()``.
if result["response"] is not None:
_reset_stale_streak(agent)
return result["response"]
# ── Provider fallback ──────────────────────────────────────────────────

View File

@@ -228,76 +228,6 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
}
def _record_codex_app_server_compaction(
agent,
turn,
*,
approx_tokens: int | None = None,
force: bool = False,
) -> bool:
"""Record a Codex-native context compaction boundary in Hermes state.
The app-server owns the compacted thread context, so Hermes should not
rewrite local transcript rows here; state.db records the boundary via the
session event/usage counters while preserving the visible transcript.
"""
if not force and not getattr(turn, "compacted", False):
return False
thread_id = getattr(turn, "thread_id", None) or ""
turn_id = getattr(turn, "turn_id", None) or ""
logger.info(
"codex app-server compaction observed: session=%s thread=%s turn=%s force=%s",
getattr(agent, "session_id", None) or "none",
thread_id,
turn_id,
force,
)
if not force:
try:
from agent.conversation_compression import COMPACTION_STATUS
agent._emit_status(COMPACTION_STATUS)
except Exception:
pass
compressor = getattr(agent, "context_compressor", None)
if compressor is not None:
compressor.compression_count = getattr(
compressor, "compression_count", 0
) + 1
compressor.last_compression_rough_tokens = approx_tokens or 0
if not getattr(turn, "token_usage_last", None):
compressor.last_prompt_tokens = -1
compressor.last_completion_tokens = 0
compressor.awaiting_real_usage_after_compression = True
agent._last_compaction_in_place = False
try:
if getattr(agent, "event_callback", None):
agent.event_callback(
"session:compress",
{
"platform": getattr(agent, "platform", None) or "",
"session_id": getattr(agent, "session_id", None) or "",
"old_session_id": "",
"in_place": False,
"compression_count": getattr(
compressor, "compression_count", 0
)
if compressor is not None
else 0,
"runtime": "codex_app_server",
"thread_id": thread_id,
"turn_id": turn_id,
},
)
except Exception:
logger.debug("event_callback error on codex session:compress", exc_info=True)
return True
def run_codex_app_server_turn(
agent,
*,
@@ -463,7 +393,6 @@ def run_codex_app_server_turn(
agent._iters_since_skill = (
getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
)
_record_codex_app_server_compaction(agent, turn)
usage_result = _record_codex_app_server_usage(agent, turn)
api_calls = 1

View File

@@ -193,10 +193,8 @@ _HISTORICAL_SUMMARY_PREFIXES = (
_MIN_SUMMARY_TOKENS = 2000
# Proportion of compressed content to allocate for summary
_SUMMARY_RATIO = 0.20
# Absolute ceiling for summary tokens (even on very large context windows).
# Summaries must stay within a 1K-10K token envelope — anything larger is
# itself a context-pressure source and slows every compaction.
_SUMMARY_TOKENS_CEILING = 10_000
# Absolute ceiling for summary tokens (even on very large context windows)
_SUMMARY_TOKENS_CEILING = 12_000
# Placeholder used when pruning old tool results
_PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]"
@@ -229,16 +227,6 @@ _AUTO_FOCUS_MAX_CHARS = 700
# back the old large-tool-output case where nothing can be compacted.
_MAX_TAIL_MESSAGE_FLOOR = 8
# Models with context windows below this get their compression threshold
# floored at ``_SMALL_CTX_THRESHOLD_PERCENT`` (raise-only — an explicitly
# higher user/model threshold always wins). At the default 50% trigger a
# 128K-262K model compacts with only ~64-131K consumed; the incompressible
# floor (system prompt + tool schemas + protected tail + rolling summary)
# eats most of the reclaimed headroom, so compaction re-fires every 1-2
# turns and the session spends most of its wall-clock summarizing.
_SMALL_CTX_WINDOW_LIMIT = 512_000
_SMALL_CTX_THRESHOLD_PERCENT = 0.75
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
@@ -310,32 +298,6 @@ def _content_length_for_budget(raw_content: Any) -> int:
return total
def _serialized_length_for_budget(value: Any) -> int:
"""Return a stable char-length for non-content replay/metadata fields."""
if value is None or value == "":
return 0
if isinstance(value, str):
return len(value)
try:
return len(json.dumps(value, ensure_ascii=False, sort_keys=True, default=str))
except (TypeError, ValueError):
return len(str(value))
# Provider replay/metadata fields that ride the wire on every request but are
# invisible to ``msg["content"]``/``msg["tool_calls"]`` accounting. Codex
# Responses sessions in particular carry ``codex_reasoning_items`` blobs of
# ``encrypted_content`` that can dominate the serialized session (a measured
# 214-turn session held ~115K tokens / 27% of its payload there — #55572).
_REPLAY_BUDGET_KEYS = (
"reasoning",
"reasoning_content",
"reasoning_details",
"codex_reasoning_items",
"codex_message_items",
)
def _estimate_msg_budget_tokens(msg: dict) -> int:
"""Token estimate for one message in the tail-protection budget walks.
@@ -346,23 +308,12 @@ def _estimate_msg_budget_tokens(msg: dict) -> int:
4-tool-call turn measures ~73 vs ~1,090 real tokens), so the protected
tail overshot ``tail_token_budget`` and compression became ineffective.
See issue #28053.
Also counts provider replay fields (``codex_reasoning_items`` etc. —
see ``_REPLAY_BUDGET_KEYS``). The preflight "should I compress?"
estimator sees the full message shape, so the tail walk must use the
same size class; otherwise an assistant message with tiny visible
content but large hidden replay blobs is protected as if it were small,
the post-compression session stays near the context limit, and
compaction re-fires continuously (#55572). Accounting-only: replay
fields are never mutated or pruned here.
"""
content_len = _content_length_for_budget(msg.get("content") or "")
tokens = content_len // _CHARS_PER_TOKEN + 10 # +10 for role/key overhead
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict):
tokens += len(str(tc)) // _CHARS_PER_TOKEN
for key in _REPLAY_BUDGET_KEYS:
tokens += _serialized_length_for_budget(msg.get(key)) // _CHARS_PER_TOKEN
return tokens
@@ -895,18 +846,6 @@ class ContextCompressor(ContextEngine):
self.provider = provider
self.api_mode = api_mode
self.context_length = context_length
# Re-apply the small-context threshold floor for the NEW window,
# starting from the originally-configured percent (not the possibly
# floored live value) so a small -> large switch drops back to the
# configured threshold and a large -> small switch gains the floor.
# Guard with getattr: compressors unpickled/constructed before this
# attribute existed fall back to the live value.
_configured_pct = getattr(
self, "_configured_threshold_percent", self.threshold_percent,
)
self.threshold_percent = self._effective_threshold_percent(
context_length, _configured_pct,
)
# max_tokens=None here means "caller didn't specify" → keep the existing
# output reservation. A switch that genuinely changes the output budget
# passes the new value explicitly. (#43547)
@@ -969,23 +908,6 @@ class ContextCompressor(ContextEngine):
return None
return ivalue if ivalue > 0 else None
@staticmethod
def _effective_threshold_percent(
context_length: int, threshold_percent: float,
) -> float:
"""Apply the small-context threshold floor (raise-only).
Models under ``_SMALL_CTX_WINDOW_LIMIT`` (512K) trigger at no less
than ``_SMALL_CTX_THRESHOLD_PERCENT`` (75%) of the window. An
explicitly higher threshold (user config or per-model autoraise,
e.g. Codex gpt-5.5's 85%) always wins; only lower values are raised.
Large-context models keep the configured value — at 512K+ the default
50% trigger already leaves ample post-compaction headroom.
"""
if context_length and context_length < _SMALL_CTX_WINDOW_LIMIT:
return max(threshold_percent, _SMALL_CTX_THRESHOLD_PERCENT)
return threshold_percent
@staticmethod
def _compute_threshold_tokens(
context_length: int, threshold_percent: float, max_tokens: int | None = None,
@@ -1073,18 +995,6 @@ class ContextCompressor(ContextEngine):
config_context_length=config_context_length,
provider=provider,
)
# Small-context threshold floor: models under 512K trigger at >=75%
# so compaction doesn't fire with half the window still free (the
# incompressible floor makes 50%-triggered compaction thrash on
# 128K-262K models). Raise-only; must run AFTER context_length is
# resolved and BEFORE threshold_tokens is derived. The pre-floor
# value is kept so update_model() can re-derive for a new window
# (switching small -> large must drop back to the configured value).
self._configured_threshold_percent = self.threshold_percent
self.threshold_percent = self._effective_threshold_percent(
self.context_length, self.threshold_percent,
)
threshold_percent = self.threshold_percent
# Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even if
# the percentage would suggest a lower value. This prevents premature
# compression on large-context models at 50% while keeping the % sane
@@ -1463,26 +1373,11 @@ class ContextCompressor(ContextEngine):
(API keys, tokens, passwords) from leaking into the summary that
gets sent to the auxiliary model and persisted across compactions.
"""
# Lazy import (matches title_generator.py) — agent_runtime_helpers
# pulls in heavy transitive imports we don't want at module load.
from agent.agent_runtime_helpers import strip_think_blocks
parts = []
for msg in turns:
role = msg.get("role", "unknown")
content = redact_sensitive_text(msg.get("content") or "")
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
# Strip inline reasoning blocks (<think>, <reasoning>, etc.) from
# assistant content before it reaches the summarizer. Reasoning
# traces are transient scratch work — feeding them to the aux
# model wastes summarizer context and risks scratch-work
# conclusions being preserved as facts in the summary. The native
# ``reasoning`` message field is already excluded (only
# ``content`` is serialized); this closes the inline-tag path
# used when native thinking is disabled or the provider inlines
# traces into content.
if role == "assistant" and content:
content = strip_think_blocks(None, content)
# Tool results: keep enough content for the summarizer
if role == "tool":
@@ -1948,15 +1843,7 @@ This compaction should PRIORITISE preserving all information related to the focu
"api_mode": self.api_mode,
},
"messages": [{"role": "user", "content": prompt}],
# NO max_tokens: the output cap must never truncate a summary.
# ``summary_budget`` is prompt-level guidance only ("Target ~N
# tokens" above). Most OpenAI-compatible wires already omit the
# param (see _build_call_kwargs), but the Anthropic Messages
# wire and NVIDIA NIM forward it — a hard cap there cut
# summaries mid-section (thinking models burn the cap on
# reasoning first), producing truncated/thinking-only
# summaries and compaction loops. Omitting lets the adapter
# fall back to the model's native output ceiling.
"max_tokens": int(summary_budget * 1.3),
# timeout resolved from auxiliary.compression.timeout config by call_llm
}
if self.summary_model:
@@ -1996,16 +1883,6 @@ This compaction should PRIORITISE preserving all information related to the focu
f"(provider={self.provider or 'auto'} "
f"model={self.summary_model or self.model})"
)
# Strip reasoning blocks the summarizer model may have emitted
# (<think>...</think> etc. from thinking models like MiniMax,
# DeepSeek, QwQ). Without this the trace is stored in
# _previous_summary, injected into the conversation, AND fed back
# into every subsequent iterative-update prompt — compounding
# token bloat across compactions. Mirrors title_generator.py.
from agent.agent_runtime_helpers import strip_think_blocks
stripped = strip_think_blocks(None, content).strip()
if stripped:
content = stripped
# Redact the summary output as well — the summarizer LLM may
# ignore prompt instructions and echo back secrets verbatim.
summary = redact_sensitive_text(content.strip())

View File

@@ -465,21 +465,6 @@ def compress_context(
prompt — the session is NOT rotated. Callers should detect the
no-op via ``len(returned) == len(input)`` and stop the retry loop.
"""
# Codex app-server sessions: the codex agent owns the real thread context;
# Hermes' summarizer would only rewrite a local mirror without shrinking
# the actual thread (#36801). Route compaction to the app server's own
# thread/compact mechanism. Behavior is controlled by
# ``compression.codex_app_server_auto`` (native|hermes|off).
if getattr(agent, "api_mode", None) == "codex_app_server":
return _compress_context_via_codex_app_server(
agent,
messages,
system_message,
approx_tokens=approx_tokens,
task_id=task_id,
force=force,
)
# Lazy feasibility check — run the auxiliary-provider probe + context
# length lookup just-in-time on the first compression attempt instead of
# at AIAgent.__init__. Saves ~400ms cold off every short session that
@@ -986,122 +971,6 @@ def compress_context(
_release_lock()
def _compress_context_via_codex_app_server(
agent: Any,
messages: list,
system_message: Optional[str],
*,
approx_tokens: Optional[int] = None,
task_id: str = "default",
force: bool = False,
) -> Tuple[list, str]:
"""Route compaction to Codex app-server for Codex-owned threads.
Hermes' normal compressor rewrites the local OpenAI-style transcript.
That does not shrink the actual Codex app-server thread context. For this
runtime, ask Codex to compact its own thread and keep Hermes' transcript
unchanged.
"""
auto_mode = str(
getattr(agent, "codex_app_server_auto_compaction", "native") or "native"
).lower()
if auto_mode not in {"native", "hermes", "off"}:
auto_mode = "native"
if not force and auto_mode != "hermes":
logger.info(
"codex app-server compaction skipped: mode=%s force=false "
"(session=%s messages=%d tokens=~%s)",
auto_mode,
getattr(agent, "session_id", None) or "none",
len(messages),
f"{approx_tokens:,}" if approx_tokens else "unknown",
)
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
codex_session = getattr(agent, "_codex_session", None)
if codex_session is None:
logger.info(
"codex app-server compaction skipped: no active codex thread "
"(session=%s messages=%d tokens=~%s)",
getattr(agent, "session_id", None) or "none",
len(messages),
f"{approx_tokens:,}" if approx_tokens else "unknown",
)
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
logger.info(
"codex app-server compaction started: session=%s messages=%d tokens=~%s",
getattr(agent, "session_id", None) or "none",
len(messages),
f"{approx_tokens:,}" if approx_tokens else "unknown",
)
try:
agent._emit_status(COMPACTION_STATUS)
except Exception:
pass
result = codex_session.compact_thread()
if getattr(result, "should_retire", False):
try:
codex_session.close()
except Exception:
pass
agent._codex_session = None
if getattr(result, "error", None):
try:
agent._emit_warning(
f"⚠ Codex app-server compaction failed: {result.error}"
)
except Exception:
pass
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
try:
from agent.codex_runtime import (
_record_codex_app_server_compaction,
_record_codex_app_server_usage,
)
_record_codex_app_server_compaction(
agent,
result,
approx_tokens=approx_tokens,
force=True,
)
if getattr(result, "token_usage_last", None):
_record_codex_app_server_usage(agent, result)
except Exception:
logger.debug("codex compaction bookkeeping failed", exc_info=True)
try:
from tools.file_tools import reset_file_dedup
reset_file_dedup(task_id)
except Exception:
pass
logger.info(
"codex app-server compaction done: session=%s thread=%s turn=%s",
getattr(agent, "session_id", None) or "none",
getattr(result, "thread_id", None) or "",
getattr(result, "turn_id", None) or "",
)
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
def try_shrink_image_parts_in_messages(
api_messages: list,
*,

View File

@@ -58,12 +58,7 @@ from agent.model_metadata import (
)
from agent.process_bootstrap import _install_safe_stdio
from agent.prompt_caching import apply_anthropic_cache_control
from agent.retry_utils import (
adaptive_rate_limit_backoff,
is_zai_coding_overload_error,
jittered_backoff,
zai_coding_overload_retry_ceiling,
)
from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff
from agent.trajectory import has_incomplete_scratchpad
from agent.usage_pricing import estimate_usage_cost, normalize_usage
from hermes_constants import PARTIAL_STREAM_STUB_ID
@@ -3147,18 +3142,6 @@ def run_conversation(
FailoverReason.timeout,
FailoverReason.overloaded,
}
# Z.AI Coding Plan GLM-5.2 overload 429s classify as
# `overloaded` (to spare the credential pool), but `overloaded`
# is excluded from `is_rate_limited` — the gate for the adaptive
# Z.AI backoff below. Detect the overload directly so its
# long-backoff schedule runs, and raise the retry ceiling so the
# long tier (30/60/90/120s) is reachable. See
# zai_coding_overload_retry_ceiling() for the ceiling rationale.
_is_zai_coding_overload = is_zai_coding_overload_error(
base_url=str(_base), model=_model, error=api_error
)
if _is_zai_coding_overload:
max_retries = max(max_retries, zai_coding_overload_retry_ceiling())
_should_fallback = (
is_rate_limited
or (_is_transport_failure and retry_count >= 2)
@@ -4109,7 +4092,7 @@ def run_conversation(
pass
wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
_backoff_policy = None
if (is_rate_limited or _is_zai_coding_overload) and not _retry_after:
if is_rate_limited and not _retry_after:
wait_time, _backoff_policy = adaptive_rate_limit_backoff(
retry_count,
base_url=str(_base),
@@ -4117,14 +4100,13 @@ def run_conversation(
error=api_error,
default_wait=wait_time,
)
if is_rate_limited or _is_zai_coding_overload:
if is_rate_limited:
_policy_note = ""
if _backoff_policy == "zai_coding_overload_long":
_policy_note = " (Z.AI Coding overload adaptive long backoff)"
elif _backoff_policy == "zai_coding_overload_short":
_policy_note = " (Z.AI Coding overload short retry)"
_wait_reason = "Provider overloaded" if _is_zai_coding_overload and not is_rate_limited else "Rate limited"
_rate_limit_status = f"⏱️ {_wait_reason}. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries}){_policy_note}..."
_rate_limit_status = f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries}){_policy_note}..."
# Normal retries are buffered to avoid noisy transient chatter. Long
# Z.AI Coding waits are different: they can last minutes, so surface
# progress immediately instead of making the TUI look frozen.
@@ -5062,11 +5044,8 @@ def run_conversation(
# Reset retry counter/signature on successful content
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
# Successful content reached — surface the one-shot fallback
# switch notice (if a fallback activated this turn) before
# dropping the noisy retry buffer, so a provider/model switch
# stays visible even when the fallback succeeds.
agent._emit_pending_fallback_notice()
# Successful content reached — drop any buffered retry
# status from earlier failed attempts in this turn.
agent._clear_status_buffer()
from agent.agent_runtime_helpers import (

View File

@@ -2105,20 +2105,8 @@ 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()
raw = env_file.get(key, "").strip()
env_val = os.environ.get(key, "").strip()
# If .env contains an unresolved op:// reference, prefer the
# already-resolved value from os.environ (set by
# load_hermes_dotenv() -> apply_onepassword_secrets()). The raw
# "op://Vault/Item/field" string would otherwise win and every
# provider auth attempt would receive a URL instead of a key. This
# happens during a partial migration, or when the user wrote op://
# references straight into .env rather than the secrets.onepassword
# config block. For every non-op:// value the original
# .env-takes-precedence behaviour is preserved unchanged.
if raw.startswith("op://") and env_val:
return env_val
return raw or _get_secret(key, "") or env_val
val = env_file.get(key) or _get_secret(key, "") or ""
return val.strip()
# Honour user suppression — `hermes auth remove <provider> <N>` for an
# env-seeded credential marks the env:<VAR> source as suppressed so it

View File

@@ -515,16 +515,6 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
msg = msg[:17] + "..."
return f"to {target}: \"{msg}\""
if tool_name == "skill_view":
name = _oneline(str(args.get("name") or ""))
file_path = args.get("file_path")
if file_path:
file_path = _oneline(str(file_path))
preview = f"{name}{file_path}" if name else file_path
else:
preview = name
return _truncate_preview(preview, max_len) if preview else None
key = primary_args.get(tool_name)
if not key:
for fallback_key in ("query", "text", "command", "path", "name", "prompt", "code", "goal"):
@@ -1394,11 +1384,7 @@ def get_cute_tool_message(
if tool_name == "skills_list":
return _wrap(f"┊ 📚 skills list {args.get('category', 'all')} {dur}")
if tool_name == "skill_view":
label = args.get("name", "")
file_path = args.get("file_path")
if file_path:
label = f"{label}{file_path}" if label else str(file_path)
return _wrap(f"┊ 📚 skill {_trunc(label, 44)} {dur}")
return _wrap(f"┊ 📚 skill {_trunc(args.get('name', ''), 30)} {dur}")
if tool_name == "image_generate":
return _wrap(f"┊ 🎨 create {_trunc(args.get('prompt', ''), 35)} {dur}")
if tool_name == "text_to_speech":

View File

@@ -1043,17 +1043,6 @@ def _classify_by_status(
)
return result_fn(FailoverReason.overloaded, retryable=True)
# 408 Request Timeout — a transient timing failure the server itself flags
# as safe to retry (RFC 9110 §15.5.9), not a malformed request. Commonly
# emitted by reverse proxies sitting in front of self-hosted backends
# (llama.cpp / Ollama / vLLM) when a long generation outruns the proxy's
# request-read window. Route to the dedicated ``timeout`` reason (rebuild
# client + retry) instead of falling through to the generic 4xx bucket
# below, which would abort the turn on a retry-safe error the same way it
# aborts a 400 Bad Request.
if status_code == 408:
return result_fn(FailoverReason.timeout, retryable=True)
# Other 4xx — non-retryable
if 400 <= status_code < 500:
return result_fn(

View File

@@ -348,15 +348,17 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
pip_target.mkdir(parents=True, exist_ok=True)
try:
logger.info("[install] pip install --target %s %s", pip_target, pkg)
from hermes_cli.tools_config import _pip_install
proc = _pip_install(
["--target", str(pip_target), "--quiet", pkg],
proc = subprocess.run(
[sys.executable, "-m", "pip", "install", "--target", str(pip_target), "--quiet", pkg],
check=False,
capture_output=True,
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
"[install] pip install failed for %s: %s", pkg, (proc.stderr or "").strip()[:500]
"[install] pip install failed for %s: %s", pkg, proc.stderr.strip()[:500]
)
return None
except (subprocess.TimeoutExpired, OSError) as e:

View File

@@ -783,55 +783,6 @@ class MemoryManager:
exc_info=True,
)
def commit_session_boundary_async(
self,
messages: List[Dict[str, Any]],
*,
new_session_id: str,
parent_session_id: str = "",
reason: str = "new_session",
) -> None:
"""Queue old-session extraction + provider rebinding as ONE serialized task.
Session rotation (/new) must deliver ``on_session_end`` (end-of-session
extraction — an LLM-bound call that can take seconds) strictly BEFORE
``on_session_switch`` (which rebinds provider-internal ``_session_id`` /
turn buffers to the new session). Running extraction inline blocked the
/new command for the whole LLM round-trip (#16454); running it on an
ad-hoc thread raced the inline switch — providers key off internal
state, so a late ``on_session_end`` ran against post-switch bindings
(transcript misattributed to the new session id, double-ingest of the
old turn buffer, new-session buffers cleared).
Submitting BOTH hooks as one task on the manager's single background
worker gives both properties at a single chokepoint: the caller returns
immediately, and the worker's FIFO order serializes end→switch against
every other provider write (per-turn ``sync_all``, prefetches), which
already share the same worker. If the executor is unavailable,
``_submit_background`` degrades to inline execution — the pre-#16454
synchronous behavior, slow but correct.
"""
if not self._providers:
return
snapshot = list(messages or [])
def _run() -> None:
try:
self.on_session_end(snapshot)
except Exception as e: # pragma: no cover - on_session_end guards per-provider
logger.warning("Session-boundary extraction failed: %s", e)
try:
self.on_session_switch(
new_session_id,
parent_session_id=parent_session_id,
reset=True,
reason=reason,
)
except Exception as e: # pragma: no cover - on_session_switch guards per-provider
logger.warning("Session-boundary switch failed: %s", e)
self._submit_background(_run)
def on_session_switch(
self,
new_session_id: str,

View File

@@ -111,15 +111,6 @@ _MODEL_CACHE_TTL = 3600
_endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
_endpoint_model_metadata_cache_time: Dict[str, float] = {}
_ENDPOINT_MODEL_CACHE_TTL = 300
# Bounded-lifetime cache: after the first successful probe we remember the
# server type so subsequent refreshes skip the full waterfall (no more 404
# spam every 5 minutes on non-matching endpoints like /api/v1/models on vllm).
# Entries expire after _ENDPOINT_PROBE_TTL_SECONDS so a server swap on the
# same port (stop Ollama, start LM Studio) is eventually re-detected instead
# of being pinned to the stale type for the whole process lifetime.
# Values are (server_type, monotonic_timestamp).
_ENDPOINT_PROBE_TTL_SECONDS = 3600.0
_endpoint_probe_path_cache: Dict[str, tuple] = {}
def _get_model_metadata_cache_path() -> Path:
@@ -229,12 +220,6 @@ DEFAULT_CONTEXT_LENGTHS = {
# ChatGPT Codex OAuth caps it at 272K; both paths resolve via their own
# provider-aware branches (_resolve_codex_oauth_context_length + models.dev).
# This hardcoded value is only reached when every probe misses.
# GPT-5.6 series (Sol/Terra/Luna, GA 2026-07-09) — 1.05M on the direct
# OpenAI API (same as gpt-5.5). Codex OAuth caps these at 272K.
# (Lookups length-sort keys at match time, so dict order is cosmetic.)
"gpt-5.6-luna": 1050000,
"gpt-5.6-terra": 1050000,
"gpt-5.6-sol": 1050000,
"gpt-5.5": 1050000,
"gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4)
"gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4)
@@ -304,13 +289,11 @@ DEFAULT_CONTEXT_LENGTHS = {
# 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-latest": 500000, # alias of grok-4.5 (early access)
"grok-build": 256000, # grok-build-0.1
"grok-code-fast": 256000, # grok-code-fast-1
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest
"grok-4-fast": 2000000, # grok-4-fast-(non-)reasoning, also matches -reasoning
"grok-4.20": 2000000, # grok-4.20-0309-(non-)reasoning, -multi-agent-0309
"grok-4.5": 500000, # grok-4.5, grok-4.5-latest — 500K context per docs.x.ai
"grok-4.3": 1000000, # grok-4.3, grok-4.3-latest — 1M context per docs.x.ai
"grok-4": 256000, # grok-4, grok-4-0709
"grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast
@@ -322,8 +305,6 @@ DEFAULT_CONTEXT_LENGTHS = {
# OpenRouter live metadata reports 262144 (256 × 1024); align the
# static fallback so cache and offline both agree (issue #22268).
"hy3-preview": 262144,
# Tencent — Hy3 (GA successor to Hy3 Preview), same 256K window.
"hy3": 262144,
# Nemotron — NVIDIA's open-weights series (128K context across all sizes)
"nemotron": 131072,
# Arcee
@@ -364,11 +345,6 @@ _GROK_EFFORT_CAPABLE_PREFIXES = (
"grok-3-mini",
"grok-4.20-multi-agent",
"grok-4.3",
# grok-4.5: verified live against /v1/responses 2026-07-08 — accepts
# effort low/medium/high (default: high when omitted) but REJECTS
# "none" ("This model does not support `reasoning_effort` value `none`"),
# unlike grok-4.3. models.dev agrees: effort values [low, medium, high].
"grok-4.5",
)
@@ -647,109 +623,66 @@ def is_local_endpoint(base_url: str) -> bool:
return False
def _localhost_to_ipv4(url: str) -> str:
"""Rewrite a ``localhost`` HOST to ``127.0.0.1`` in a probe URL.
On Windows dual-stack machines, httpx resolves ``localhost`` to ``::1``
first and pays a ~2s IPv6 connect timeout before falling back to IPv4
when the local server only listens on IPv4 (LM Studio, Ollama defaults).
Probing the IPv4 loopback directly skips that penalty.
Only the URL's own host component is rewritten (anchored at the scheme),
so a non-localhost URL whose path or query merely embeds the substring
``http://localhost...`` (e.g. ``?upstream=http://localhost:11434``)
passes through untouched.
"""
if not url:
return url
return re.sub(
r"^(https?://)localhost(?=[:/]|$)",
r"\g<1>127.0.0.1",
url,
count=1,
)
def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]:
"""Detect which local server is running at base_url by probing known endpoints.
Returns one of: "ollama", "lm-studio", "vllm", "llamacpp", or None.
The result is cached for the lifetime of the process so that repeated
calls (e.g. every 5-minute metadata refresh) never re-run the waterfall
and never spray 404s at endpoints the server does not expose.
"""
import httpx
normalized = _normalize_base_url(base_url)
# Resolve localhost to IPv4 to avoid 2s IPv6 timeout on Windows dual-stack.
# Applied to ``normalized`` before deriving server/LM Studio URLs AND
# before the cache lookup, so localhost and 127.0.0.1 share a cache entry.
normalized = _localhost_to_ipv4(normalized)
server_url = normalized
if server_url.endswith("/v1"):
server_url = server_url[:-3]
lmstudio_url = _lmstudio_server_root(normalized)
cached = _endpoint_probe_path_cache.get(server_url)
if cached is not None and (time.monotonic() - cached[1]) < _ENDPOINT_PROBE_TTL_SECONDS:
return cached[0]
lmstudio_url = _lmstudio_server_root(base_url)
headers = _auth_headers(api_key)
result: Optional[str] = None
try:
with httpx.Client(timeout=2.0, headers=headers) as client:
# LM Studio exposes /api/v1/models — check first (most specific)
try:
r = client.get(f"{lmstudio_url}/api/v1/models")
if r.status_code == 200:
result = "lm-studio"
return "lm-studio"
except Exception:
pass
if result is None:
# Ollama exposes /api/tags and responds with {"models": [...]}
# LM Studio returns {"error": "Unexpected endpoint"} with status 200
# on this path, so we must verify the response contains "models".
try:
r = client.get(f"{server_url}/api/tags")
if r.status_code == 200:
try:
data = r.json()
if "models" in data:
result = "ollama"
except Exception:
pass
except Exception:
pass
if result is None:
# llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix)
try:
r = client.get(f"{server_url}/v1/props")
if r.status_code != 200:
r = client.get(f"{server_url}/props") # fallback for older builds
if r.status_code == 200 and "default_generation_settings" in r.text:
result = "llamacpp"
except Exception:
pass
if result is None:
# vLLM: /version
try:
r = client.get(f"{server_url}/version")
if r.status_code == 200:
# Ollama exposes /api/tags and responds with {"models": [...]}
# LM Studio returns {"error": "Unexpected endpoint"} with status 200
# on this path, so we must verify the response contains "models".
try:
r = client.get(f"{server_url}/api/tags")
if r.status_code == 200:
try:
data = r.json()
if "version" in data:
result = "vllm"
except Exception:
pass
if "models" in data:
return "ollama"
except Exception:
pass
except Exception:
pass
# llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix)
try:
r = client.get(f"{server_url}/v1/props")
if r.status_code != 200:
r = client.get(f"{server_url}/props") # fallback for older builds
if r.status_code == 200 and "default_generation_settings" in r.text:
return "llamacpp"
except Exception:
pass
# vLLM: /version
try:
r = client.get(f"{server_url}/version")
if r.status_code == 200:
data = r.json()
if "version" in data:
return "vllm"
except Exception:
pass
except Exception:
pass
if result is not None:
_endpoint_probe_path_cache[server_url] = (result, time.monotonic())
return result
return None
def _iter_nested_dicts(value: Any):
@@ -853,10 +786,7 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
return _model_metadata_cache
try:
# Tuple (connect, read) — flat timeout=10 means urllib3 can block 10s per
# retry stage through proxies that 403 CONNECT, ballooning to minutes
# (#46620). 5s connect / 10s read fails fast on unreachable hosts.
response = requests.get(OPENROUTER_MODELS_URL, timeout=(5, 10), verify=_resolve_requests_verify())
response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify())
response.raise_for_status()
data = response.json()
@@ -934,7 +864,7 @@ def fetch_endpoint_model_metadata(
response = requests.get(
server_url.rstrip("/") + "/api/v1/models",
headers=headers,
timeout=(5, 10),
timeout=10,
verify=_resolve_requests_verify(),
)
response.raise_for_status()
@@ -982,7 +912,7 @@ def fetch_endpoint_model_metadata(
for candidate in candidates:
url = candidate.rstrip("/") + "/models"
try:
response = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify())
response = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify())
response.raise_for_status()
payload = response.json()
cache: Dict[str, Dict[str, Any]] = {}
@@ -1083,23 +1013,13 @@ def _load_context_cache() -> Dict[str, int]:
return {}
def _context_cache_key(model: str, base_url: str) -> str:
"""Canonical ``model@base_url`` key for the persistent context cache.
Trailing slashes are stripped so ``http://host/v1`` and
``http://host/v1/`` share one entry instead of creating duplicates
that can go stale independently.
"""
return f"{model}@{(base_url or '').rstrip('/')}"
def save_context_length(model: str, base_url: str, length: int) -> None:
"""Persist a discovered context length for a model+provider combo.
Cache key is ``model@base_url`` so the same model name served from
different providers can have different limits.
"""
key = _context_cache_key(model, base_url)
key = f"{model}@{base_url}"
cache = _load_context_cache()
if cache.get(key) == length:
return # already stored
@@ -1116,43 +1036,18 @@ def save_context_length(model: str, base_url: str, length: int) -> None:
def get_cached_context_length(model: str, base_url: str) -> Optional[int]:
"""Look up a previously discovered context length for model+provider."""
key = _context_cache_key(model, base_url)
key = f"{model}@{base_url}"
cache = _load_context_cache()
hit = cache.get(key)
if hit is not None:
return hit
# Legacy rows written before key normalization may carry a trailing
# slash — honor them rather than re-probing. Checked regardless of the
# caller's slash form: the row's shape and the caller's shape can differ
# in either direction (old slashed row + new normalized config, or the
# reverse), so probe the literal form and the slashed canonical form.
for legacy_key in (f"{model}@{base_url}", f"{key}/"):
if legacy_key != key:
hit = cache.get(legacy_key)
if hit is not None:
return hit
return None
return cache.get(key)
def _invalidate_cached_context_length(model: str, base_url: str) -> None:
"""Drop a stale cache entry so it gets re-resolved on the next lookup."""
key = _context_cache_key(model, base_url)
key = f"{model}@{base_url}"
cache = _load_context_cache()
# Invalidation must also drop the in-memory TTL probe entries for this
# pair — otherwise the next resolution inside the TTL window reuses the
# very value we just declared stale and re-persists it.
bare = _strip_provider_prefix(model)
stripped = (base_url or "").rstrip("/")
_LOCAL_CTX_PROBE_CACHE.pop((bare, stripped), None)
_LOCAL_CTX_PROBE_CACHE.pop(("ollama_show", bare, stripped), None)
# Clear every key shape for this pair: canonical, the caller's literal
# form, and the slashed legacy form — same set get_cached_context_length
# consults, so a lookup can never resurrect a row invalidation missed.
stale_keys = {key, f"{model}@{base_url}", f"{key}/"}
if not any(k in cache for k in stale_keys):
if key not in cache:
return
for k in stale_keys:
cache.pop(k, None)
del cache[key]
path = _get_context_cache_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
@@ -1439,7 +1334,7 @@ def query_ollama_num_ctx(model: str, base_url: str, api_key: str = "") -> Option
import httpx
bare_model = _strip_provider_prefix(model)
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
server_url = base_url.rstrip("/")
if server_url.endswith("/v1"):
server_url = server_url[:-3]
@@ -1500,7 +1395,7 @@ def query_ollama_supports_vision(model: str, base_url: str, api_key: str = "") -
except Exception:
return None
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
server_url = base_url.rstrip("/")
if server_url.endswith("/v1"):
server_url = server_url[:-3]
@@ -1539,12 +1434,6 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti
hosting behind a reverse proxy, etc. For non-Ollama servers the POST
returns 404/405 quickly; the function handles errors gracefully.
Results are cached in ``_LOCAL_CTX_PROBE_CACHE`` (same 30s TTL,
positive-only — see ``_query_local_context_length``) so back-to-back
resolutions during one startup issue a single POST instead of one per
call site. Failures are never memoized: a server that isn't up yet must
be re-probed once it comes up.
For hosted servers the GGUF ``model_info.*.context_length`` is the
authoritative source: the user can't set their own ``num_ctx``, and the
OpenAI-compat ``/v1/models`` endpoint correctly omits ``context_length``
@@ -1556,28 +1445,9 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti
The order is flipped vs ``query_ollama_num_ctx()`` because local users
control ``num_ctx`` themselves; hosted users can't.
"""
import time as _time
# Namespaced cache key: shares the TTL store with
# _query_local_context_length but never collides with its (model, url)
# keys — the two probes can return different values for the same pair.
cache_key = ("ollama_show", _strip_provider_prefix(model), base_url.rstrip("/"))
now = _time.monotonic()
cached = _LOCAL_CTX_PROBE_CACHE.get(cache_key)
if cached is not None and (now - cached[1]) < _LOCAL_CTX_PROBE_TTL_SECONDS:
return cached[0]
result = _query_ollama_api_show_uncached(model, base_url, api_key=api_key)
if result: # positive-only — never memoize a failed probe
_LOCAL_CTX_PROBE_CACHE[cache_key] = (result, now)
return result
def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = "") -> Optional[int]:
"""Uncached body of ``_query_ollama_api_show`` — one POST to ``/api/show``."""
import httpx
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
server_url = base_url.rstrip("/")
if server_url.endswith("/v1"):
server_url = server_url[:-3]
@@ -1696,10 +1566,10 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str
model = _strip_provider_prefix(model)
# Strip /v1 suffix to get the server root
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
server_url = base_url.rstrip("/")
if server_url.endswith("/v1"):
server_url = server_url[:-3]
lmstudio_url = _localhost_to_ipv4(_lmstudio_server_root(base_url))
lmstudio_url = _lmstudio_server_root(base_url)
headers = _auth_headers(api_key)
@@ -1809,7 +1679,7 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) ->
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
resp = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify())
resp = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify())
if resp.status_code != 200:
return None
data = resp.json()
@@ -1843,9 +1713,6 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = {
"gpt-5.3-codex-spark": 128_000,
"gpt-5.2-codex": 272_000,
"gpt-5.4-mini": 272_000,
"gpt-5.6-sol": 272_000,
"gpt-5.6-terra": 272_000,
"gpt-5.6-luna": 272_000,
"gpt-5.5": 272_000,
"gpt-5.4": 272_000,
"gpt-5.2": 272_000,
@@ -1879,7 +1746,7 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
resp = requests.get(
"https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
headers={"Authorization": f"Bearer {access_token}"},
timeout=(5, 10),
timeout=10,
verify=_resolve_requests_verify(),
)
if resp.status_code != 200:
@@ -2321,29 +2188,21 @@ def get_model_context_length(
ctx = _resolve_endpoint_context_length(model, base_url, api_key=api_key)
if ctx is not None:
return ctx
# 5e. Ollama native /api/show probe — runs for providers whose base_url
# is NOT a known non-Ollama provider. Ollama-compatible servers expose
# 5e. Ollama native /api/show probe — runs for ANY provider with a
# base_url, not just ollama-cloud. Ollama-compatible servers expose
# this endpoint regardless of hostname (local Ollama, Ollama Cloud,
# custom Ollama hosting). The OpenAI-compat /v1/models endpoint
# correctly omits context_length per the OpenAI schema, but /api/show
# returns the authoritative GGUF model_info.context_length.
# Known hosted providers (OpenRouter, Anthropic, OpenAI, …) are skipped:
# they are definitively not Ollama, the POST always 404s, and the result
# is never cached for them — so every fresh process used to pay a
# ~300ms blocking HTTP round-trip on the first-turn critical path
# (measured against openrouter.ai; worse on slow DNS).
# For non-Ollama servers (OpenAI, Anthropic, etc.), the POST returns
# 404/405 quickly. Results are cached, so the hit is per-model+URL,
# once per hour.
if base_url:
_inferred_for_probe = _infer_provider_from_url(base_url)
_skip_ollama_probe = (
_inferred_for_probe is not None
and "ollama" not in _inferred_for_probe
)
if not _skip_ollama_probe:
ctx = _query_ollama_api_show(model, base_url, api_key=api_key)
if ctx is not None:
if not _skip_persistent_context_cache(base_url, provider):
save_context_length(model, base_url, ctx)
return ctx
ctx = _query_ollama_api_show(model, base_url, api_key=api_key)
if ctx is not None:
if not _skip_persistent_context_cache(base_url, provider):
save_context_length(model, base_url, ctx)
return ctx
# 5f. OpenRouter live /models metadata — authoritative for OpenRouter-routed
# models. OpenRouter's catalog carries per-model context_length (e.g.
# anthropic/claude-fable-5 -> 1M) and refreshes as new slugs ship, so it
@@ -2563,82 +2422,5 @@ def estimate_request_tokens_rough(
if messages:
total += estimate_messages_tokens_rough(messages)
if tools:
total += _estimate_tools_tokens_rough(tools)
total += (len(str(tools)) + 3) // 4
return total
# NOTE: tool schemas can be large. Avoid repeated `str(tools)` conversions,
# which are CPU-heavy and can stall GUI event loops under GIL pressure.
#
# Keyed by ``id(tools)``. A long-lived gateway/desktop backend builds many
# transient tool lists over its lifetime, so the cache is bounded and evicts
# oldest-first (insertion-ordered dict) once it exceeds the cap. The cap is
# generous relative to how rarely toolsets are rebuilt within a process.
_TOOLS_TOKENS_CACHE: dict[int, Tuple[int, str, str, int]] = {}
_TOOLS_TOKENS_CACHE_MAX = 256
def _tool_name_for_cache(tool: Any) -> str:
if not isinstance(tool, dict):
return ""
fn = tool.get("function")
if isinstance(fn, dict):
name = fn.get("name")
if isinstance(name, str):
return name
name = tool.get("name")
return name if isinstance(name, str) else ""
def _estimate_tools_tokens_rough(tools: List[Dict[str, Any]]) -> int:
if not tools:
return 0
# Cache by list identity. Tools are rebuilt rarely (toolset changes),
# but token estimates are requested frequently (preflight, compaction).
key = id(tools)
n = len(tools)
first = _tool_name_for_cache(tools[0]) if n else ""
last = _tool_name_for_cache(tools[-1]) if n else ""
cached = _TOOLS_TOKENS_CACHE.get(key)
if cached is not None:
cached_n, cached_first, cached_last, cached_tokens = cached
if cached_n == n and cached_first == first and cached_last == last:
return cached_tokens
# Fast, stable rough estimate: sum lengths of the major schema fields.
# This avoids the pathological `str(tools)` path while still scaling with
# schema size (descriptions + parameters dominate).
total_chars = 0
for tool in tools:
if not isinstance(tool, dict):
continue
fn = tool.get("function")
if isinstance(fn, dict):
name = fn.get("name") or ""
desc = fn.get("description") or ""
params = fn.get("parameters") or {}
else:
name = tool.get("name") or ""
desc = tool.get("description") or ""
params = tool.get("parameters") or {}
if isinstance(name, str):
total_chars += len(name)
if isinstance(desc, str):
total_chars += len(desc)
# Parameters can be nested; JSON is closer to over-the-wire size than repr().
try:
total_chars += len(json.dumps(params, ensure_ascii=False, separators=(",", ":")))
except Exception:
total_chars += len(str(params))
tokens = (total_chars + 3) // 4
# Bound the cache: drop the oldest entry when the cap is exceeded so a
# long-running process can't accumulate an unbounded number of stale
# ``id(tools)`` entries (id values are recycled after GC anyway).
if len(_TOOLS_TOKENS_CACHE) >= _TOOLS_TOKENS_CACHE_MAX:
_TOOLS_TOKENS_CACHE.pop(next(iter(_TOOLS_TOKENS_CACHE)), None)
_TOOLS_TOKENS_CACHE[key] = (n, first, last, tokens)
return tokens

View File

@@ -7,7 +7,6 @@ assemble pieces, then combines them with memory and ephemeral prompts.
import json
import logging
import os
import sys
import threading
import contextvars
from collections import OrderedDict
@@ -18,8 +17,6 @@ from typing import Optional
from agent.runtime_cwd import resolve_agent_cwd
from agent.skill_utils import (
EXCLUDED_SKILL_DIRS,
SKILL_SUPPORT_DIRS,
extract_skill_conditions,
extract_skill_description,
get_all_skills_dirs,
@@ -28,7 +25,6 @@ from agent.skill_utils import (
parse_frontmatter,
skill_matches_environment,
skill_matches_platform,
skill_matches_platform_list,
)
from utils import atomic_json_write
@@ -747,17 +743,6 @@ PLATFORM_HINTS = {
"or 'all'). Do not promise the user that a deliver='origin' or "
"default-deliver cron job will message them in this session."
),
"desktop": (
"You are chatting inside the Hermes desktop app — a graphical chat "
"surface, not a terminal. Use markdown freely: it renders with full "
"GitHub flavor (tables, code blocks with syntax highlighting, math "
"via $...$, task lists, blockquote callouts). "
"You can deliver files natively — include MEDIA:/absolute/path/to/file "
"in your response. Images (.png, .jpg, .webp) appear inline, audio and "
"video play inline, and other files arrive as download links. You can "
"also include image URLs in markdown format ![alt](url) and they "
"render inline as photos."
),
"sms": (
"You are communicating via SMS. Keep responses concise and use plain text "
"only — no markdown, no formatting. SMS messages are limited to ~1600 "
@@ -1142,6 +1127,22 @@ def build_environment_hints() -> str:
f"`uname -a && whoami && pwd`."
)
# Hermes desktop GUI — any agent running under the desktop app should know
# it. HERMES_DESKTOP marks the backend powering the chat; HERMES_DESKTOP_TERMINAL
# marks a hermes launched in the embedded terminal pane. Both set by main.cjs.
_truthy = ("1", "true", "yes")
_in_desktop = (os.getenv("HERMES_DESKTOP") or "").strip().lower() in _truthy
_in_desktop_term = (os.getenv("HERMES_DESKTOP_TERMINAL") or "").strip().lower() in _truthy
if _in_desktop or _in_desktop_term:
_desktop_hint = "Runtime surface: you're running inside the Hermes desktop GUI app."
if _in_desktop_term:
_desktop_hint += (
" You're in its embedded terminal pane, beside the GUI chat — the user can "
"select your output (⌥-drag on macOS, Shift-drag elsewhere) and press "
"⌘/Ctrl+L to send it to the chat composer."
)
hints.append(_desktop_hint)
if is_wsl():
hints.append(WSL_ENVIRONMENT_HINT)
@@ -1275,26 +1276,13 @@ def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None:
def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]:
"""Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files."""
manifest: dict[str, list[int]] = {}
skills_dir_str = str(skills_dir)
base = os.path.join(skills_dir_str, "")
prefix_len = len(base)
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
has_skill_md = "SKILL.md" in files
dirs[:] = [
d
for d in dirs
if d not in EXCLUDED_SKILL_DIRS
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
]
for filename in ("SKILL.md", "DESCRIPTION.md"):
if filename not in files:
continue
path = os.path.join(root, filename)
for filename in ("SKILL.md", "DESCRIPTION.md"):
for path in iter_skill_index_files(skills_dir, filename):
try:
st = os.stat(path)
st = path.stat()
except OSError:
continue
manifest[path[prefix_len:]] = [st.st_mtime_ns, st.st_size]
manifest[str(path.relative_to(skills_dir))] = [st.st_mtime_ns, st.st_size]
return manifest
@@ -1426,22 +1414,6 @@ def _skill_should_show(
return True
def _current_session_platform_hint() -> str:
"""Return the active platform without importing the gateway package on CLI startup."""
platform = os.environ.get("HERMES_PLATFORM") or os.environ.get("HERMES_SESSION_PLATFORM")
if platform:
return platform
session_context = sys.modules.get("gateway.session_context")
get_session_env = getattr(session_context, "get_session_env", None) if session_context else None
if get_session_env is None:
return ""
try:
return get_session_env("HERMES_SESSION_PLATFORM") or ""
except Exception:
return ""
def build_skills_system_prompt(
available_tools: "set[str] | None" = None,
available_toolsets: "set[str] | None" = None,
@@ -1476,10 +1448,15 @@ def build_skills_system_prompt(
# ── Layer 1: in-process LRU cache ─────────────────────────────────
# Include the resolved platform so per-platform disabled-skill lists
# produce distinct cache entries (gateway serves multiple platforms).
_platform_hint = _current_session_platform_hint()
from gateway.session_context import get_session_env
_platform_hint = (
os.environ.get("HERMES_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM")
or ""
)
disabled = get_disabled_skill_names(_platform_hint or None)
cache_key = (
str(skills_dir),
str(skills_dir.resolve()),
tuple(str(d) for d in external_dirs),
tuple(sorted(str(t) for t in (available_tools or set()))),
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
@@ -1508,7 +1485,7 @@ def build_skills_system_prompt(
category = entry.get("category") or "general"
frontmatter_name = entry.get("frontmatter_name") or skill_name
platforms = entry.get("platforms") or []
if not skill_matches_platform_list(platforms):
if not skill_matches_platform({"platforms": platforms}):
continue
if frontmatter_name in disabled or skill_name in disabled:
continue

View File

@@ -66,13 +66,9 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
("nemotron-3-ultra", 600),
("nemotron-3-super", 600),
("nemotron-3-nano", 300),
# DeepSeek — R1 and V4 reasoning models on hosted NIM / DeepSeek direct.
# V4 series emits reasoning_content in a separate delta field before
# final content, requiring the same extended stale timeout floor.
# DeepSeek — R1 reasoning model on hosted NIM / DeepSeek direct.
("deepseek-r1", 600),
("deepseek-reasoner", 600),
("deepseek-v4-flash", 600),
("deepseek-v4-pro", 600),
# Qwen — QwQ reasoning + Qwen3 thinking variants. QwQ-32B
# preview is the stable slug; ``qwen3`` covers the family of
# thinking-mode Qwen3 models (qwen3-235b-a22b, qwen3-32b, etc.)
@@ -194,10 +190,6 @@ def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]:
300.0
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-r1")
600.0
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-flash")
600.0
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-pro")
600.0
>>> get_reasoning_stale_timeout_floor("qwen/qwen3-235b-a22b-thinking")
180.0
>>> get_reasoning_stale_timeout_floor("x-ai/grok-4-fast-reasoning")

View File

@@ -138,120 +138,3 @@ def sanitize_replay_history(
if not agent_history:
return agent_history
return strip_dangling_tool_call_tail(strip_interrupted_tool_tails(agent_history))
# ──────────────────────────────────────────────────────────────────────
# Stale dangerous-confirmation text expiry (#59607)
# ──────────────────────────────────────────────────────────────────────
# How long a high-risk confirmation phrase remains valid.
# Short on purpose: dangerous side effects should not survive any restart
# or session resumption gap. The user can always re-confirm if needed.
_DANGEROUS_CONFIRMATION_EXPIRY_SECONDS = 60.0
# Confirmation phrases that unlock destructive host actions.
# Substring match (case-insensitive) so that user variants (e.g. trailing
# punctuation, additional context) still match. Add new patterns here when
# new high-risk actions are introduced.
_DANGEROUS_CONFIRMATION_PATTERNS: tuple = (
"confirm forced restart",
"confirm forced reboot",
"confirm shutdown",
"confirm reboot",
"confirm power off",
"yes, delete everything",
"confirm wipe",
"confirm factory reset",
# i18n variants observed in the original incident
"確認強制重開機",
"確認強制重開",
"確認重啟",
)
# Replacement text for an expired confirmation. Redacting in place (rather
# than deleting the message) preserves strict user/assistant role
# alternation in the replayed history.
_EXPIRED_CONFIRMATION_SENTINEL = (
"[A high-risk confirmation previously given here has EXPIRED and must "
"not be acted on. Ask the user to re-confirm explicitly before "
"performing any destructive action.]"
)
def is_dangerous_confirmation(content: Any) -> bool:
"""Return True if a user-message text matches a known dangerous confirmation.
Used by ``strip_stale_dangerous_confirmations`` to decide which
transcript rows to expire. Substring + case-insensitive so that
``"Please confirm forced restart, the host is critical"`` still matches.
"""
if not isinstance(content, str):
return False
text = content.strip().lower()
return any(pattern in text for pattern in _DANGEROUS_CONFIRMATION_PATTERNS)
def strip_stale_dangerous_confirmations(
agent_history: List[Dict[str, Any]],
*,
now: float,
expiry_seconds: float = _DANGEROUS_CONFIRMATION_EXPIRY_SECONDS,
) -> List[Dict[str, Any]]:
"""Expire stale dangerous-confirmation text in user messages (#59607).
When a high-risk side effect (e.g. host restart via ``shutdown.exe``)
runs, the user's plain-text confirmation phrase is persisted in the
conversation transcript. If the host restart killed the gateway
process before the assistant's tool result was written, the
transcript tail ends on the assistant's text response — and the
dangerous confirmation text remains in the user role.
On the next inbound message — possibly a casual "are you there?" from
the user minutes later — the LLM sees the stale confirmation and may
interpret the new turn as a fresh re-confirmation, re-executing the
destructive action. This is the failure mode reported in #59607.
Expired confirmations are REDACTED IN PLACE, not removed: deleting a
user message from the incident tail (``user(confirm) →
assistant("OK, restarting")``) would leave two consecutive assistant
messages, violating the strict role-alternation invariant providers
enforce. The message survives with its role intact; only the trigger
text is replaced by a sentinel that tells the model the confirmation
has expired.
Messages without a timestamp are left untouched (backward
compatibility: legacy transcripts and in-memory test scaffolding have
no timestamps). User messages that contain dangerous confirmation
text but are within the expiry window are also left untouched — they
represent a fresh confirmation that has not yet been acted on.
Complements 75ed07ace (which strips the *assistant* side of the
broken tail) by handling the *user* side: a stale plain-text
confirmation that the assistant has not yet responded to in a way
the resume logic recognises.
"""
if not agent_history:
return agent_history
cleaned: List[Dict[str, Any]] = []
for msg in agent_history:
if (
isinstance(msg, dict)
and msg.get("role") == "user"
and is_dangerous_confirmation(msg.get("content", ""))
):
ts = msg.get("timestamp")
if ts is not None and (now - float(ts)) > expiry_seconds:
logger.debug(
"Redacting stale dangerous-confirmation text in user "
"message (age=%.1fs, expiry=%.1fs): %r",
now - float(ts),
expiry_seconds,
(msg.get("content") or "")[:80],
)
redacted = dict(msg)
redacted["content"] = _EXPIRED_CONFIRMATION_SENTINEL
cleaned.append(redacted)
continue
cleaned.append(msg)
return cleaned

View File

@@ -24,14 +24,6 @@ _jitter_lock = threading.Lock()
# not sit silent for 20+ minutes.
_ZAI_CODING_OVERLOAD_LONG_BACKOFF = (30.0, 60.0, 90.0, 120.0)
# Number of initial short retries before the adaptive long-backoff tier kicks
# in. Shared by ``adaptive_rate_limit_backoff`` (which walks the long table
# starting at attempt ``short_attempts + 1``) and
# ``zai_coding_overload_retry_ceiling`` (which sizes the retry loop so every
# long-tier entry is reachable). Keeping it a single module constant prevents
# the two from silently desyncing if the short-retry count is ever tuned.
_ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS = 3
def jittered_backoff(
attempt: int,
@@ -112,7 +104,7 @@ def adaptive_rate_limit_backoff(
model: str | None,
error: Any,
default_wait: float,
short_attempts: int = _ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS,
short_attempts: int = 3,
) -> tuple[float, str | None]:
"""Provider-aware rate-limit backoff.
@@ -135,20 +127,3 @@ def adaptive_rate_limit_backoff(
# A smaller jitter ratio keeps long waits readable while still avoiding
# synchronized retry storms across concurrent Hermes sessions.
return jittered_backoff(1, base_delay=base_delay, max_delay=base_delay, jitter_ratio=0.2), "zai_coding_overload_long"
def zai_coding_overload_retry_ceiling(short_attempts: int = _ZAI_CODING_OVERLOAD_SHORT_ATTEMPTS) -> int:
"""Retry-loop ceiling needed for the full Z.AI overload backoff schedule.
The adaptive policy runs ``short_attempts`` short retries, then walks the
long-backoff table one entry per subsequent attempt. The retry loop gives
up as soon as ``retry_count >= ceiling`` — and that check runs *before* the
attempt's backoff is computed — so the ceiling must sit one past the final
long-backoff entry for every long tier to actually execute.
With the default ``api_max_retries`` (3) equal to ``short_attempts`` (3),
the loop always gave up before reaching the long tier, leaving the whole
long-backoff schedule as dead code. Callers extend the ceiling to this
value for Z.AI Coding overload 429s so the 30/60/90/120s waits run.
"""
return short_attempts + len(_ZAI_CODING_OVERLOAD_LONG_BACKOFF) + 1

View File

@@ -1,41 +1,13 @@
"""External secret source integrations.
A secret source is anything that can supply environment-variable-shaped
credentials at process startup, _after_ ~/.hermes/.env has loaded.
credentials at process startup, _after_ ~/.hermes/.env has loaded. By
default sources are non-destructive: they only set values for env vars
that aren't already present, so .env and shell exports continue to win.
The contract every source implements is
:class:`agent.secret_sources.base.SecretSource`; the orchestrator that
runs the enabled sources (ordering, mapped-beats-bulk precedence,
first-claim-wins conflicts, ``override_existing`` semantics, provenance)
is :func:`agent.secret_sources.registry.apply_all`. Multiple sources
can be enabled at once — see the registry module docstring for the
precedence ladder. The atomic-write / 0600 / TTL disk-cache substrate
is shared across backends in ``agent.secret_sources._cache`` so the
security-sensitive bits live in exactly one place.
Currently bundled:
Currently shipped:
- ``bitwarden`` — Bitwarden Secrets Manager (`bws` CLI). See
``agent.secret_sources.bitwarden`` for the integration and
``hermes_cli.secrets_cli`` for the user-facing setup wizard.
- ``onepassword`` — 1Password ``op://`` secret references (`op` CLI).
See ``agent.secret_sources.onepassword`` for the integration and
``hermes_cli.onepassword_secrets_cli`` for the user-facing commands.
The bundled set is deliberately closed (policy mirrors memory
providers): new third-party secret managers ship as standalone plugin
repos that subclass ``SecretSource`` and register through
``PluginContext.register_secret_source()`` — they are NOT added to this
package. A generic ``command`` source is a possible future exception;
OS keystores (Keychain/DPAPI/libsecret) are under discussion.
"""
from agent.secret_sources.base import ( # noqa: F401
SECRET_SOURCE_API_VERSION,
ErrorKind,
FetchResult,
SecretSource,
is_valid_env_name,
run_secret_cli,
scrub_ansi,
)

View File

@@ -1,213 +0,0 @@
"""Shared substrate for external secret-source backends.
Every backend (Bitwarden, 1Password, …) needs the same handful of
security-sensitive primitives:
* a uniform result object (:class:`FetchResult`),
* environment-variable name validation (:func:`is_valid_env_name`),
* a two-layer fetch cache whose disk half writes atomically with ``0600``
permissions and honours a TTL (:class:`DiskCache`, :class:`CachedFetch`).
These used to live inline inside ``bitwarden.py``. Pulling them here means
the atomic-write / ``0600`` / TTL logic is audited and fixed in exactly one
place instead of drifting across copy-pasted per-backend modules — each
backend supplies only its own cache-key shape and a serializer for it.
Nothing in this module ever raises out to the caller's hot path: the disk
layer is strictly best-effort (a miss just triggers a refetch), because a
cache problem must never block Hermes startup.
"""
from __future__ import annotations
import json
import os
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, Generic, Optional, TypeVar
__all__ = [
"FetchResult",
"CachedFetch",
"DiskCache",
"is_valid_env_name",
"resolve_cache_home",
]
# ---------------------------------------------------------------------------
# Result object + env-name validation — canonical definitions live in
# ``agent.secret_sources.base`` (the SecretSource contract module); re-exported
# here so backends that import from ``_cache`` keep working.
# ---------------------------------------------------------------------------
from agent.secret_sources.base import ( # noqa: E402
FetchResult,
is_valid_env_name,
)
# ---------------------------------------------------------------------------
# Cache entry
# ---------------------------------------------------------------------------
@dataclass
class CachedFetch:
"""A set of fetched secret values plus when they were fetched."""
secrets: Dict[str, str]
fetched_at: float
def is_fresh(self, ttl_seconds: float) -> bool:
if ttl_seconds <= 0:
return False
return (time.time() - self.fetched_at) < ttl_seconds
# ---------------------------------------------------------------------------
# Disk cache
# ---------------------------------------------------------------------------
def resolve_cache_home(home_path: Optional[Path] = None) -> Path:
"""Resolve the Hermes home used for cache paths.
``home_path`` is whatever ``load_hermes_dotenv()`` already resolved;
falling back to ``$HERMES_HOME`` / ``~/.hermes`` keeps direct callers
(and tests that don't thread a home through) working.
"""
if home_path is None:
home_path = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
return home_path
K = TypeVar("K")
class DiskCache(Generic[K]):
"""Best-effort, profile-aware on-disk cache for fetched secret values.
One JSON object per backend lives at ``<hermes_home>/cache/<basename>``::
{"key": "<serialized cache key>", "secrets": {...}, "fetched_at": 1.0}
The file holds only secret *values* keyed by the serialized cache key —
never raw auth material. Backends are responsible for fingerprinting
tokens/sessions *before* they reach ``key_serializer`` so the token can't
land in the key.
Writes are atomic (``mkstemp`` → ``chmod 0600`` → ``os.replace``) and the
containing ``cache/`` directory is forced to ``0700`` — ``mkdir``'s mode is
umask-subject, so the chmod is the reliable form. Both ``read`` and
``write`` short-circuit when ``ttl_seconds <= 0``, so setting the TTL to
zero disables *both* cache layers symmetrically: a user opting out never
gets secret values written to disk at all.
"""
def __init__(self, basename: str, *, key_serializer: Callable[[K], str]) -> None:
self._basename = basename
self._key_serializer = key_serializer
# Temp-file prefix derived from the basename so concurrent writers for
# different backends in the same dir don't collide on the staging name.
stem = basename.split(".", 1)[0]
self._tmp_prefix = f".{stem}_"
def path(self, home_path: Optional[Path] = None) -> Path:
return resolve_cache_home(home_path) / "cache" / self._basename
def read(
self,
key: K,
ttl_seconds: float,
home_path: Optional[Path] = None,
) -> Optional[CachedFetch]:
"""Return a fresh cached entry for ``key``, or None.
Best-effort: any I/O or parse error, a key mismatch, or a stale entry
all return None so the caller re-fetches.
"""
if ttl_seconds <= 0:
return None
path = self.path(home_path)
try:
with open(path, "r", encoding="utf-8") as f:
payload = json.load(f)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
if payload.get("key") != self._key_serializer(key):
return None
secrets = payload.get("secrets")
fetched_at = payload.get("fetched_at")
if not isinstance(secrets, dict) or not isinstance(fetched_at, (int, float)):
return None
# JSON permits non-string values; env vars need strings, so coerce by
# dropping anything that isn't a str→str pair.
typed: Dict[str, str] = {
k: v for k, v in secrets.items() if isinstance(k, str) and isinstance(v, str)
}
entry = CachedFetch(secrets=typed, fetched_at=float(fetched_at))
if not entry.is_fresh(ttl_seconds):
return None
return entry
def write(
self,
key: K,
entry: CachedFetch,
ttl_seconds: float,
home_path: Optional[Path] = None,
) -> None:
"""Persist ``entry`` for ``key`` atomically at mode ``0600``.
No-op when ``ttl_seconds <= 0`` (so caching is genuinely off) or on any
I/O error — the next invocation just re-fetches.
"""
if ttl_seconds <= 0:
return
path = self.path(home_path)
try:
cache_dir = path.parent
cache_dir.mkdir(parents=True, exist_ok=True)
# mkdir's mode is umask-subject; chmod the dir to 0700 so cache
# metadata isn't exposed if HERMES_HOME is ever made traversable.
try:
os.chmod(cache_dir, 0o700)
except OSError:
pass
payload = {
"key": self._key_serializer(key),
"secrets": entry.secrets,
"fetched_at": entry.fetched_at,
}
# Write to a sibling temp file and atomic-rename. tempfile honours
# os.umask, so we explicitly chmod 0600 before the rename.
fd, tmp = tempfile.mkstemp(
prefix=self._tmp_prefix, suffix=".tmp", dir=str(cache_dir)
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except OSError:
pass # best-effort — a disk-cache miss next invocation is fine
def clear(self, home_path: Optional[Path] = None) -> None:
"""Delete the on-disk cache file if present (idempotent)."""
try:
self.path(home_path).unlink()
except (FileNotFoundError, OSError):
pass

View File

@@ -1,274 +0,0 @@
"""Secret-source contract: the ABC every secret backend implements.
A *secret source* resolves credentials from an external secret manager
(Bitwarden Secrets Manager, 1Password, an OS keystore, a user script, ...)
into environment-variable-shaped values at process startup, AFTER
``~/.hermes/.env`` has loaded and BEFORE the rest of Hermes reads
``os.environ``.
Scope of the contract (deliberate, please do not widen):
* **Read-only.** Sources resolve refs → values. There is no write-back
("save this key to your vault"), no arbitrary secret objects, and no
mid-session secret API. If a future need for rotation/refresh appears
it will arrive as a versioned optional hook — do not bolt it on.
* **Startup-time, synchronous.** ``fetch()`` is called once per process
(per HERMES_HOME) by the orchestrator in
:mod:`agent.secret_sources.registry`, which enforces a wall-clock
timeout around it. Sources must not spawn background refreshers.
* **Never raises, never prompts.** ``fetch()`` returns a
:class:`FetchResult` — errors go in ``result.error`` with a
machine-readable :class:`ErrorKind`. Interactive auth belongs in the
source's CLI ``setup`` flow, never on the startup path (non-TTY
gateway/cron startup must never block on stdin).
* **Sources fetch; the orchestrator applies.** A source returns the
name→value mapping it *would* contribute. Precedence (mapped-beats-bulk,
first-wins, ``override_existing``, protected vars), conflict warnings,
provenance tracking, and the actual ``os.environ`` writes are owned by
the orchestrator so no backend can get them wrong.
Versioning: ``SECRET_SOURCE_API_VERSION`` gates plugin compatibility.
New *optional* hooks with default implementations do not bump it;
required-signature changes do, and the registry skips (with a warning)
sources built against a different major version instead of crashing
startup.
"""
from __future__ import annotations
import os
import re
import subprocess
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Dict, FrozenSet, List, Optional, Sequence
# Bump ONLY for breaking changes to the required contract surface
# (abstract-method signatures, FetchResult required fields). Additive
# optional hooks must ship with defaults and must NOT bump this.
SECRET_SOURCE_API_VERSION = 1
# Timeout the orchestrator enforces around fetch() when the source's
# config section doesn't override it. Generous because a first run may
# include a one-time CLI binary auto-install (e.g. bws download+verify).
DEFAULT_FETCH_TIMEOUT_SECONDS = 120.0
# Default timeout for run_secret_cli() subprocess invocations.
DEFAULT_CLI_TIMEOUT_SECONDS = 30.0
class ErrorKind(str, Enum):
"""Machine-readable failure taxonomy for :class:`FetchResult.error`.
A fixed vocabulary keeps startup warnings and ``hermes secrets status``
uniform across backends, and lets the orchestrator implement
kind-dependent policy (e.g. a future stale-cache fallback on
``NETWORK``/``TIMEOUT`` but not on ``AUTH_FAILED``) exactly once.
"""
NOT_CONFIGURED = "not_configured" # enabled but missing token/project/map
BINARY_MISSING = "binary_missing" # helper CLI not found / not installed
AUTH_FAILED = "auth_failed" # bad credentials
AUTH_EXPIRED = "auth_expired" # credentials were valid, aren't now
REF_INVALID = "ref_invalid" # a secret reference failed validation
NETWORK = "network" # transport-level failure
EMPTY_VALUE = "empty_value" # backend returned nothing for a ref
TIMEOUT = "timeout" # fetch exceeded its wall-clock budget
INTERNAL = "internal" # anything else (bug, unexpected shape)
@dataclass
class FetchResult:
"""Outcome of one source's fetch.
``secrets`` holds what the source *would* contribute; whether each
var is actually applied is the orchestrator's decision. ``applied``
and ``skipped`` exist for backward compatibility with the original
Bitwarden fetch-and-apply entry point and are left empty by
conforming ``fetch()`` implementations.
"""
secrets: Dict[str, str] = field(default_factory=dict)
applied: List[str] = field(default_factory=list)
skipped: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
error: Optional[str] = None
error_kind: Optional[ErrorKind] = None
# Path of the helper binary used, when the source is CLI-driven.
# Surfaced by status commands; None for SDK/API-driven sources.
binary_path: Optional[Path] = None
@property
def ok(self) -> bool:
return self.error is None
class SecretSource(ABC):
"""One external secret backend.
Subclasses set the class attributes and implement :meth:`fetch`.
Everything else has a sensible default.
Attributes:
name: Config-section key under ``secrets:`` in config.yaml.
Lowercase ``[a-z0-9_]+``. Also the provenance label stored
for every var this source supplies.
label: Human-readable name used in startup messages and
``hermes secrets status`` (e.g. ``"Bitwarden Secrets Manager"``).
shape: ``"mapped"`` when the user explicitly binds env-var names
to refs (1Password ``env:`` map, command source) or
``"bulk"`` when the backend injects whole projects/folders
of secrets implicitly (Bitwarden BSM). The orchestrator
gives mapped sources precedence over bulk sources: an
explicit binding is stronger intent than a project dump.
scheme: Optional URI scheme this source owns for secret
references (``"op"`` for ``op://...``). Must be unique
across registered sources — refs may eventually appear
outside the ``secrets:`` block (e.g. credential-pool
``api_key`` fields), so scheme collisions are rejected at
registration time to keep that future possible.
api_version: Contract version this source was built against.
"""
api_version: int = SECRET_SOURCE_API_VERSION
name: str = ""
label: str = ""
shape: str = "mapped" # "mapped" | "bulk"
scheme: Optional[str] = None
# -- required ----------------------------------------------------------
@abstractmethod
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
"""Resolve this source's secrets. MUST NOT raise or prompt.
``cfg`` is the source's raw config section (``secrets.<name>``)
from config.yaml — treat every field defensively, the section
may be malformed. ``home_path`` is the resolved HERMES_HOME.
"""
# -- optional hooks (defaults are correct for most sources) ------------
def is_enabled(self, cfg: dict) -> bool:
"""Whether the user turned this source on."""
return bool(isinstance(cfg, dict) and cfg.get("enabled"))
def override_existing(self, cfg: dict) -> bool:
"""May this source overwrite vars that .env / the shell already set?
This NEVER extends to vars claimed by another secret source in the
same startup pass — cross-source overrides are a config error the
orchestrator warns about, not a knob.
"""
return bool(isinstance(cfg, dict) and cfg.get("override_existing", False))
def protected_env_vars(self, cfg: dict) -> FrozenSet[str]:
"""Env vars the orchestrator must never let ANY source overwrite.
Typically the source's own bootstrap-auth var (e.g.
``BWS_ACCESS_TOKEN``) so a vault that contains its own access
token can't clobber the credential used to reach it.
"""
return frozenset()
def fetch_timeout_seconds(self, cfg: dict) -> float:
"""Wall-clock budget the orchestrator enforces around fetch()."""
try:
val = float((cfg or {}).get("timeout_seconds", DEFAULT_FETCH_TIMEOUT_SECONDS))
except (TypeError, ValueError):
return DEFAULT_FETCH_TIMEOUT_SECONDS
return val if val > 0 else DEFAULT_FETCH_TIMEOUT_SECONDS
def config_schema(self) -> dict:
"""Optional description of this source's config keys.
Shape: ``{key: {"description": str, "default": Any}}``. Used by
setup surfaces to render config without hardcoding per-source
knowledge. Purely informational.
"""
return {}
# ---------------------------------------------------------------------------
# Shared helpers — use these instead of hand-rolling per backend
# ---------------------------------------------------------------------------
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# ANSI CSI/OSC escape sequences — helper-CLI stderr often carries color
# codes that must not reach Hermes' own startup output.
_ANSI_RE = re.compile(r"\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?)")
def is_valid_env_name(name: str) -> bool:
"""True when ``name`` is a legal environment-variable name."""
return bool(name) and bool(_ENV_NAME_RE.match(name))
def scrub_ansi(text: str) -> str:
"""Strip ANSI escape sequences (whole CSI/OSC sequences, not just ESC)."""
return _ANSI_RE.sub("", text or "")
def run_secret_cli(
argv: Sequence[str],
*,
allow_env: Sequence[str] = (),
extra_env: Optional[Dict[str, str]] = None,
timeout: float = DEFAULT_CLI_TIMEOUT_SECONDS,
) -> subprocess.CompletedProcess:
"""Run a secret-manager helper CLI with a minimal, allowlisted env.
Security posture shared by every subprocess-driven backend:
* argv list only — never ``shell=True``. Callers pass user-supplied
reference strings AFTER a ``--`` option terminator in their argv.
* The child gets ``PATH``/``HOME``/locale basics plus only the env
vars named in ``allow_env`` (auth/session vars) and ``extra_env``
— never a copy of the full post-dotenv ``os.environ``, which by
this point holds every credential Hermes knows about.
* ``NO_COLOR=1`` is set and stderr/stdout are ANSI-scrubbed so
helper diagnostics can't smuggle escape sequences into Hermes
output.
* stdin is ``/dev/null`` so a helper that decides to prompt fails
fast instead of hanging startup.
Raises ``RuntimeError`` on spawn failure or timeout (message safe to
surface); returns the completed process otherwise — callers own
returncode interpretation.
"""
base_keep = ("PATH", "HOME", "USERPROFILE", "SYSTEMROOT", "TMPDIR", "TEMP",
"LANG", "LC_ALL", "XDG_CONFIG_HOME", "XDG_DATA_HOME")
env: Dict[str, str] = {}
for key in (*base_keep, *allow_env):
val = os.environ.get(key)
if val is not None:
env[key] = val
if extra_env:
env.update(extra_env)
env.setdefault("NO_COLOR", "1")
try:
proc = subprocess.run( # noqa: S603 — argv list, no shell
list(argv),
env=env,
capture_output=True,
text=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"{Path(str(argv[0])).name} timed out after {timeout:.0f}s"
) from exc
except OSError as exc:
raise RuntimeError(
f"failed to invoke {Path(str(argv[0])).name}: {exc}"
) from exc
proc.stdout = proc.stdout or ""
proc.stderr = scrub_ansi(proc.stderr or "")
return proc

View File

@@ -42,17 +42,10 @@ import time
import urllib.error
import urllib.request
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from agent.secret_sources._cache import (
CachedFetch as _CachedFetch,
DiskCache,
FetchResult,
is_valid_env_name as _is_valid_env_name,
)
from agent.secret_sources.base import ErrorKind, SecretSource
logger = logging.getLogger(__name__)
@@ -77,7 +70,7 @@ _BWS_RUN_TIMEOUT = 30
# In-process cache so repeated load_hermes_dotenv() calls (CLI startup,
# gateway hot-reload, test suites) don't re-fetch from BSM.
_CacheKey = Tuple[str, str, str] # (access_token_fingerprint, project_id, server_url)
_CACHE: Dict[_CacheKey, _CachedFetch] = {}
_CACHE: Dict[_CacheKey, "_CachedFetch"] = {}
# Disk-persisted cache so back-to-back CLI invocations (e.g. `hermes chat -q ...`
# called from scripts, cron, the gateway forking new agents) don't each pay the
@@ -88,29 +81,124 @@ _CACHE: Dict[_CacheKey, _CachedFetch] = {}
# <hermes_home>/cache/bws_cache.json. The file holds only the secret VALUES,
# never the access token. It's plaintext-equivalent to ~/.hermes/.env (which
# we already accept) but kept out of the .env file so users editing it won't
# accidentally commit BSM-sourced secrets. The atomic-write/0600/TTL mechanics
# live in agent.secret_sources._cache.DiskCache, shared with the other backends.
# accidentally commit BSM-sourced secrets.
_DISK_CACHE_BASENAME = "bws_cache.json"
def _disk_cache_path(home_path: Optional[Path] = None) -> Path:
"""Return the disk cache path under hermes_home/cache/.
`home_path` is what `load_hermes_dotenv()` already resolved; falling back
to `$HERMES_HOME` / `~/.hermes` keeps direct callers working too.
"""
if home_path is None:
home_path = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
return home_path / "cache" / _DISK_CACHE_BASENAME
def _cache_key_str(cache_key: _CacheKey) -> str:
"""Serialize a cache key to a stable string for JSON storage."""
token_fp, project_id, server_url = cache_key
return f"{token_fp}|{project_id}|{server_url}"
_DISK_CACHE: DiskCache = DiskCache(
_DISK_CACHE_BASENAME, key_serializer=_cache_key_str
)
def _read_disk_cache(cache_key: _CacheKey, ttl_seconds: float,
home_path: Optional[Path] = None) -> Optional["_CachedFetch"]:
"""Return a cached entry from disk if fresh, else None.
def _disk_cache_path(home_path: Optional[Path] = None) -> Path:
"""Return the disk cache path under hermes_home/cache/.
Thin wrapper over the shared DiskCache, kept for tests and any direct
callers; falls back to `$HERMES_HOME` / `~/.hermes` when home is None.
Best-effort: any I/O or parse error returns None and we re-fetch.
"""
return _DISK_CACHE.path(home_path)
if ttl_seconds <= 0:
return None
path = _disk_cache_path(home_path)
try:
with open(path, "r", encoding="utf-8") as f:
payload = json.load(f)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
if payload.get("key") != _cache_key_str(cache_key):
return None
secrets = payload.get("secrets")
fetched_at = payload.get("fetched_at")
if not isinstance(secrets, dict) or not isinstance(fetched_at, (int, float)):
return None
# Coerce all values to strings — JSON allows numbers but env vars need strings
typed_secrets: Dict[str, str] = {
k: v for k, v in secrets.items() if isinstance(k, str) and isinstance(v, str)
}
entry = _CachedFetch(secrets=typed_secrets, fetched_at=float(fetched_at))
if not entry.is_fresh(ttl_seconds):
return None
return entry
def _write_disk_cache(cache_key: _CacheKey, entry: "_CachedFetch",
home_path: Optional[Path] = None) -> None:
"""Persist a cache entry to disk atomically with mode 0600.
Best-effort: any I/O error is swallowed (the next invocation will just
re-fetch). We never want disk cache failures to break startup.
"""
path = _disk_cache_path(home_path)
try:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"key": _cache_key_str(cache_key),
"secrets": entry.secrets,
"fetched_at": entry.fetched_at,
}
# Write to a temp file in the same directory and atomic-rename.
# tempfile honors os.umask, so we explicitly chmod 0600 before rename.
fd, tmp = tempfile.mkstemp(
prefix=".bws_cache_", suffix=".tmp", dir=str(path.parent)
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except OSError:
pass # best-effort — disk cache miss on next invocation is fine
@dataclass
class _CachedFetch:
secrets: Dict[str, str]
fetched_at: float
def is_fresh(self, ttl_seconds: float) -> bool:
if ttl_seconds <= 0:
return False
return (time.time() - self.fetched_at) < ttl_seconds
# ---------------------------------------------------------------------------
# Public dataclasses
# ---------------------------------------------------------------------------
@dataclass
class FetchResult:
"""Outcome of a single BSM pull."""
secrets: Dict[str, str] = field(default_factory=dict)
applied: List[str] = field(default_factory=list) # set into os.environ
skipped: List[str] = field(default_factory=list) # already set, not overridden
warnings: List[str] = field(default_factory=list) # non-fatal issues
error: Optional[str] = None # fatal: nothing was fetched
binary_path: Optional[Path] = None
@property
def ok(self) -> bool:
return self.error is None
# ---------------------------------------------------------------------------
@@ -391,7 +479,7 @@ def fetch_bitwarden_secrets(
if cached and cached.is_fresh(cache_ttl_seconds):
return cached.secrets, []
# L2: disk cache. ~5ms on cache hit vs ~380ms for `bws secret list`.
disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)
disk_cached = _read_disk_cache(cache_key, cache_ttl_seconds, home_path)
if disk_cached is not None:
# Promote into in-process cache so subsequent fetches in the
# same process skip the disk read too.
@@ -411,7 +499,7 @@ def fetch_bitwarden_secrets(
entry = _CachedFetch(secrets=secrets, fetched_at=time.time())
_CACHE[cache_key] = entry
if use_cache:
_DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)
_write_disk_cache(cache_key, entry, home_path)
return secrets, warnings
@@ -487,6 +575,14 @@ def _run_bws_list(
return secrets, warnings
def _is_valid_env_name(name: str) -> bool:
if not name:
return False
if not (name[0].isalpha() or name[0] == "_"):
return False
return all(c.isalnum() or c == "_" for c in name)
# ---------------------------------------------------------------------------
# Public entry point — called from hermes_cli.env_loader
# ---------------------------------------------------------------------------
@@ -577,142 +673,6 @@ def apply_bitwarden_secrets(
return result
# ---------------------------------------------------------------------------
# SecretSource adapter — the registry-facing wrapper around this module.
# ---------------------------------------------------------------------------
class BitwardenSource(SecretSource):
"""Bitwarden Secrets Manager as a registered secret source.
Thin adapter over the module's fetch machinery. ``fetch()`` only
*fetches* — precedence, override semantics, conflict warnings, and
the ``os.environ`` writes are the orchestrator's job
(see ``agent.secret_sources.registry.apply_all``).
Bitwarden is a **bulk** source: it injects every secret in the
configured BSM project, so explicit per-var bindings from mapped
sources (e.g. the 1Password ``env:`` map) outrank it.
"""
name = "bitwarden"
label = "Bitwarden Secrets Manager"
shape = "bulk"
scheme = "bws"
def override_existing(self, cfg: dict) -> bool:
# Default True (matches DEFAULT_CONFIG): the point of BSM is
# centralized rotation — if .env had the final say, rotating a
# key in Bitwarden wouldn't take effect until the stale .env
# line was also deleted.
return bool(isinstance(cfg, dict) and cfg.get("override_existing", True))
def protected_env_vars(self, cfg: dict):
token_env = "BWS_ACCESS_TOKEN"
if isinstance(cfg, dict):
token_env = str(cfg.get("access_token_env") or token_env)
return frozenset({token_env})
def config_schema(self) -> dict:
return {
"enabled": {"description": "Master switch", "default": False},
"access_token_env": {
"description": "Env var holding the machine-account access token",
"default": "BWS_ACCESS_TOKEN",
},
"project_id": {"description": "BSM project UUID", "default": ""},
"cache_ttl_seconds": {
"description": "Disk+memory cache TTL; 0 disables",
"default": 300,
},
"override_existing": {
"description": "BSM values overwrite .env/shell values",
"default": True,
},
"auto_install": {
"description": "Auto-download the pinned bws binary",
"default": True,
},
"server_url": {
"description": "Region / self-hosted endpoint (empty = US Cloud)",
"default": "",
},
}
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
cfg = cfg if isinstance(cfg, dict) else {}
result = FetchResult()
access_token_env = str(cfg.get("access_token_env") or "BWS_ACCESS_TOKEN")
access_token = os.environ.get(access_token_env, "").strip()
if not access_token:
result.error = (
f"secrets.bitwarden.enabled is true but {access_token_env} is "
"not set. Run `hermes secrets bitwarden setup`."
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
project_id = str(cfg.get("project_id") or "")
if not project_id:
result.error = (
"secrets.bitwarden.project_id is empty. "
"Run `hermes secrets bitwarden setup`."
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
auto_install = bool(cfg.get("auto_install", True))
binary = find_bws(install_if_missing=auto_install)
result.binary_path = binary
if binary is None:
result.error = (
"bws binary not available and auto-install is disabled. "
"Run `hermes secrets bitwarden setup` to install."
)
result.error_kind = ErrorKind.BINARY_MISSING
return result
try:
ttl = float(cfg.get("cache_ttl_seconds", 300))
except (TypeError, ValueError):
ttl = 300.0
try:
secrets, warnings = fetch_bitwarden_secrets(
access_token=access_token,
project_id=project_id,
binary=binary,
cache_ttl_seconds=ttl,
server_url=str(cfg.get("server_url", "") or "").strip(),
home_path=home_path,
)
except RuntimeError as exc:
result.error = str(exc)
result.error_kind = _classify_bws_error(str(exc))
return result
result.secrets = secrets
result.warnings.extend(warnings)
return result
def _classify_bws_error(message: str) -> ErrorKind:
"""Best-effort mapping of bws failure text onto the shared taxonomy."""
lowered = message.lower()
if "timed out" in lowered:
return ErrorKind.TIMEOUT
if "binary not available" in lowered or "failed to invoke" in lowered:
return ErrorKind.BINARY_MISSING
if any(tok in lowered for tok in ("unauthorized", "invalid token",
"access token", "401", "403")):
return ErrorKind.AUTH_FAILED
if any(tok in lowered for tok in ("network", "connection", "resolve",
"download", "dns")):
return ErrorKind.NETWORK
return ErrorKind.INTERNAL
# ---------------------------------------------------------------------------
# Test hook — used by hermetic tests to flush the cache between cases.
# ---------------------------------------------------------------------------
@@ -726,4 +686,7 @@ def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:
writer itself.
"""
_CACHE.clear()
_DISK_CACHE.clear(home_path)
try:
_disk_cache_path(home_path).unlink()
except (FileNotFoundError, OSError):
pass

View File

@@ -1,643 +0,0 @@
"""1Password (`op` CLI) secret source.
Resolve provider credentials from 1Password ``op://vault/item/field``
references at process startup so they don't have to live in plaintext in
``~/.hermes/.env``.
Design summary
--------------
* Users map environment-variable names to official 1Password secret
references in ``secrets.onepassword.env``::
secrets:
onepassword:
enabled: true
env:
OPENAI_API_KEY: "op://Private/OpenAI/api key"
ANTHROPIC_API_KEY: "op://Private/Anthropic/credential"
* After ``.env`` loads, each reference is resolved with a single
``op read -- <reference>`` call and injected into ``os.environ`` (the
same point in startup as the Bitwarden source).
* Authentication is whatever the user's ``op`` CLI already uses — a
service-account token (``OP_SERVICE_ACCOUNT_TOKEN``) for headless boxes,
or a desktop/interactive session (``OP_SESSION_*``). Hermes never
authenticates on the user's behalf; it shells out to an already-trusted,
already-authenticated CLI.
* Failures NEVER block startup. A missing ``op`` binary, expired auth, a
bad reference, or a permission error each surface a one-line warning and
Hermes continues with whatever credentials ``.env`` already had.
The atomic-write / ``0600`` / TTL cache mechanics are shared with the other
backends via :mod:`agent.secret_sources._cache` — successful, complete pulls
are cached in-process and on disk under ``<hermes_home>/cache/op_cache.json``
so back-to-back short-lived ``hermes`` invocations don't re-shell ``op`` for
every reference. The disk file holds only resolved secret *values*; auth
material is fingerprinted, never stored.
"""
from __future__ import annotations
import hashlib
import logging
import os
import re
import shutil
import subprocess
import time
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from agent.secret_sources._cache import (
CachedFetch,
DiskCache,
FetchResult,
is_valid_env_name,
)
from agent.secret_sources.base import ErrorKind, SecretSource
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Configuration constants
# ---------------------------------------------------------------------------
# How long to wait for a single `op read`, in seconds.
_OP_RUN_TIMEOUT = 30
# Default env var the official `op` CLI reads for service-account auth. Users
# can point `service_account_token_env` at a different name; we always export
# the value to the child as OP_SERVICE_ACCOUNT_TOKEN, which is what `op` itself
# looks for.
_DEFAULT_TOKEN_ENV = "OP_SERVICE_ACCOUNT_TOKEN"
# Strip whole ANSI CSI sequences (colour, cursor moves, line erases) from any
# `op` diagnostic we surface — not just the lone ESC byte — so a control
# sequence can't reposition the cursor or hide text after a redaction marker.
_ANSI_CSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
# Env vars the `op` child actually needs. We build a minimal allowlisted env
# rather than copying all of os.environ (which, post-dotenv, holds every
# provider credential) into the child — tighter blast radius if `op` or
# anything it execs ever misbehaves. OP_SESSION_* and the token are added
# dynamically in _op_child_env().
_OP_ENV_ALLOWLIST = (
"PATH",
"HOME",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"SystemRoot",
"TMPDIR",
"TMP",
"TEMP",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
"OP_ACCOUNT",
"OP_CONNECT_HOST",
"OP_CONNECT_TOKEN",
)
# ---------------------------------------------------------------------------
# Cache
# ---------------------------------------------------------------------------
# In-process cache. The key folds in str(home_path) so a HERMES_HOME switch
# inside one long-lived process (e.g. the gateway) can't return another
# profile's secrets from L1. The disk layer omits home from its serialized
# key because the file already lives under the home dir (see _disk_key_str).
_CacheKey = Tuple[str, str, str, str] # (auth_fp, account, home, refs_fp)
_CACHE: Dict[_CacheKey, CachedFetch] = {}
_DISK_CACHE_BASENAME = "op_cache.json"
def _disk_key_str(cache_key: _CacheKey) -> str:
"""Serialize a cache key for on-disk storage, omitting home_path.
The disk file is already partitioned by home (it lives under
``<home>/cache/``), so the path provides the home dimension; folding it
into the key string too would be redundant.
"""
auth_fp, account, _home, refs_fp = cache_key
return f"{auth_fp}|{account}|{refs_fp}"
_DISK_CACHE: DiskCache = DiskCache(
_DISK_CACHE_BASENAME, key_serializer=_disk_key_str
)
def _disk_cache_path(home_path: Optional[Path] = None) -> Path:
"""Path to the on-disk cache (exposed for tests and direct callers)."""
return _DISK_CACHE.path(home_path)
# ---------------------------------------------------------------------------
# Reference validation + fingerprinting
# ---------------------------------------------------------------------------
def _validate_references(
references: Optional[Dict[str, str]],
) -> Tuple[Dict[str, str], List[str]]:
"""Return ``(valid_refs, warnings)`` from an ``env`` mapping.
A reference is kept only if its target env-var name is a valid POSIX
name and the value is a stripped ``op://…`` reference string. Everything
else produces a warning and is dropped (never fatal).
"""
valid: Dict[str, str] = {}
warnings: List[str] = []
for name, ref in (references or {}).items():
if not is_valid_env_name(name):
warnings.append(f"Skipping {name!r}: not a valid env-var name")
continue
if not isinstance(ref, str):
warnings.append(f"Skipping {name!r}: reference is not a string")
continue
cleaned = ref.strip()
if not cleaned.startswith("op://"):
warnings.append(
f"Skipping {name!r}: {ref!r} is not an op:// secret reference"
)
continue
valid[name] = cleaned
return valid, warnings
def _auth_fingerprint(token_env: str) -> str:
"""SHA-256 prefix over the auth material `op` would use.
Folds in the service-account token, ``OP_ACCOUNT``, and *all*
``OP_SESSION_*`` vars (the names `op` actually exports for interactive
sessions — ``OP_SESSION_<account_shorthand>``). Signing out and into a
different identity therefore changes the cache key, so a value cached under
a previous identity is never served under a new one. Never logged or
displayed; the raw token never leaves this hash.
"""
parts: List[str] = [
f"token={os.environ.get(token_env, '')}",
f"account={os.environ.get('OP_ACCOUNT', '')}",
]
for key in sorted(os.environ):
if key.startswith("OP_SESSION_"):
parts.append(f"{key}={os.environ[key]}")
material = "\n".join(parts)
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
def _refs_fingerprint(references: Dict[str, str]) -> str:
"""SHA-256 prefix over the configured name→reference mapping."""
material = "\n".join(f"{name}={references[name]}" for name in sorted(references))
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
# ---------------------------------------------------------------------------
# Binary discovery
# ---------------------------------------------------------------------------
def find_op(binary_path: str = "") -> Optional[Path]:
"""Resolve a usable ``op`` binary, or None.
When ``binary_path`` is set it is used verbatim and PATH is NOT consulted
— pinning an absolute path is a way to avoid trusting whatever ``op`` shows
up first on ``PATH``. A pinned-but-missing path returns None (the caller
surfaces a clear error) rather than silently falling back.
"""
if binary_path:
pinned = Path(binary_path)
if pinned.exists() and os.access(pinned, os.X_OK):
return pinned
return None
found = shutil.which("op")
return Path(found) if found else None
# ---------------------------------------------------------------------------
# `op read` invocation
# ---------------------------------------------------------------------------
def _scrub(text: str) -> str:
"""Remove ANSI control sequences and trim, for safe message surfacing."""
return _ANSI_CSI_RE.sub("", text).replace("\x1b", "").strip()
def _op_child_env(token_value: str) -> Dict[str, str]:
"""Build a minimal allowlisted environment for the ``op`` child process."""
env: Dict[str, str] = {}
for key in _OP_ENV_ALLOWLIST:
val = os.environ.get(key)
if val is not None:
env[key] = val
# Desktop / interactive session credentials.
for key, val in os.environ.items():
if key.startswith("OP_SESSION_"):
env[key] = val
# `op` reads OP_SERVICE_ACCOUNT_TOKEN regardless of which env var the user
# configured Hermes to source it from, so normalize to that name here.
if token_value:
env["OP_SERVICE_ACCOUNT_TOKEN"] = token_value
env["NO_COLOR"] = "1"
return env
def _run_op_read(
op: Path,
reference: str,
*,
account: str = "",
token_value: str = "",
) -> str:
"""Resolve a single ``op://`` reference to its value.
Raises :class:`RuntimeError` on any failure — including a ``returncode 0``
with empty output, which would otherwise silently clobber a good
``.env``/shell credential with ``""``.
"""
cmd: List[str] = [str(op), "read"]
if account:
cmd += ["--account", account]
# `--` terminates option parsing so a reference can never be mis-parsed as
# an `op` flag even if validation is ever loosened.
cmd += ["--", reference]
try:
proc = subprocess.run( # noqa: S603 — op path is user-trusted, argv list
cmd,
env=_op_child_env(token_value),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_OP_RUN_TIMEOUT,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"op read timed out after {_OP_RUN_TIMEOUT}s for {reference!r}"
) from exc
except OSError as exc:
raise RuntimeError(f"failed to invoke op: {exc}") from exc
if proc.returncode != 0:
err = _scrub(proc.stderr or "")[:200]
if err:
raise RuntimeError(f"op read failed for {reference!r}: {err}")
raise RuntimeError(
f"op read exited {proc.returncode} for {reference!r}"
)
# `op` appends a trailing newline; strip only that so a value with
# intentional internal/edge spaces survives. But a value that is empty or
# whitespace-only is treated as empty: applying it would silently clobber a
# good .env/shell credential with effectively nothing.
value = (proc.stdout or "").rstrip("\r\n")
if not value.strip():
raise RuntimeError(f"op read returned an empty value for {reference!r}")
return value
# ---------------------------------------------------------------------------
# Fetch
# ---------------------------------------------------------------------------
def fetch_onepassword_secrets(
*,
references: Dict[str, str],
account: str = "",
token_env: str = _DEFAULT_TOKEN_ENV,
binary: Optional[Path] = None,
binary_path: str = "",
use_cache: bool = True,
cache_ttl_seconds: float = 300,
home_path: Optional[Path] = None,
) -> Tuple[Dict[str, str], List[str]]:
"""Resolve ``references`` (name → ``op://…``) to ``(secrets, warnings)``.
Raises :class:`RuntimeError` only when no ``op`` binary is available — a
fatal "can't fetch anything" condition. Per-reference failures (expired
auth, bad reference, empty value) are collected as warnings and the
reference is dropped, so one bad entry never sinks the rest.
Only a complete, error-free pull is cached, so a transient auth failure
isn't frozen in for the whole TTL window.
"""
valid, warnings = _validate_references(references)
if not valid:
return {}, warnings
token_value = os.environ.get(token_env, "").strip()
cache_key: _CacheKey = (
_auth_fingerprint(token_env),
account or "",
str(home_path) if home_path is not None else "",
_refs_fingerprint(valid),
)
if use_cache:
cached = _CACHE.get(cache_key)
if cached and cached.is_fresh(cache_ttl_seconds):
return dict(cached.secrets), warnings
disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)
if disk_cached is not None:
# Promote into L1 so later fetches in this process skip the disk read.
_CACHE[cache_key] = disk_cached
return dict(disk_cached.secrets), warnings
op = binary or find_op(binary_path)
if op is None:
raise RuntimeError(
"op CLI not found. Install the 1Password CLI "
"(https://developer.1password.com/docs/cli/get-started/) or set "
"secrets.onepassword.binary_path to its absolute location."
)
secrets: Dict[str, str] = {}
read_errors = 0
for name in sorted(valid):
try:
secrets[name] = _run_op_read(
op, valid[name], account=account, token_value=token_value
)
except RuntimeError as exc:
warnings.append(str(exc))
read_errors += 1
if use_cache and not read_errors and secrets:
entry = CachedFetch(secrets=dict(secrets), fetched_at=time.time())
_CACHE[cache_key] = entry
_DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)
return secrets, warnings
# ---------------------------------------------------------------------------
# Public entry point — called from hermes_cli.env_loader
# ---------------------------------------------------------------------------
def apply_onepassword_secrets(
*,
enabled: bool,
env: Optional[Dict[str, str]] = None,
account: str = "",
service_account_token_env: str = _DEFAULT_TOKEN_ENV,
binary_path: str = "",
override_existing: bool = True,
cache_ttl_seconds: float = 300,
home_path: Optional[Path] = None,
) -> FetchResult:
"""Resolve configured ``op://`` references and set them on ``os.environ``.
Called by ``load_hermes_dotenv()`` after the .env files have loaded.
Intentionally defensive — any failure returns a :class:`FetchResult` with
``error`` set (or surfaces warnings); it never raises.
Parameters mirror the ``secrets.onepassword.*`` config keys so the caller
can splat the dict in. References that are already satisfied by the
current environment (when ``override_existing`` is false) are skipped
*before* fetching, so ``op`` is never invoked for a value that would be
discarded.
"""
result = FetchResult()
if not enabled:
return result
valid, warnings = _validate_references(env)
result.warnings.extend(warnings)
# Skip-before-fetch: never resolve a reference we'd only throw away.
refs_to_fetch: Dict[str, str] = {}
for name, ref in valid.items():
if name == service_account_token_env:
# Never let a resolved secret clobber the very token used to auth.
result.skipped.append(name)
continue
if not override_existing and os.environ.get(name):
result.skipped.append(name)
continue
refs_to_fetch[name] = ref
if not refs_to_fetch:
return result
binary = find_op(binary_path)
result.binary_path = binary
if binary is None:
if binary_path:
result.error = (
f"secrets.onepassword.binary_path ({binary_path!r}) is not an "
"executable op binary."
)
else:
result.error = (
"secrets.onepassword.enabled is true but the op CLI was not "
"found on PATH. Install it "
"(https://developer.1password.com/docs/cli/get-started/) or set "
"secrets.onepassword.binary_path."
)
return result
try:
secrets, fetch_warnings = fetch_onepassword_secrets(
references=refs_to_fetch,
account=account,
token_env=service_account_token_env,
binary=binary,
cache_ttl_seconds=cache_ttl_seconds,
home_path=home_path,
)
except RuntimeError as exc:
result.error = str(exc)
return result
result.secrets = secrets
result.warnings.extend(fetch_warnings)
for name, value in secrets.items():
# The token-var and override guards already filtered refs_to_fetch, but
# re-check defensively in case the fetch layer ever returns extras.
if name == service_account_token_env:
if name not in result.skipped:
result.skipped.append(name)
continue
if not override_existing and os.environ.get(name):
if name not in result.skipped:
result.skipped.append(name)
continue
os.environ[name] = value
result.applied.append(name)
return result
# ---------------------------------------------------------------------------
# SecretSource adapter — the registry-facing wrapper around this module.
# ---------------------------------------------------------------------------
class OnePasswordSource(SecretSource):
"""1Password as a registered secret source.
Thin adapter over the module's fetch machinery. ``fetch()`` only
*fetches* — precedence, override semantics, conflict warnings, and
the ``os.environ`` writes are the orchestrator's job
(see ``agent.secret_sources.registry.apply_all``).
1Password is a **mapped** source: the user explicitly binds each env
var to an ``op://`` reference under ``secrets.onepassword.env``, so
its claims outrank bulk sources (e.g. a Bitwarden project dump) on
contested vars.
"""
name = "onepassword"
label = "1Password"
shape = "mapped"
scheme = "op"
def override_existing(self, cfg: dict) -> bool:
# Default True: an explicit VAR→op:// binding is the strongest
# user intent there is — leaving a stale .env line in place
# should not silently defeat it (same rotation rationale as
# Bitwarden).
return bool(isinstance(cfg, dict) and cfg.get("override_existing", True))
def protected_env_vars(self, cfg: dict):
token_env = _DEFAULT_TOKEN_ENV
if isinstance(cfg, dict):
token_env = str(cfg.get("service_account_token_env") or token_env)
return frozenset({token_env})
def config_schema(self) -> dict:
return {
"enabled": {"description": "Master switch", "default": False},
"env": {
"description": "Map of ENV_VAR -> op://vault/item/field reference",
"default": {},
},
"account": {
"description": "op --account shorthand (empty = default account)",
"default": "",
},
"service_account_token_env": {
"description": "Env var holding the service-account token "
"(unset = desktop/interactive session)",
"default": _DEFAULT_TOKEN_ENV,
},
"binary_path": {
"description": "Pin the op binary (empty = resolve via PATH)",
"default": "",
},
"cache_ttl_seconds": {
"description": "Disk+memory cache TTL; 0 disables",
"default": 300,
},
"override_existing": {
"description": "Resolved values overwrite .env/shell values",
"default": True,
},
}
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
cfg = cfg if isinstance(cfg, dict) else {}
result = FetchResult()
env_map = cfg.get("env")
valid, warnings = _validate_references(
env_map if isinstance(env_map, dict) else None
)
result.warnings.extend(warnings)
if not valid:
if not warnings:
result.error = (
"secrets.onepassword.enabled is true but the env: map is "
"empty. Add ENV_VAR: op://vault/item/field entries."
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
binary_path = str(cfg.get("binary_path") or "")
binary = find_op(binary_path)
result.binary_path = binary
if binary is None:
if binary_path:
result.error = (
f"secrets.onepassword.binary_path ({binary_path!r}) is "
"not an executable op binary."
)
else:
result.error = (
"secrets.onepassword.enabled is true but the op CLI was "
"not found on PATH. Install it "
"(https://developer.1password.com/docs/cli/get-started/) "
"or set secrets.onepassword.binary_path."
)
result.error_kind = ErrorKind.BINARY_MISSING
return result
try:
ttl = float(cfg.get("cache_ttl_seconds", 300))
except (TypeError, ValueError):
ttl = 300.0
try:
secrets, fetch_warnings = fetch_onepassword_secrets(
references=valid,
account=str(cfg.get("account") or ""),
token_env=str(
cfg.get("service_account_token_env") or _DEFAULT_TOKEN_ENV
),
binary=binary,
cache_ttl_seconds=ttl,
home_path=home_path,
)
except RuntimeError as exc:
result.error = str(exc)
result.error_kind = _classify_op_error(str(exc))
return result
result.secrets = secrets
result.warnings.extend(fetch_warnings)
return result
def _classify_op_error(message: str) -> ErrorKind:
"""Best-effort mapping of op failure text onto the shared taxonomy."""
lowered = message.lower()
if "timed out" in lowered:
return ErrorKind.TIMEOUT
if "not found on path" in lowered or "not an executable" in lowered \
or "failed to invoke" in lowered:
return ErrorKind.BINARY_MISSING
if any(tok in lowered for tok in ("unauthorized", "not signed in",
"session expired", "authentication",
"401", "403")):
return ErrorKind.AUTH_FAILED
if "empty value" in lowered:
return ErrorKind.EMPTY_VALUE
if any(tok in lowered for tok in ("network", "connection", "resolve host",
"dns")):
return ErrorKind.NETWORK
return ErrorKind.INTERNAL
# ---------------------------------------------------------------------------
# Test hook — used by hermetic tests to flush the cache between cases.
# ---------------------------------------------------------------------------
def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:
"""Clear in-process AND disk caches.
Tests can pass ``home_path`` to scope the disk cleanup to a tmpdir.
Without it we fall back to the same default resolution as the writer.
"""
_CACHE.clear()
_DISK_CACHE.clear(home_path)

View File

@@ -1,370 +0,0 @@
"""Secret-source registry + apply orchestrator.
This module owns everything that must be uniform across secret backends
so no individual source can get it wrong:
* registration (name/scheme uniqueness, API-version gating)
* per-source wall-clock timeout enforcement around ``fetch()``
* precedence: mapped sources beat bulk sources; within a shape,
``secrets.sources`` order (or registration order) decides; first
claim wins — later sources never silently clobber an earlier one
* ``override_existing`` semantics (may beat .env/shell, never another
secret source, never a protected var)
* cross-source conflict warnings (shadowed claims are always surfaced)
* provenance: which source supplied every applied var
The single entry point for startup is :func:`apply_all`, called from
``hermes_cli.env_loader._apply_external_secret_sources()``.
Plugins register additional sources via
``PluginContext.register_secret_source()`` which lands in
:func:`register_source`. In-tree sources are registered lazily by
:func:`_ensure_builtin_sources` — the set of bundled sources is
deliberately closed (Bitwarden, and 1Password once it lands); new
third-party backends ship as standalone plugin repos implementing
:class:`agent.secret_sources.base.SecretSource`.
"""
from __future__ import annotations
import concurrent.futures
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
from agent.secret_sources.base import (
SECRET_SOURCE_API_VERSION,
ErrorKind,
FetchResult,
SecretSource,
is_valid_env_name,
)
logger = logging.getLogger(__name__)
# Ordered registry: name → source instance. Python dicts preserve
# insertion order, which doubles as the default apply order.
_SOURCES: Dict[str, SecretSource] = {}
_BUILTINS_LOADED = False
@dataclass
class AppliedVar:
"""Provenance record for one env var the orchestrator set."""
name: str
source: str # SecretSource.name
shape: str # "mapped" | "bulk"
overrode_env: bool # replaced a pre-existing .env/shell value
@dataclass
class SourceReport:
"""One source's outcome within an :class:`ApplyReport`."""
name: str
label: str
result: FetchResult
applied: List[str] = field(default_factory=list)
skipped_existing: List[str] = field(default_factory=list) # .env/shell won
skipped_claimed: List[str] = field(default_factory=list) # earlier source won
skipped_protected: List[str] = field(default_factory=list) # bootstrap-auth guard
skipped_invalid: List[str] = field(default_factory=list) # bad env-var name
@dataclass
class ApplyReport:
"""Merged outcome of one orchestrated apply pass."""
sources: List[SourceReport] = field(default_factory=list)
provenance: Dict[str, AppliedVar] = field(default_factory=dict)
conflicts: List[str] = field(default_factory=list) # human-readable warnings
@property
def applied_any(self) -> bool:
return bool(self.provenance)
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
def register_source(source: SecretSource, *, replace: bool = False) -> bool:
"""Register a secret source. Returns True on success.
Rejections are logged, never raised — a bad plugin must not take
down startup. ``replace`` allows tests / user plugins to override
a bundled source of the same name (last-writer-wins like model
providers), but scheme collisions across *different* names are
always rejected.
"""
if not isinstance(source, SecretSource):
logger.warning(
"Ignoring secret source %r: does not inherit from SecretSource",
source,
)
return False
name = getattr(source, "name", "") or ""
if not name or not name.replace("_", "").isalnum() or name != name.lower():
logger.warning("Ignoring secret source with invalid name %r", name)
return False
if getattr(source, "api_version", None) != SECRET_SOURCE_API_VERSION:
logger.warning(
"Ignoring secret source '%s': built against secret-source API v%s, "
"this Hermes speaks v%s",
name, getattr(source, "api_version", "?"), SECRET_SOURCE_API_VERSION,
)
return False
if getattr(source, "shape", None) not in ("mapped", "bulk"):
logger.warning(
"Ignoring secret source '%s': shape must be 'mapped' or 'bulk', got %r",
name, getattr(source, "shape", None),
)
return False
if name in _SOURCES and not replace:
logger.warning("Secret source '%s' already registered; ignoring duplicate", name)
return False
scheme = getattr(source, "scheme", None)
if scheme:
for other_name, other in _SOURCES.items():
if other_name != name and getattr(other, "scheme", None) == scheme:
logger.warning(
"Ignoring secret source '%s': scheme '%s://' is already "
"owned by source '%s'",
name, scheme, other_name,
)
return False
_SOURCES[name] = source
return True
def get_source(name: str) -> Optional[SecretSource]:
_ensure_builtin_sources()
return _SOURCES.get(name)
def list_sources() -> List[SecretSource]:
_ensure_builtin_sources()
return list(_SOURCES.values())
def _ensure_builtin_sources() -> None:
"""Idempotently register the bundled sources.
Lazy so importing this module stays cheap and so a broken bundled
source can never break registration of the others.
"""
global _BUILTINS_LOADED
if _BUILTINS_LOADED:
return
_BUILTINS_LOADED = True
try:
from agent.secret_sources.bitwarden import BitwardenSource
register_source(BitwardenSource())
except Exception: # noqa: BLE001 — never block startup
logger.warning("Failed to register bundled Bitwarden secret source",
exc_info=True)
try:
from agent.secret_sources.onepassword import OnePasswordSource
register_source(OnePasswordSource())
except Exception: # noqa: BLE001 — never block startup
logger.warning("Failed to register bundled 1Password secret source",
exc_info=True)
def _reset_registry_for_tests() -> None:
global _BUILTINS_LOADED
_SOURCES.clear()
_BUILTINS_LOADED = False
# ---------------------------------------------------------------------------
# Orchestrated apply
# ---------------------------------------------------------------------------
def _fetch_with_timeout(
source: SecretSource, cfg: dict, home_path: Path
) -> FetchResult:
"""Run source.fetch() under a wall-clock budget; never raises.
The budget is enforced with a daemon worker thread: a source that
blows its budget is reported as ``TIMEOUT`` and its (eventual)
result is discarded. The thread itself may linger until process
exit — acceptable for a startup-only path, and strictly better than
an unbounded hang on every ``hermes`` invocation.
"""
timeout = source.fetch_timeout_seconds(cfg)
executor = concurrent.futures.ThreadPoolExecutor(
max_workers=1, thread_name_prefix=f"secret-src-{source.name}"
)
try:
future = executor.submit(source.fetch, cfg, home_path)
try:
result = future.result(timeout=timeout)
except concurrent.futures.TimeoutError:
future.cancel()
res = FetchResult()
res.error = (
f"fetch exceeded {timeout:.0f}s budget — startup continued "
"without this source (raise secrets."
f"{source.name}.timeout_seconds if the backend is just slow)"
)
res.error_kind = ErrorKind.TIMEOUT
return res
except Exception as exc: # noqa: BLE001 — contract violation, contain it
res = FetchResult()
res.error = f"fetch raised {type(exc).__name__}: {exc}"
res.error_kind = ErrorKind.INTERNAL
return res
finally:
executor.shutdown(wait=False)
if not isinstance(result, FetchResult):
res = FetchResult()
res.error = (
f"fetch returned {type(result).__name__} instead of FetchResult"
)
res.error_kind = ErrorKind.INTERNAL
return res
return result
def _ordered_enabled_sources(secrets_cfg: dict) -> List[SecretSource]:
"""Resolve which sources run, in which order.
Order: the optional ``secrets.sources`` list wins; sources not named
there follow in registration order. Enabled = the source's own
``is_enabled`` says so for its config section. Mapped-vs-bulk
precedence is applied on top of this order by :func:`apply_all`.
"""
_ensure_builtin_sources()
explicit = secrets_cfg.get("sources")
order: List[str] = []
if isinstance(explicit, list):
for entry in explicit:
if isinstance(entry, str) and entry in _SOURCES and entry not in order:
order.append(entry)
unknown = [e for e in explicit
if isinstance(e, str) and e not in _SOURCES]
if unknown:
logger.warning(
"secrets.sources names unknown source(s): %s (known: %s)",
", ".join(unknown), ", ".join(_SOURCES) or "none",
)
for name in _SOURCES:
if name not in order:
order.append(name)
enabled: List[SecretSource] = []
for name in order:
source = _SOURCES[name]
cfg = secrets_cfg.get(name)
cfg = cfg if isinstance(cfg, dict) else {}
try:
if source.is_enabled(cfg):
enabled.append(source)
except Exception: # noqa: BLE001
logger.warning("Secret source '%s' is_enabled() raised; skipping",
name, exc_info=True)
return enabled
def apply_all(secrets_cfg: dict, home_path: Path,
environ: Optional[Dict[str, str]] = None) -> ApplyReport:
"""Fetch from every enabled source and apply the merged result to env.
``environ`` defaults to ``os.environ``; injectable for tests.
Precedence per env var (most-specific intent wins):
1. Pre-existing env (.env / shell) — unless the winning source has
``override_existing: true``.
2. Mapped sources, in configured order.
3. Bulk sources, in configured order.
First claim wins. A later source that also carries the var gets a
``skipped_claimed`` entry and a conflict warning — never a silent
clobber, and ``override_existing`` never applies across sources.
"""
import os as _os
env = environ if environ is not None else _os.environ
report = ApplyReport()
secrets_cfg = secrets_cfg if isinstance(secrets_cfg, dict) else {}
enabled = _ordered_enabled_sources(secrets_cfg)
if not enabled:
return report
# Mapped sources outrank bulk sources regardless of list order:
# an explicit VAR→ref binding is stronger intent than a project dump.
ordered = ([s for s in enabled if s.shape == "mapped"]
+ [s for s in enabled if s.shape == "bulk"])
# Fetch phase.
fetches: List[tuple[SecretSource, dict, FetchResult]] = []
protected: Dict[str, str] = {} # var → source that protects it
for source in ordered:
cfg = secrets_cfg.get(source.name)
cfg = cfg if isinstance(cfg, dict) else {}
result = _fetch_with_timeout(source, cfg, home_path)
fetches.append((source, cfg, result))
try:
for var in source.protected_env_vars(cfg):
protected.setdefault(var, source.name)
except Exception: # noqa: BLE001
pass
# Apply phase — sequential, first-wins, fully attributed.
claimed: Dict[str, str] = {} # var → source name that won it
for source, cfg, result in fetches:
sr = SourceReport(name=source.name,
label=source.label or source.name,
result=result)
report.sources.append(sr)
if not result.ok:
continue
try:
override = source.override_existing(cfg)
except Exception: # noqa: BLE001
override = False
for var, value in result.secrets.items():
if not isinstance(var, str) or not isinstance(value, str):
continue
if not is_valid_env_name(var):
sr.skipped_invalid.append(var)
continue
if var in protected:
sr.skipped_protected.append(var)
continue
if var in claimed:
sr.skipped_claimed.append(var)
report.conflicts.append(
f"{var}: kept value from {claimed[var]}; "
f"{source.name} also supplies it (first source wins — "
"remove one binding or reorder secrets.sources)"
)
continue
existed = bool(env.get(var))
if existed and not override:
sr.skipped_existing.append(var)
continue
env[var] = value
claimed[var] = source.name
sr.applied.append(var)
report.provenance[var] = AppliedVar(
name=var,
source=source.name,
shape=source.shape,
overrode_env=existed,
)
return report

View File

@@ -224,15 +224,6 @@ def register_from_config(
if not isinstance(cfg, dict):
return []
# Safe mode (--safe-mode / HERMES_SAFE_MODE=1): shell hooks are user
# customizations too — skip registration entirely so a troubleshooting
# run fires zero user-configured code (plugins, MCP, AND hooks).
from utils import env_var_enabled
if env_var_enabled("HERMES_SAFE_MODE"):
logger.info("HERMES_SAFE_MODE=1 — shell-hook registration skipped")
return []
effective_accept = _resolve_effective_accept(cfg, accept_hooks)
specs = _parse_hooks_block(cfg.get("hooks"))

View File

@@ -254,7 +254,6 @@ def build_bundle_invocation_message(
cmd_key: str,
user_instruction: str = "",
task_id: str | None = None,
platform: str | None = None,
) -> Optional[Tuple[str, List[str], List[str]]]:
"""Build the user message content for a bundle slash command invocation.
@@ -265,16 +264,6 @@ def build_bundle_invocation_message(
loads — the agent gets a note about which ones were skipped. This is
the same forgiving stance ``build_preloaded_skills_prompt`` uses for
``-s`` CLI preloading.
Disabled skills are also skipped: bundles load members via
``_load_skill_payload`` directly, bypassing the scan-time disabled
filter in ``get_skill_commands()``, so the disabled list must be
re-applied here. ``platform`` scopes the check to a specific
platform's ``skills.platform_disabled`` config (gateway dispatch
passes it explicitly because the gateway handles multiple platforms
in one process); when *None*, the platform resolves from session env
vars and the global disabled list still applies. Mirrors the
stacked-skill gate in gateway dispatch (#58888).
"""
bundles = get_skill_bundles()
info = bundles.get(cmd_key)
@@ -285,15 +274,8 @@ def build_bundle_invocation_message(
# keep skill_bundles cheap to import in test environments.
from agent.skill_commands import _load_skill_payload, _build_skill_message
try:
from agent.skill_utils import get_disabled_skill_names
disabled_names = get_disabled_skill_names(platform=platform)
except Exception:
disabled_names = set()
loaded_names: List[str] = []
missing: List[str] = []
disabled: List[str] = []
skill_blocks: List[str] = []
seen: set[str] = set()
@@ -313,12 +295,6 @@ def build_bundle_invocation_message(
continue
loaded_skill, skill_dir, skill_name = loaded
# Per-platform / global disabled gate. Checked against the loaded
# skill's canonical name (identifiers may be paths or aliases).
if skill_name in disabled_names or identifier in disabled_names:
disabled.append(skill_name or identifier)
continue
try:
from tools.skill_usage import bump_use
bump_use(skill_name)
@@ -353,10 +329,6 @@ def build_bundle_invocation_message(
]
if missing:
header_lines.append(f"Skills missing (skipped): {', '.join(missing)}")
if disabled:
header_lines.append(
f"Skills disabled for this platform (skipped): {', '.join(disabled)}"
)
if extra_instruction:
header_lines.extend(["", f"Bundle instruction: {extra_instruction}"])
if user_instruction:

View File

@@ -143,9 +143,37 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu
try:
from tools.skills_tool import SKILLS_DIR, skill_view
from agent.skill_utils import normalize_skill_lookup_name
from agent.skill_utils import get_external_skills_dirs
normalized = normalize_skill_lookup_name(raw_identifier)
identifier_path = Path(raw_identifier).expanduser()
if identifier_path.is_absolute():
normalized = None
trusted_roots = [SKILLS_DIR]
try:
trusted_roots.extend(get_external_skills_dirs())
except Exception:
pass
# Prefer the lexical path under a trusted skill root before
# resolving symlinks. Slash-command discovery can legitimately
# find a skill via ~/.hermes/skills/<name> where <name> is a
# symlink to a checked-out skill elsewhere. Resolving first turns
# that trusted visible path into an arbitrary absolute path that
# skill_view() refuses to load.
for root in trusted_roots:
try:
normalized = str(identifier_path.relative_to(root))
break
except ValueError:
continue
if normalized is None:
try:
normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve()))
except Exception:
normalized = raw_identifier
else:
normalized = raw_identifier.lstrip("/")
loaded_skill = json.loads(
skill_view(normalized, task_id=task_id, preprocess=False)
@@ -668,27 +696,14 @@ def build_preloaded_skills_prompt(
skill_identifiers: list[str],
task_id: str | None = None,
) -> tuple[str, list[str], list[str]]:
"""Load one or more skills for session-wide CLI/TUI preloading.
"""Load one or more skills for session-wide CLI preloading.
Returns (prompt_text, loaded_skill_names, missing_identifiers).
Disabled skills are treated the same as missing ones: this loads via a
raw identifier straight into ``_load_skill_payload``, bypassing
``get_skill_commands()``'s scan-time disabled filter — mirrors the
bundle-invocation gate (#59156). Without this, ``hermes -s <skill>`` or
a deployment's ``HERMES_TUI_SKILLS`` env var could force-load a skill an
operator disabled via ``skills.disabled``/``skills.platform_disabled``.
"""
prompt_parts: list[str] = []
loaded_names: list[str] = []
missing: list[str] = []
try:
from agent.skill_utils import get_disabled_skill_names
disabled_names = get_disabled_skill_names()
except Exception:
disabled_names = set()
seen: set[str] = set()
for raw_identifier in skill_identifiers:
identifier = (raw_identifier or "").strip()
@@ -703,10 +718,6 @@ def build_preloaded_skills_prompt(
loaded_skill, skill_dir, skill_name = loaded
if skill_name in disabled_names or identifier in disabled_names:
missing.append(identifier)
continue
# Track active usage for Curator lifecycle management (#17782)
try:
from tools.skill_usage import bump_use

View File

@@ -160,8 +160,27 @@ def parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:
# ── Platform matching ─────────────────────────────────────────────────────
def skill_matches_platform_list(platforms: Any) -> bool:
"""Return True when *platforms* is compatible with the current OS."""
def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
"""Return True when the skill is compatible with the current OS.
Skills declare platform requirements via a top-level ``platforms`` list
in their YAML frontmatter::
platforms: [macos] # macOS only
platforms: [macos, linux] # macOS and Linux
If the field is absent or empty the skill is compatible with **all**
platforms (backward-compatible default).
Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on
older Pythons but became ``"android"`` on Python 3.13+. Termux is a
Linux userland riding on the Android kernel, so skills tagged
``linux`` are treated as compatible in Termux regardless of which
``sys.platform`` value Python reports. Individual Linux commands
inside a skill may still misbehave (no systemd, BusyBox utils, no
apt/dnf, etc.) but that is on the skill, not on platform gating.
"""
platforms = frontmatter.get("platforms")
if not platforms:
return True
if not isinstance(platforms, list):
@@ -185,29 +204,6 @@ def skill_matches_platform_list(platforms: Any) -> bool:
return False
def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
"""Return True when the skill is compatible with the current OS.
Skills declare platform requirements via a top-level ``platforms`` list
in their YAML frontmatter::
platforms: [macos] # macOS only
platforms: [macos, linux] # macOS and Linux
If the field is absent or empty the skill is compatible with **all**
platforms (backward-compatible default).
Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on
older Pythons but became ``"android"`` on Python 3.13+. Termux is a
Linux userland riding on the Android kernel, so skills tagged
``linux`` are treated as compatible in Termux regardless of which
``sys.platform`` value Python reports. Individual Linux commands
inside a skill may still misbehave (no systemd, BusyBox utils, no
apt/dnf, etc.) but that is on the skill, not on platform gating.
"""
return skill_matches_platform_list(frontmatter.get("platforms"))
# ── Environment matching ──────────────────────────────────────────────────
# Recognized environment tags and how each is detected. An environment tag is
@@ -511,63 +507,6 @@ def get_all_skills_dirs() -> List[Path]:
return dirs
def normalize_skill_lookup_name(identifier: str) -> str:
"""Normalize a skill identifier to a ``skill_view()``-safe relative path.
Slash commands and cron jobs may store absolute paths to skills that live
under ``~/.hermes/skills/`` (including via symlinks) or configured
``skills.external_dirs``. ``skill_view()`` rejects absolute names for
security, so callers must translate trusted absolute paths to their
relative form first.
"""
raw_identifier = (identifier or "").strip()
if not raw_identifier:
return raw_identifier
identifier_path = Path(raw_identifier).expanduser()
if not identifier_path.is_absolute():
return raw_identifier.lstrip("/")
# Look the primary skills root up on tools.skills_tool at CALL time
# (not via get_skills_dir()): callers and tests patch
# ``tools.skills_tool.SKILLS_DIR`` and skill_view() itself resolves
# against that module attribute, so normalization must agree with the
# exact root skill_view() will enforce. Import deferred to avoid a
# module cycle (tools.skills_tool imports agent.skill_utils).
try:
from tools import skills_tool as _skills_tool
primary_root = Path(_skills_tool.SKILLS_DIR)
except Exception:
primary_root = get_skills_dir()
trusted_roots = [primary_root]
try:
trusted_roots.extend(get_external_skills_dirs())
except Exception:
pass
# Prefer the lexical path under a trusted skill root before resolving
# symlinks. Slash-command discovery can legitimately find a skill via
# ~/.hermes/skills/<name> where <name> is a symlink to a checked-out
# skill elsewhere. Resolving first turns that trusted visible path into
# an arbitrary absolute path that skill_view() refuses to load.
for root in trusted_roots:
try:
return str(identifier_path.relative_to(root))
except ValueError:
continue
try:
return str(identifier_path.resolve().relative_to(primary_root.resolve()))
except Exception:
logger.debug(
"Skill identifier %r is an absolute path outside trusted skills "
"roots — passing through unchanged (skill_view will reject it)",
raw_identifier,
)
return raw_identifier
def _resolve_for_skill_ownership(path) -> Path:
path_obj = path if isinstance(path, Path) else Path(str(path))
try:
@@ -791,9 +730,8 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
``SKILL.md`` files, but they are progressive-disclosure data loaded through
``skill_view(..., file_path=...)`` rather than active skill roots.
"""
skills_dir_str = str(skills_dir)
matches: list[str] = []
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
matches = []
for root, dirs, files in os.walk(skills_dir, followlinks=True):
has_skill_md = "SKILL.md" in files
dirs[:] = [
d
@@ -802,9 +740,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
]
if filename in files:
matches.append(os.path.join(root, filename))
for path in sorted(matches):
yield Path(path)
matches.append(Path(root) / filename)
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
yield path
# ── Namespace helpers for plugin-provided skills ───────────────────────────

View File

@@ -24,7 +24,6 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
from __future__ import annotations
import json
import os
from typing import Any, Dict, List, Optional
from agent.prompt_builder import (
@@ -45,7 +44,6 @@ from agent.prompt_builder import (
drain_truncation_warnings,
)
from agent.runtime_cwd import resolve_context_cwd
from utils import is_truthy_value
def _ra():
@@ -112,36 +110,6 @@ def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) ->
return base
_TUI_EMBEDDED_PANE_CLARIFIER = (
" You're in its embedded terminal pane, beside the GUI chat — the user can "
"select your output (Option-drag on macOS, Shift-drag elsewhere) and press "
"Cmd/Ctrl+L to send it to the chat composer."
)
def _tui_embedded_pane_clarifier(hint: str) -> str:
"""Append the desktop-embedded-terminal-pane clarifier to a tui hint.
Triggered by ``HERMES_DESKTOP_TERMINAL=1`` (set by ``main.cjs`` only on the
shell env of the desktop's embedded TUI PTY — never on the chat backend).
This is a runtime-surface qualifier, not a config override, so it lives at
the resolution site rather than inside ``_resolve_platform_hint`` (which
is purely the config-platform_hints override applier). Byte-stable for the
cache: called once per session build, deterministically from env state.
Idempotent and empty-safe: re-applying on an already-augmented hint is a
no-op, and an empty input returns empty (we never synthesize the
clarifier without its tui framing).
"""
if not hint:
return hint
if _TUI_EMBEDDED_PANE_CLARIFIER in hint:
return hint
if not is_truthy_value(os.getenv("HERMES_DESKTOP_TERMINAL")):
return hint
return hint + _TUI_EMBEDDED_PANE_CLARIFIER
def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]:
"""Assemble the system prompt as three ordered parts.
@@ -430,8 +398,6 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
pass
_effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)
if platform_key == "tui" and _effective_hint:
_effective_hint = _tui_embedded_pane_clarifier(_effective_hint)
if _effective_hint:
stable_parts.append(_effective_hint)

View File

@@ -415,8 +415,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
)
else:
try:
from hermes_cli.plugins import resolve_pre_tool_block
block_message = resolve_pre_tool_block(
from hermes_cli.plugins import get_pre_tool_call_block_message
block_message = get_pre_tool_call_block_message(
function_name,
function_args,
task_id=effective_task_id or "",
@@ -1034,8 +1034,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
_block_error_type = "tool_scope_block"
else:
try:
from hermes_cli.plugins import resolve_pre_tool_block
_block_msg = resolve_pre_tool_block(
from hermes_cli.plugins import get_pre_tool_call_block_message
_block_msg = get_pre_tool_call_block_message(
function_name,
function_args,
task_id=effective_task_id or "",

View File

@@ -1,398 +0,0 @@
"""Upload a Hermes session transcript to Hugging Face as an agent trace.
Hermes stores sessions in its own SQLite store (``hermes_state.SessionDB``),
so we reconstruct the conversation and emit it in the **Claude Code JSONL**
shape — one of the three formats the Hugging Face Agent Trace Viewer
auto-detects (Claude Code / Codex / Pi). No dataset-side preprocessing is
needed; the Hub tags the dataset ``agent-traces`` and opens it in the viewer.
Docs: https://huggingface.co/docs/hub/agent-traces
Design notes
------------
* **Zero LLM turn.** This is a deterministic export — it never spends a
model call. The ``hermes trace upload`` subcommand calls
:func:`upload_session_trace` directly.
* **Private by default.** Traces can contain prompts, tool output, local
paths, and secrets. The dataset is created private and every text body
is passed through Hermes' secret redactor (``force=True``) unless the
caller explicitly opts out with ``redact=False``.
* **Never raises.** Returns a user-facing status string so command
handlers can echo it straight back to the user. Programmatic callers
that need the URL can use :func:`build_trace_jsonl` + :func:`_do_upload`
directly.
"""
from __future__ import annotations
import json
import logging
import os
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
DEFAULT_DATASET_NAME = "hermes-traces"
_HERMES_VERSION = "hermes-agent"
_REDACTION_BLOCKED_MESSAGE = (
"Trace upload blocked: secret redaction failed, so the transcript may "
"still contain credentials or other sensitive data. Fix the redactor or "
"rerun with --no-redact only after manually reviewing the transcript."
)
class TraceRedactionError(RuntimeError):
"""Raised when a trace cannot be safely redacted before upload."""
# ---------------------------------------------------------------------------
# Conversion: Hermes OpenAI-format messages -> Claude Code JSONL
# ---------------------------------------------------------------------------
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
def _redact(text: Any, enabled: bool) -> Any:
"""Redact secrets from a string body when redaction is enabled.
Non-strings pass through untouched. Uses Hermes' shared redactor with
``force=True`` so an upload always scrubs known secret shapes even if
the user disabled log redaction globally.
"""
if not enabled or not isinstance(text, str) or not text:
return text
try:
from agent.redact import redact_sensitive_text
return redact_sensitive_text(text, force=True)
except Exception as exc:
logger.warning("Trace upload redaction failed; refusing upload", exc_info=True)
raise TraceRedactionError(_REDACTION_BLOCKED_MESSAGE) from exc
def _content_to_blocks(content: Any, redact: bool) -> List[Dict[str, Any]]:
"""Normalize a message ``content`` field into Anthropic content blocks."""
if content is None:
return []
if isinstance(content, str):
return [{"type": "text", "text": _redact(content, redact)}]
if isinstance(content, list):
blocks: List[Dict[str, Any]] = []
for part in content:
if isinstance(part, dict):
ptype = part.get("type")
if ptype == "text":
blocks.append({"type": "text", "text": _redact(part.get("text", ""), redact)})
elif ptype in ("image_url", "image"):
# Keep a placeholder; the viewer renders text turns and we
# don't want to inline base64 blobs into a trace.
blocks.append({"type": "text", "text": "[image omitted]"})
else:
blocks.append({"type": "text", "text": _redact(json.dumps(part), redact)})
else:
blocks.append({"type": "text", "text": _redact(str(part), redact)})
return blocks
return [{"type": "text", "text": _redact(json.dumps(content), redact)}]
def _tool_calls_to_blocks(tool_calls: Any, redact: bool) -> List[Dict[str, Any]]:
"""Convert OpenAI tool_calls into Anthropic ``tool_use`` content blocks."""
blocks: List[Dict[str, Any]] = []
if not isinstance(tool_calls, list):
return blocks
for tc in tool_calls:
if not isinstance(tc, dict):
continue
fn = tc.get("function") or {}
name = fn.get("name") or tc.get("name") or "tool"
raw_args = fn.get("arguments")
if isinstance(raw_args, str):
try:
parsed = json.loads(raw_args) if raw_args.strip() else {}
except (json.JSONDecodeError, ValueError):
parsed = {"_raw": raw_args}
elif isinstance(raw_args, dict):
parsed = raw_args
else:
parsed = {}
if redact:
try:
parsed = json.loads(_redact(json.dumps(parsed), redact))
except (json.JSONDecodeError, ValueError):
logger.warning("Trace upload redacted tool arguments are not valid JSON; refusing upload")
raise TraceRedactionError(_REDACTION_BLOCKED_MESSAGE)
blocks.append({
"type": "tool_use",
"id": tc.get("id") or f"toolu_{uuid.uuid4().hex[:16]}",
"name": name,
"input": parsed,
})
return blocks
def build_trace_jsonl(
messages: List[Dict[str, Any]],
*,
session_id: str,
model: str = "",
cwd: str = "",
redact: bool = True,
) -> str:
"""Render Hermes conversation messages as Claude Code JSONL text.
Each non-system message becomes one JSONL line in the Claude Code
transcript shape the HF Agent Trace Viewer auto-detects:
* ``user`` / ``tool`` -> ``{"type": "user", "message": {...}}``
* ``assistant`` -> ``{"type": "assistant", "message": {...}}``
with ``content`` blocks (text + ``tool_use``).
Tool results are emitted as user turns carrying a ``tool_result``
block keyed by ``tool_call_id`` — the same way Claude Code records
them. Turns are linked via ``uuid`` / ``parentUuid``.
"""
lines: List[str] = []
parent: Optional[str] = None
base_ts = _now_iso()
git_branch = ""
try:
import subprocess
if cwd:
r = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=3, cwd=cwd,
)
if r.returncode == 0:
git_branch = r.stdout.strip()
except Exception:
git_branch = ""
def _common(turn_uuid: str) -> Dict[str, Any]:
return {
"parentUuid": parent,
"isSidechain": False,
"userType": "external",
"cwd": cwd or os.getcwd(),
"sessionId": session_id,
"version": _HERMES_VERSION,
"gitBranch": git_branch,
"uuid": turn_uuid,
"timestamp": base_ts,
}
for msg in messages:
role = msg.get("role")
if role == "system":
continue
turn_uuid = str(uuid.uuid4())
if role == "assistant":
blocks = _content_to_blocks(msg.get("content"), redact)
blocks.extend(_tool_calls_to_blocks(msg.get("tool_calls"), redact))
if not blocks:
blocks = [{"type": "text", "text": ""}]
entry = _common(turn_uuid)
entry["type"] = "assistant"
entry["message"] = {
"role": "assistant",
"model": model or "unknown",
"content": blocks,
}
lines.append(json.dumps(entry, ensure_ascii=False))
parent = turn_uuid
continue
if role == "tool":
tool_use_id = msg.get("tool_call_id") or msg.get("tool_name") or "tool"
result_content = _redact(
msg.get("content") if isinstance(msg.get("content"), str)
else json.dumps(msg.get("content")),
redact,
)
entry = _common(turn_uuid)
entry["type"] = "user"
entry["message"] = {
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": result_content,
}],
}
lines.append(json.dumps(entry, ensure_ascii=False))
parent = turn_uuid
continue
# Default: user (and any unknown role) -> user turn.
content = msg.get("content")
if isinstance(content, str):
message_content: Any = _redact(content, redact)
else:
message_content = _content_to_blocks(content, redact)
entry = _common(turn_uuid)
entry["type"] = "user"
entry["message"] = {"role": "user", "content": message_content}
lines.append(json.dumps(entry, ensure_ascii=False))
parent = turn_uuid
return "\n".join(lines) + ("\n" if lines else "")
# ---------------------------------------------------------------------------
# Upload
# ---------------------------------------------------------------------------
def _resolve_hf_token() -> Optional[str]:
"""Return the user's Hugging Face token from the usual env vars."""
for var in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACE_TOKEN"):
val = os.getenv(var)
if val and val.strip():
return val.strip()
return None
_NO_TOKEN_MESSAGE = (
"Can't upload — no Hugging Face token is available. To set it up:\n"
"\n"
"1. Create a token with WRITE access at https://huggingface.co/settings/tokens\n"
" (New token -> type \"Write\" -> copy it).\n"
"2. Add it to your environment as HF_TOKEN (e.g. in ~/.hermes/.env):\n"
" HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx\n"
"3. Run /upload-trace again (or `hermes trace upload`)."
)
def _do_upload(
jsonl: str,
*,
token: str,
session_id: str,
dataset_name: str = DEFAULT_DATASET_NAME,
private: bool = True,
) -> str:
"""Create (idempotently) the private dataset and push the trace file.
Returns a user-facing status string. Never raises.
"""
try:
from tools import lazy_deps
lazy_deps.ensure("tool.trace_upload", prompt=False)
except Exception:
# lazy-install unavailable/declined — fall through to the import,
# which surfaces the install hint below if the package is missing.
pass
try:
from huggingface_hub import HfApi
except ImportError:
return ("Hugging Face upload needs the `huggingface_hub` package "
"(`pip install huggingface_hub`).")
api = HfApi(token=token)
try:
who = api.whoami()
user = who.get("name") if isinstance(who, dict) else None
except Exception as e:
logger.warning("HF whoami failed: %s", e)
return ("Your Hugging Face token was rejected (whoami failed). "
"Make sure it has WRITE access and isn't expired.")
if not user:
return "Could not resolve your Hugging Face username from the token."
repo_id = f"{user}/{dataset_name}"
try:
api.create_repo(
repo_id=repo_id, repo_type="dataset", private=private, exist_ok=True,
)
except Exception as e:
logger.warning("HF create_repo failed for %s: %s", repo_id, e)
return f"Could not create/access dataset {repo_id}: {e}"
path_in_repo = f"sessions/{session_id}.jsonl"
try:
api.upload_file(
path_or_fileobj=jsonl.encode("utf-8"),
path_in_repo=path_in_repo,
repo_id=repo_id,
repo_type="dataset",
commit_message=f"add session trace {session_id}",
)
except Exception as e:
logger.warning("HF upload_file failed for %s: %s", repo_id, e)
return f"Upload to Hugging Face failed: {e}"
return (f"Uploaded -> https://huggingface.co/datasets/{repo_id}/blob/main/{path_in_repo}\n"
f"View in the trace viewer: https://huggingface.co/datasets/{repo_id}")
def load_session_messages(
session_id: str, db_path=None
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""Load a session's conversation + metadata from the SQLite store.
Returns ``(messages, meta)``. ``meta`` is ``{}`` when the session row is
missing (messages may still be present for a live, untitled session).
"""
from hermes_state import SessionDB
db = SessionDB(db_path=db_path) if db_path else SessionDB()
resolved = db.resolve_session_id(session_id) or session_id
meta = db.get_session(resolved) or {}
messages = db.get_messages_as_conversation(resolved)
return messages, meta
def upload_session_trace(
session_id: str,
*,
model: str = "",
cwd: str = "",
redact: bool = True,
private: bool = True,
dataset_name: str = DEFAULT_DATASET_NAME,
db_path=None,
token: Optional[str] = None,
) -> str:
"""Top-level entry point used by the CLI/gateway/subcommand.
Loads the session, converts it to Claude Code JSONL, and uploads it to
the user's private ``{user}/hermes-traces`` dataset. Returns a
user-facing status string and never raises.
"""
if not session_id:
return "No active session to upload."
token = token or _resolve_hf_token()
if not token:
return _NO_TOKEN_MESSAGE
try:
messages, meta = load_session_messages(session_id, db_path=db_path)
except Exception as e:
logger.warning("Failed to load session %s for trace upload: %s", session_id, e)
return f"Could not load session {session_id}: {e}"
if not messages:
return "No transcript to upload for this session yet."
resolved_model = model or meta.get("model") or ""
try:
jsonl = build_trace_jsonl(
messages,
session_id=session_id,
model=resolved_model,
cwd=cwd,
redact=redact,
)
except TraceRedactionError:
return _REDACTION_BLOCKED_MESSAGE
if not jsonl.strip():
return "No transcript content to upload for this session."
return _do_upload(
jsonl,
token=token,
session_id=session_id,
dataset_name=dataset_name,
private=private,
)

View File

@@ -9,6 +9,7 @@ which has provider-specific conditionals for max_tokens defaults,
reasoning configuration, temperature handling, and extra_body assembly.
"""
import copy
from typing import Any, Dict
from agent.lmstudio_reasoning import resolve_lmstudio_effort
@@ -194,63 +195,27 @@ class ChatCompletionsTransport(ProviderTransport):
if not needs_sanitize:
return messages
sanitized = list(messages)
for msg_idx, msg in enumerate(messages):
sanitized = copy.deepcopy(messages)
for msg in sanitized:
if not isinstance(msg, dict):
continue
copied_msg: dict[str, Any] | None = None
def mutable_msg() -> dict[str, Any]:
nonlocal copied_msg
if copied_msg is None:
copied_msg = dict(msg)
sanitized[msg_idx] = copied_msg
return copied_msg
if (
"codex_reasoning_items" in msg
or "codex_message_items" in msg
or "tool_name" in msg
or "timestamp" in msg # #47868 — leak into strict providers
):
out_msg = mutable_msg()
out_msg.pop("codex_reasoning_items", None)
out_msg.pop("codex_message_items", None)
out_msg.pop("tool_name", None)
out_msg.pop("timestamp", None) # #47868 — leak into strict providers
msg.pop("codex_reasoning_items", None)
msg.pop("codex_message_items", None)
msg.pop("tool_name", None)
msg.pop("timestamp", None) # #47868 — leak into strict providers
# Drop all Hermes-internal scaffolding markers (``_``-prefixed).
# OpenAI's message schema has no ``_``-prefixed fields, so this
# is safe and future-proofs against new markers being added.
internal_keys = [k for k in msg if isinstance(k, str) and k.startswith("_")]
if internal_keys:
out_msg = mutable_msg()
for key in internal_keys:
out_msg.pop(key, None)
for key in [k for k in msg if isinstance(k, str) and k.startswith("_")]:
msg.pop(key, None)
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list):
copied_tool_calls: list[Any] | None = None
for tc_idx, tc in enumerate(tool_calls):
for tc in tool_calls:
if isinstance(tc, dict):
should_copy_tc = (
"call_id" in tc
or "response_item_id" in tc
or (strip_extra_content and "extra_content" in tc)
)
if should_copy_tc:
if copied_tool_calls is None:
copied_tool_calls = list(tool_calls)
copied_tc = dict(tc)
copied_tc.pop("call_id", None)
copied_tc.pop("response_item_id", None)
if strip_extra_content:
copied_tc.pop("extra_content", None)
copied_tool_calls[tc_idx] = copied_tc
if copied_tool_calls is not None:
mutable_msg()["tool_calls"] = copied_tool_calls
tc.pop("call_id", None)
tc.pop("response_item_id", None)
if strip_extra_content:
tc.pop("extra_content", None)
return sanitized
def convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:

View File

@@ -75,7 +75,6 @@ class TurnResult:
token_usage_last: Optional[dict[str, Any]] = None
token_usage_total: Optional[dict[str, Any]] = None
model_context_window: Optional[int] = None
compacted: bool = False
# Hint to the caller that the underlying codex subprocess is likely
# wedged (turn-level timeout fired, post-tool watchdog tripped, or
# token-refresh failure killed the child). The caller should retire
@@ -506,7 +505,6 @@ class CodexAppServerSession:
if pending is None:
break
_apply_token_usage_notification(result, pending)
_apply_compaction_notification(result, pending)
self._track_pending_file_change(pending)
proj = projector.project(pending)
if proj.messages:
@@ -543,7 +541,6 @@ class CodexAppServerSession:
logger.debug("on_event callback raised", exc_info=True)
_apply_token_usage_notification(result, note)
_apply_compaction_notification(result, note)
# Track in-progress fileChange items so the approval bridge
# can surface a real change summary when codex requests
@@ -635,154 +632,6 @@ class CodexAppServerSession:
return result
def compact_thread(
self,
*,
turn_timeout: float = 600.0,
notification_poll_timeout: float = 0.25,
) -> TurnResult:
"""Trigger Codex-native history compaction for the current thread.
`thread/compact/start` returns immediately; the actual compaction
progress streams through the same turn/item notifications as a normal
turn. We wait for the matching `turn/completed` so callers can treat a
successful return as a completed compaction boundary.
"""
result = TurnResult()
try:
self.ensure_started()
except (CodexAppServerError, TimeoutError) as exc:
result.error = self._format_error_with_stderr(
"codex app-server startup failed", exc
)
result.should_retire = True
return result
assert self._client is not None and self._thread_id is not None
result.thread_id = self._thread_id
self._interrupt_event.clear()
projector = CodexEventProjector()
try:
self._client.request(
"thread/compact/start",
{"threadId": self._thread_id},
timeout=10,
)
except CodexAppServerError as exc:
stderr_blob = "\n".join(self._client.stderr_tail(40))
hint = _classify_oauth_failure(exc.message, stderr_blob)
if hint is not None:
result.error = hint
result.should_retire = True
else:
result.error = self._format_error_with_stderr(
"thread/compact/start failed", exc
)
return result
except TimeoutError as exc:
stderr_blob = "\n".join(self._client.stderr_tail(40))
hint = _classify_oauth_failure(stderr_blob)
result.error = hint or self._format_error_with_stderr(
"thread/compact/start timed out", exc
)
result.should_retire = True
return result
deadline = time.monotonic() + turn_timeout
turn_complete = False
while time.monotonic() < deadline and not turn_complete:
if self._interrupt_event.is_set():
self._issue_interrupt(result.turn_id)
result.interrupted = True
break
if not self._client.is_alive():
stderr_blob = "\n".join(self._client.stderr_tail(60))
hint = _classify_oauth_failure(stderr_blob)
if hint is not None:
result.error = hint
else:
result.error = self._format_error_with_stderr(
"codex app-server subprocess exited unexpectedly",
tail_lines=20,
)
result.should_retire = True
break
sreq = self._client.take_server_request(timeout=0)
if sreq is not None:
self._handle_server_request(sreq)
continue
note = self._client.take_notification(
timeout=notification_poll_timeout
)
if note is None:
continue
method = note.get("method", "")
if self._on_event is not None:
try:
self._on_event(note)
except Exception: # pragma: no cover - display callback
logger.debug("on_event callback raised", exc_info=True)
_apply_token_usage_notification(result, note)
_apply_compaction_notification(result, note)
self._track_pending_file_change(note)
projection = projector.project(note)
if projection.messages:
result.projected_messages.extend(projection.messages)
if projection.is_tool_iteration:
result.tool_iterations += 1
if projection.final_text is not None:
result.final_text = projection.final_text
if _has_turn_aborted_marker(projection.final_text):
turn_complete = True
result.interrupted = True
result.error = (
result.error or "codex reported turn_aborted"
)
if method == "turn/started":
turn_obj = (note.get("params") or {}).get("turn") or {}
result.turn_id = turn_obj.get("id") or result.turn_id
elif method == "turn/completed":
turn_complete = True
turn_obj = (note.get("params") or {}).get("turn") or {}
result.turn_id = turn_obj.get("id") or result.turn_id
turn_status = turn_obj.get("status")
if turn_status and turn_status not in {"completed", "interrupted"}:
err_obj = turn_obj.get("error")
if err_obj:
err_msg = _format_responses_error(err_obj, str(turn_status))
stderr_blob = "\n".join(
self._client.stderr_tail(40)
)
hint = _classify_oauth_failure(err_msg, stderr_blob)
if hint is not None:
result.error = hint
result.should_retire = True
else:
result.error = self._format_error_with_stderr(
f"compact turn ended status={turn_status}",
err_msg,
)
if not turn_complete and not result.interrupted:
self._issue_interrupt(result.turn_id)
result.interrupted = True
if not result.error:
result.error = self._format_error_with_stderr(
f"compact turn timed out after {turn_timeout}s"
)
result.should_retire = True
return result
# ---------- internals ----------
def _issue_interrupt(self, turn_id: Optional[str]) -> None:
@@ -996,38 +845,6 @@ def _apply_token_usage_notification(result: TurnResult, note: dict) -> None:
result.model_context_window = window
def _apply_compaction_notification(result: TurnResult, note: dict) -> None:
"""Capture Codex-native context compaction boundaries.
Recent app-server builds expose compaction as a ContextCompaction item.
Older builds also emit the deprecated thread/compacted notification. Both
mean the underlying Codex thread history has been compacted.
"""
if not isinstance(note, dict):
return
method = note.get("method") or ""
params = note.get("params") or {}
if not isinstance(params, dict):
return
if method == "thread/compacted":
result.compacted = True
result.thread_id = params.get("threadId") or result.thread_id
result.turn_id = params.get("turnId") or result.turn_id
return
if method not in {"item/started", "item/completed"}:
return
item = params.get("item") or {}
if not isinstance(item, dict) or item.get("type") != "contextCompaction":
return
result.compacted = True
result.thread_id = params.get("threadId") or result.thread_id
result.turn_id = params.get("turnId") or result.turn_id
def _approval_choice_to_codex_decision(choice: str) -> str:
"""Map Hermes approval choices onto codex's CommandExecutionApprovalDecision
/ FileChangeApprovalDecision wire values.

View File

@@ -185,19 +185,9 @@ def build_turn_context(
# name and leaves the snapshot untouched on no-change).
try:
if not getattr(agent, "_skip_mcp_refresh", False):
# Import-cost gate: ``tools.mcp_tool`` pulls in the whole ``mcp``
# package (~0.4s measured) even when the user has zero MCP servers
# configured. MCP tools can only be registered by code that has
# already imported ``tools.mcp_tool`` (discovery, /reload-mcp,
# late-binding refresh) — so if it isn't in sys.modules yet, there
# is nothing to refresh and the import can be skipped outright.
# This keeps the no-MCP first turn off the heavy import path
# without changing behavior for MCP users.
import sys as _sys
if "tools.mcp_tool" in _sys.modules:
from tools.mcp_tool import has_registered_mcp_tools, refresh_agent_mcp_tools
if has_registered_mcp_tools():
refresh_agent_mcp_tools(agent, quiet_mode=True)
from tools.mcp_tool import has_registered_mcp_tools, refresh_agent_mcp_tools
if has_registered_mcp_tools():
refresh_agent_mcp_tools(agent, quiet_mode=True)
except Exception:
logger.debug("between-turns MCP tool refresh skipped", exc_info=True)
@@ -369,20 +359,6 @@ def build_turn_context(
lambda _tokens: False,
)
_preflight_deferred = _defer_preflight(_preflight_tokens)
# Codex app-server threads are compacted by the codex agent itself;
# Hermes only initiates compaction in "hermes" mode (#36801).
_codex_native_auto = (
getattr(agent, "api_mode", None) == "codex_app_server"
and str(
getattr(
agent,
"codex_app_server_auto_compaction",
"native",
)
or "native"
).lower()
in {"native", "off"}
)
if not _preflight_deferred:
_last = _compressor.last_prompt_tokens
@@ -411,12 +387,6 @@ def build_turn_context(
int(_compression_cooldown.get("remaining_seconds", 0.0)),
agent.session_id or "none",
)
elif _codex_native_auto:
logger.info(
"Skipping Hermes preflight compression for codex app-server "
"(mode=%s); Hermes will not start thread compaction here.",
getattr(agent, "codex_app_server_auto_compaction", "native"),
)
elif _compressor.should_compress(_preflight_tokens):
logger.info(
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",

View File

@@ -103,54 +103,6 @@ _UTC_NOW = lambda: datetime.now(timezone.utc)
# Official docs snapshot entries. Models whose published pricing and cache
# semantics are stable enough to encode exactly.
_OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
# ── OpenAI GPT-5.6 series (Sol/Terra/Luna) ───────────────────────────
# Announced in limited preview 2026-06-26; GA 2026-07-09 at the same
# rates (Sol $5/$30, Terra $2.50/$15, Luna $1/$6 per 1M in/out). Cache
# writes are billed at 1.25x the uncached input rate; cache reads get the
# standard 90% discount (0.10x input, confirmed: Sol $0.50/M cached).
# Note: "Sol Fast mode" ($12.5/$75, up to 750 tok/s via Cerebras) is a
# separate serving tier, not covered by these entries. The "-pro"
# variants (high-effort modes, GA alongside base tiers) bill at the
# SAME per-token rates and are aliased onto these entries below the
# dict (they cost more per task by consuming more tokens, not by a
# higher rate — verified against OpenRouter's live pricing 2026-07-09).
# Source: https://openai.com/index/previewing-gpt-5-6-sol/
(
"openai",
"gpt-5.6-sol",
): PricingEntry(
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("30.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
pricing_version="openai-gpt-5.6-2026-07",
),
(
"openai",
"gpt-5.6-terra",
): PricingEntry(
input_cost_per_million=Decimal("2.50"),
output_cost_per_million=Decimal("15.00"),
cache_read_cost_per_million=Decimal("0.25"),
cache_write_cost_per_million=Decimal("3.125"),
source="official_docs_snapshot",
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
pricing_version="openai-gpt-5.6-2026-07",
),
(
"openai",
"gpt-5.6-luna",
): PricingEntry(
input_cost_per_million=Decimal("1.00"),
output_cost_per_million=Decimal("6.00"),
cache_read_cost_per_million=Decimal("0.10"),
cache_write_cost_per_million=Decimal("1.25"),
source="official_docs_snapshot",
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
pricing_version="openai-gpt-5.6-2026-07",
),
# ── Anthropic Claude 4.8 ─────────────────────────────────────────────
# Same $5/$25 base pricing as 4.6/4.7. Fast-mode variant is a separate
# model ID with 2x premium (vs the 6x premium on older Opus generations).
@@ -611,15 +563,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
),
}
# GPT-5.6 "-pro" high-effort variants bill at the same per-token rates as
# their base tiers (more tokens per task, not a higher rate). Alias them
# onto the base entries so the snapshot stays single-source.
for _base_56 in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"):
_OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-pro")] = _OFFICIAL_DOCS_PRICING[
("openai", _base_56)
]
del _base_56
def _to_decimal(value: Any) -> Optional[Decimal]:
if value is None:
@@ -659,11 +602,7 @@ def resolve_billing_route(
return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api")
if provider_name == "anthropic":
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
# "openai-api" is the picker/registry slug for direct api.openai.com; it
# bills identically to bare "openai", so normalize it here — otherwise the
# ("openai", <model>) _OFFICIAL_DOCS_PRICING keys are unreachable from the
# openai-api provider path.
if provider_name in {"openai", "openai-api"}:
if provider_name == "openai":
return BillingRoute(provider="openai", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name in {"minimax", "minimax-cn"}:
return BillingRoute(provider=provider_name, model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")

View File

@@ -52,33 +52,7 @@ On failure (either capability)::
from __future__ import annotations
import abc
import os
from typing import Any, Dict, List, Optional
def get_provider_env(name: str) -> str:
"""Config-aware env lookup for web providers.
Resolves *name* via :func:`hermes_cli.config.get_env_value` (checks
``os.environ`` first, then ``~/.hermes/.env``) so credentials set
through Hermes' config layer are visible even when they were never
exported into the process environment — gateway sessions, delegate
children, and subprocess agent runs (issue #40190). Falls back to a
bare ``os.getenv`` when the config module is unavailable (stripped
installs, early import contexts).
Returns the stripped value, or ``""`` when unset.
"""
val: Optional[str] = None
try:
from hermes_cli.config import get_env_value
val = get_env_value(name)
except Exception: # noqa: BLE001 — config layer optional here
val = None
if val is None:
val = os.getenv(name, "")
return (val or "").strip()
from typing import Any, Dict, List
# ---------------------------------------------------------------------------

View File

@@ -219,65 +219,6 @@ def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearc
return None
def _disabled_web_plugin_for(configured: Optional[str] = None, *, capability: Optional[str] = None) -> Optional[str]:
"""Return the plugin key of a *disabled* bundled web plugin that would
have provided the configured backend, or None.
When a user sets ``web.extract_backend: firecrawl`` (or the search
equivalent) but also lists ``web-firecrawl`` in ``plugins.disabled``,
the provider never registers and the dispatcher would otherwise emit a
misleading "No web extract provider configured. Set web.extract_backend
to ..." error — even though the backend IS configured correctly. The
real fix is to re-enable the plugin. This helper detects that case so
the dispatcher can point the user at the actual cause (issue #40190
follow-up: pi314's disabled-plugin symptom).
Pass ``capability`` ("search" | "extract") to resolve the configured
name straight from ``config.yaml`` (``web.<capability>_backend`` →
``web.backend``). This is more reliable than the resolved backend the
dispatcher fell back to, since a disabled provider fails the
``_is_backend_available`` gate and the dispatcher silently drops to
the shared default. An explicit ``configured`` name still wins when
given.
Matching is by convention: bundled web plugins live under the
``web/<vendor>`` key with the provider ``name`` differing only in
hyphen/underscore (``brave-free`` provider ⇄ ``web/brave_free`` key,
``firecrawl`` ⇄ ``web/firecrawl``). We normalize both sides before
comparing so every bundled provider is covered without hardcoding a
per-vendor table.
"""
def _norm(s: str) -> str:
return s.strip().lower().replace("-", "_")
if not configured and capability in ("search", "extract"):
configured = (
_read_config_key("web", f"{capability}_backend")
or _read_config_key("web", "backend")
)
if not configured:
return None
want = _norm(configured)
try:
from hermes_cli.plugins import get_plugin_manager
pm = get_plugin_manager()
for key, loaded in pm._plugins.items():
if not isinstance(key, str) or not key.startswith("web/"):
continue
if loaded.enabled:
continue
if loaded.error != "disabled via config":
continue
vendor = key.split("/", 1)[1]
if _norm(vendor) == want:
return key
except Exception as exc: # noqa: BLE001 — diagnostics are best-effort
logger.debug("disabled-web-plugin lookup failed: %s", exc)
return None
def get_active_search_provider() -> Optional[WebSearchProvider]:
"""Resolve the currently-active web search provider.

View File

@@ -1,6 +1,6 @@
//! Bootstrap orchestration.
//!
//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.ts`.
//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`.
//! Drives install.ps1 / install.sh stage-by-stage, emits progress events
//! over the Tauri `bootstrap` channel, writes a forensic log to
//! HERMES_HOME/logs/bootstrap-<timestamp>.log.

View File

@@ -1,6 +1,6 @@
//! Event types streamed from Rust → React.
//!
//! These mirror `apps/desktop/electron/bootstrap-runner.ts`'s event shape
//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape
//! 1:1 so the React installer code can be roughly identical to the Electron
//! install-overlay we'll replace.
//!

View File

@@ -8,7 +8,7 @@
//! 3. Network: download from GitHub raw at a pinned commit or branch.
//! Commit pins are immutable; branch pins are HEAD-tracking.
//!
//! Mirrors `apps/desktop/electron/bootstrap-runner.ts`'s `resolveInstallScript`,
//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`,
//! but the dev-checkout resolution is driven by an env var rather than the
//! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant
//! to live OUTSIDE any repo checkout.
@@ -64,7 +64,7 @@ impl ScriptKind {
}
/// Validates a string looks like a git SHA (7+ hex chars). Mirrors
/// `STAMP_COMMIT_RE` from bootstrap-runner.ts.
/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs.
fn is_valid_commit(s: &str) -> bool {
let len = s.len();
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())

View File

@@ -150,7 +150,7 @@ fn repair_macos_installer_helper(path: &Path) {
fn repair_macos_installer_helper(_path: &Path) {}
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
/// the Electron app also checks). Per main.ts:
/// the Electron app also checks). Per main.cjs:
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
/// this is a probe helper, not a definitive path.

View File

@@ -1,6 +1,6 @@
//! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh.
//!
//! Port of `spawnPowerShell` from bootstrap-runner.ts, with the same
//! Port of `spawnPowerShell` from bootstrap-runner.cjs, with the same
//! line-buffered stdout/stderr streaming + cancellation semantics.
//!
//! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File <script>`.
@@ -19,7 +19,7 @@ pub struct StreamSink {
pub on_stderr_line: Box<dyn Fn(&str) + Send + Sync>,
}
/// Outcome of a script invocation. Mirrors bootstrap-runner.ts's
/// Outcome of a script invocation. Mirrors bootstrap-runner.cjs's
/// `{stdout, stderr, code, signal, killed}` shape.
#[derive(Debug)]
pub struct ScriptResult {
@@ -258,7 +258,7 @@ fn interpreter_label() -> String {
/// Parses the LAST line of stdout that looks like a JSON object matching
/// the install.ps1 stage-result contract: `{ok: bool, stage: string, ...}`.
///
/// Mirrors `parseStageResult` from bootstrap-runner.ts. install.ps1 may
/// Mirrors `parseStageResult` from bootstrap-runner.cjs. install.ps1 may
/// print info/banner lines before the result frame; we scan from the end.
pub fn parse_stage_result(stdout: &str) -> Option<crate::events::StageResultPayload> {
for line in stdout.lines().rev() {

View File

@@ -85,7 +85,7 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig
### How it works
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.ts` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.ts`.
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.cjs` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
### Verification

View File

@@ -17,9 +17,8 @@
* Build the canonical headless backend argv (always `serve`).
* @param {string} [profile] optional Hermes profile to pin via `--profile`.
*/
export function serveBackendArgs(profile?: string) {
function serveBackendArgs(profile) {
const head = profile ? ['--profile', profile] : []
return [...head, 'serve', '--host', '127.0.0.1', '--port', '0']
}
@@ -29,13 +28,9 @@ export function serveBackendArgs(profile?: string) {
* `-m hermes_cli.main` and any `--profile <name>`). Returns a copy; if there is
* no `serve` token the argv is returned unchanged.
*/
export function dashboardFallbackArgs(args) {
function dashboardFallbackArgs(args) {
const i = args.indexOf('serve')
if (i === -1) {
return args.slice()
}
if (i === -1) return args.slice()
return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)]
}
@@ -45,6 +40,12 @@ export function dashboardFallbackArgs(args) {
* specifically so the substring "server" (e.g. "start_server", "web server")
* never produces a false positive.
*/
export function sourceDeclaresServe(dashboardPySource) {
function sourceDeclaresServe(dashboardPySource) {
return /add_parser\(\s*["']serve["']/.test(String(dashboardPySource || ''))
}
module.exports = {
serveBackendArgs,
dashboardFallbackArgs,
sourceDeclaresServe
}

View File

@@ -1,9 +1,9 @@
'use strict'
import assert from 'node:assert/strict'
import test from 'node:test'
const test = require('node:test')
const assert = require('node:assert/strict')
import { dashboardFallbackArgs, serveBackendArgs, sourceDeclaresServe } from './backend-command'
const { serveBackendArgs, dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs')
test('serveBackendArgs builds a headless serve invocation', () => {
assert.deepEqual(serveBackendArgs(), ['serve', '--host', '127.0.0.1', '--port', '0'])
@@ -61,6 +61,5 @@ test('sourceDeclaresServe does not false-positive on the substring "server"', ()
dashboard_parser = subparsers.add_parser("dashboard", help="Start the web UI dashboard")
from hermes_cli.web_server import start_server # web server
`
assert.equal(sourceDeclaresServe(oldSource), false)
})

View File

@@ -1,4 +1,4 @@
import path from 'node:path'
const path = require('node:path')
// Match the POSIX fallback surface used by the Python terminal environment.
// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
@@ -23,16 +23,12 @@ function pathModuleForPlatform(platform = process.platform) {
}
function pathEnvKey(env = process.env, platform = process.platform) {
if (platform !== 'win32') {
return 'PATH'
}
if (platform !== 'win32') return 'PATH'
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
}
function currentPathValue(env = process.env, platform = process.platform) {
const key = pathEnvKey(env, platform)
return env?.[key] || ''
}
@@ -41,15 +37,10 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
const ordered = []
for (const entry of entries) {
if (!entry) {
continue
}
if (!entry) continue
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
for (const part of parts) {
if (!part || seen.has(part)) {
continue
}
if (!part || seen.has(part)) continue
seen.add(part)
ordered.push(part)
}
@@ -64,7 +55,7 @@ function buildDesktopBackendPath({
currentPath = '',
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
}: any = {}) {
} = {}) {
const delimiter = delimiterForPlatform(platform)
const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
@@ -73,17 +64,13 @@ function buildDesktopBackendPath({
return appendUniquePathEntries([hermesNodeBin, venvBin, currentPath, saneEntries], { delimiter })
}
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) }: any = {}) {
if (!hermesHome) {
return hermesHome
}
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) {
if (!hermesHome) return hermesHome
const resolved = pathModule.resolve(String(hermesHome))
const parent = pathModule.dirname(resolved)
if (pathModule.basename(parent).toLowerCase() === 'profiles') {
return pathModule.dirname(parent)
}
return resolved
}
@@ -94,7 +81,7 @@ function buildDesktopBackendEnv({
currentEnv = process.env,
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
}: any = {}) {
} = {}) {
const delimiter = delimiterForPlatform(platform)
const currentPythonPath = currentEnv?.PYTHONPATH || ''
const key = pathEnvKey(currentEnv, platform)
@@ -111,12 +98,12 @@ function buildDesktopBackendEnv({
}
}
export {
module.exports = {
POSIX_SANE_PATH_ENTRIES,
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
delimiterForPlatform,
normalizeHermesHomeRoot,
pathEnvKey,
POSIX_SANE_PATH_ENTRIES
pathEnvKey
}

View File

@@ -1,15 +1,15 @@
import assert from 'node:assert/strict'
import path from 'node:path'
import test from 'node:test'
const test = require('node:test')
const assert = require('node:assert/strict')
const path = require('node:path')
import {
const {
POSIX_SANE_PATH_ENTRIES,
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
normalizeHermesHomeRoot,
pathEnvKey,
POSIX_SANE_PATH_ENTRIES
} from './backend-env'
pathEnvKey
} = require('./backend-env.cjs')
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
const result = buildDesktopBackendPath({

View File

@@ -1,8 +1,8 @@
/**
* backend-probes.ts
* backend-probes.cjs
*
* Cheap "does this candidate backend actually work" checks used by
* resolveHermesBackend (main.ts). The resolver walks a ladder of
* resolveHermesBackend (main.cjs). The resolver walks a ladder of
* candidates -- bootstrap marker, `hermes` on PATH, system Python with
* hermes_cli installed -- and historically returned the first candidate
* whose binary existed on disk. That assumption breaks when a user has
@@ -27,12 +27,12 @@
* via the caller's catch block if it chooses)
* - any throw -> false (never propagate -- resolver wants a boolean)
*
* Kept in a standalone ts module so it can be unit-tested with
* Kept in a standalone cjs module so it can be unit-tested with
* `node --test` without dragging in the electron runtime (same pattern
* as bootstrap-platform.ts and hardening.ts).
* as bootstrap-platform.cjs and hardening.cjs).
*/
import { execFileSync } from 'node:child_process'
const { execFileSync } = require('node:child_process')
const PROBE_TIMEOUT_MS = 5000
@@ -44,7 +44,7 @@ const PROBE_TIMEOUT_MS = 5000
* @returns {string}
*/
function hermesRuntimeImportProbe() {
return 'import yaml; import dotenv; import hermes_cli.config'
return 'import yaml; import hermes_cli.config'
}
/**
@@ -62,14 +62,12 @@ function hermesRuntimeImportProbe() {
* through PYTHONPATH but lack PyYAML, then die on the first real CLI import.
*
* @param {string} pythonPath - Absolute path to a python.exe / python.
* @param {object} [opts]
* @param {object} [opts.env] - Additional environment for the probe.
* @returns {boolean}
*/
function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, string> } = {}) {
if (!pythonPath) {
return false
}
function canImportHermesCli(pythonPath, opts = {}) {
if (!pythonPath) return false
try {
execFileSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
env: { ...process.env, ...(opts.env || {}) },
@@ -77,7 +75,6 @@ function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, str
timeout: PROBE_TIMEOUT_MS,
windowsHide: true
})
return true
} catch {
return false
@@ -98,29 +95,31 @@ function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, str
*
* @param {string} hermesCommand - Resolved absolute path to a hermes
* executable (or an interpreter+script wrapper).
* @param {object} [opts]
* @param {boolean} [opts.shell] - Whether to run through a shell. For
* .cmd/.bat shims on Windows execFileSync needs shell:true to find
* the cmd interpreter; mirrors the same flag isCommandScript() drives
* in resolveHermesBackend.
* @returns {boolean}
*/
function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
if (!hermesCommand) {
return false
}
function verifyHermesCli(hermesCommand, opts = {}) {
if (!hermesCommand) return false
try {
execFileSync(hermesCommand, ['--version'], {
stdio: 'ignore',
timeout: PROBE_TIMEOUT_MS,
shell: Boolean(opts?.shell),
shell: Boolean(opts.shell),
windowsHide: true
})
return true
} catch {
return false
}
}
export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, verifyHermesCli }
module.exports = {
canImportHermesCli,
hermesRuntimeImportProbe,
verifyHermesCli,
PROBE_TIMEOUT_MS
}

View File

@@ -1,17 +1,17 @@
/**
* Tests for electron/backend-probes.ts.
* Tests for electron/backend-probes.cjs.
*
* Run with: node --test electron/backend-probes.test.ts
* Run with: node --test electron/backend-probes.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*/
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
import { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } from './backend-probes'
const { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } = require('./backend-probes.cjs')
// Resolve the host's own Node binary -- guaranteed to be on disk and
// runnable. We use it as both a stand-in for "a python that doesn't
@@ -43,10 +43,6 @@ test('canImportHermesCli returns false when binary does not exist', () => {
test('hermes runtime import probe checks config dependencies', () => {
const probe = hermesRuntimeImportProbe()
assert.match(probe, /\bimport yaml\b/)
// dotenv is the first third-party import on the CLI boot path
// (hermes_cli/env_loader.py); a mid-update venv missing python-dotenv
// passed the old probe and produced an unrecoverable boot loop.
assert.match(probe, /\bimport dotenv\b/)
assert.match(probe, /\bimport hermes_cli\.config\b/)
})
@@ -67,7 +63,6 @@ test('verifyHermesCli returns true when --version exits 0', () => {
// verifyHermesCli only cares about the exit code.
const scriptPath = path.join(os.tmpdir(), `hermes-probes-ok-${Date.now()}-${process.pid}.cjs`)
fs.writeFileSync(scriptPath, 'process.exit(0)\n')
try {
// Use node as the launcher and our script as the "command". Pass
// shell:false (default) -- node is a real binary, no shim.

View File

@@ -1,9 +1,6 @@
import fs from 'node:fs'
const fs = require('node:fs')
// `hermes serve` announces HERMES_BACKEND_READY; the legacy `hermes dashboard`
// backend announces HERMES_DASHBOARD_READY. Accept either so the desktop spawn
// works against both the headless backend and old/dashboard runtimes.
const _READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/m
const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
// The announcement clock starts the instant the backend process is spawned —
// before uvicorn binds its socket. On a cold install the child must first
@@ -26,17 +23,15 @@ const MIN_PORT_ANNOUNCE_TIMEOUT_MS = 45_000
*/
function resolvePortAnnounceTimeoutMs(env = process.env) {
const parsed = Number(env.HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS)
if (Number.isFinite(parsed) && parsed > 0) {
return Math.max(MIN_PORT_ANNOUNCE_TIMEOUT_MS, Math.round(parsed))
}
return DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS
}
/**
* Watch a child process's stdout for the `HERMES_(BACKEND|DASHBOARD)_READY
* port=<N>` line that web_server.py prints after uvicorn binds its socket.
* Watch a child process's stdout for the `HERMES_DASHBOARD_READY port=<N>`
* line that web_server.py prints after uvicorn binds its socket.
*
* Returns the parsed port. Rejects if:
* - the child exits before emitting the line
@@ -57,9 +52,7 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
let done = false
function cleanup() {
if (done) {
return
}
if (done) return
done = true
clearTimeout(timer)
child.stdout.off('data', onData)
@@ -70,16 +63,13 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
function onData(chunk) {
buf += chunk.toString()
let nl
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl)
buf = buf.slice(nl + 1)
const m = line.match(_READY_RE)
if (m) {
cleanup()
resolve(parseInt(m[1], 10))
return
}
}
@@ -106,15 +96,11 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
})
}
function readDashboardReadyFile(readyFile: fs.PathOrFileDescriptor) {
if (!readyFile) {
return null
}
function readDashboardReadyFile(readyFile) {
if (!readyFile) return null
try {
const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8'))
const port = Number(parsed?.port)
return Number.isInteger(port) && port > 0 ? port : null
} catch {
return null
@@ -127,22 +113,16 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
let interval = null
function cleanup() {
if (done) {
return
}
if (done) return
done = true
clearTimeout(timer)
if (interval) {
clearInterval(interval)
}
if (interval) clearInterval(interval)
child.off('exit', onExit)
child.off('error', onError)
}
function check() {
const port = readDashboardReadyFile(readyFile)
if (port) {
cleanup()
resolve(port)
@@ -167,36 +147,25 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
child.on('exit', onExit)
child.on('error', onError)
interval = setInterval(check, 50)
if (typeof interval.unref === 'function') {
interval.unref()
}
if (typeof interval.unref === 'function') interval.unref()
check()
})
}
function waitForDashboardPortAnnouncement(
child,
options: {
readyFile?: fs.PathOrFileDescriptor
timeoutMs?: number
} = {}
) {
function waitForDashboardPortAnnouncement(child, options = {}) {
const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs()
if (options.readyFile) {
return waitForDashboardReadyFile(options.readyFile, child, timeoutMs)
}
return waitForDashboardPort(child, timeoutMs)
}
export {
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
module.exports = {
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile
waitForDashboardReadyFile,
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS
}

View File

@@ -1,7 +1,7 @@
/**
* Tests for electron/backend-ready.ts.
* Tests for electron/backend-ready.cjs.
*
* Run with: node --test electron/backend-ready.test.ts
* Run with: node --test electron/backend-ready.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* Covers the cold-start port-announcement deadline (issue #50209): the clock
@@ -11,34 +11,29 @@
* HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS, clamped to a 45s floor.
*/
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
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')
import {
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
const {
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile
} from './backend-ready'
type FakeChildProcess = EventEmitter & {
stdout: EventEmitter
}
waitForDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS
} = require('./backend-ready.cjs')
// A minimal stand-in for a spawned child process: an EventEmitter with a
// stdout EventEmitter, matching the surface waitForDashboardPort consumes
// (child.stdout.on('data'), child.on('exit'|'error') + the .off() teardown).
function makeFakeChild(): FakeChildProcess {
const child = new EventEmitter() as FakeChildProcess
function makeFakeChild() {
const child = new EventEmitter()
child.stdout = new EventEmitter()
return child
}
@@ -91,13 +86,6 @@ test('resolves with the announced port', async () => {
assert.equal(await p, 54321)
})
test('resolves with a HERMES_BACKEND_READY port (headless `serve`)', async () => {
const child = makeFakeChild()
const p = waitForDashboardPort(child, 1000)
child.stdout.emit('data', 'HERMES_BACKEND_READY port=43210\n')
assert.equal(await p, 43210)
})
test('parses the port even when the line arrives split across chunks', async () => {
const child = makeFakeChild()
const p = waitForDashboardPort(child, 1000)
@@ -144,7 +132,6 @@ test('a late announcement after timeout does not throw (listeners torn down)', a
function mkTmpReadyFile() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-'))
return {
dir,
file: path.join(dir, 'ready.json'),
@@ -154,7 +141,6 @@ function mkTmpReadyFile() {
test('readDashboardReadyFile returns a valid port from JSON', () => {
const tmp = mkTmpReadyFile()
try {
fs.writeFileSync(tmp.file, JSON.stringify({ port: 4567 }))
assert.equal(readDashboardReadyFile(tmp.file), 4567)
@@ -165,7 +151,6 @@ test('readDashboardReadyFile returns a valid port from JSON', () => {
test('readDashboardReadyFile ignores missing, malformed, or invalid files', () => {
const tmp = mkTmpReadyFile()
try {
assert.equal(readDashboardReadyFile(tmp.file), null)
fs.writeFileSync(tmp.file, '{')
@@ -180,7 +165,6 @@ test('readDashboardReadyFile ignores missing, malformed, or invalid files', () =
test('waitForDashboardReadyFile resolves when the ready file appears', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 8765 })), 20)
@@ -193,7 +177,6 @@ test('waitForDashboardReadyFile resolves when the ready file appears', async ()
test('waitForDashboardPortAnnouncement uses ready file when provided', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 })
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 9876 })), 20)
@@ -206,7 +189,6 @@ test('waitForDashboardPortAnnouncement uses ready file when provided', async ()
test('waitForDashboardReadyFile rejects when the child exits before file readiness', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
child.emit('exit', 1, null)

View File

@@ -1,32 +1,20 @@
import fs from 'node:fs'
const fs = require('node:fs')
function isWslEnvironment(env = process.env, platform = process.platform, kernelRelease = null) {
if (platform !== 'linux') {
return false
}
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {
return true
}
if (platform !== 'linux') return false
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true
try {
const release = kernelRelease ?? fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8')
return /microsoft|wsl/i.test(release)
} catch {
return false
}
}
function isWindowsBinaryPathInWsl(
filePath,
options: { isWsl?: boolean; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}
) {
function isWindowsBinaryPathInWsl(filePath, options = {}) {
const isWsl = options.isWsl ?? isWslEnvironment(options.env, options.platform)
if (!isWsl) {
return false
}
if (!isWsl) return false
const normalized = String(filePath || '')
.replace(/\\/g, '/')
@@ -60,27 +48,19 @@ const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off'])
*
* Pure + dependency-free so it can be unit-tested and called before app ready.
*/
function detectRemoteDisplay(options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}) {
function detectRemoteDisplay(options = {}) {
const env = options.env ?? process.env
const platform = options.platform ?? process.platform
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '')
.trim()
.toLowerCase()
if (GPU_OVERRIDE_ON.has(override)) {
return 'override (HERMES_DESKTOP_DISABLE_GPU)'
}
if (GPU_OVERRIDE_OFF.has(override)) {
return null
}
if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)'
if (GPU_OVERRIDE_OFF.has(override)) return null
// Launched from an SSH session → the display is X11-forwarded or otherwise
// remote. Covers the common `ssh user@box` + GUI-forwarding case.
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) {
return 'ssh-session'
}
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'ssh-session'
if (platform === 'linux') {
// X11 forwarding sets DISPLAY to "<host>:N" (e.g. "localhost:10.0"); a
@@ -88,7 +68,6 @@ function detectRemoteDisplay(options: { env?: NodeJS.ProcessEnv; platform?: Node
// NB: WSLg deliberately isn't treated as remote — it reports
// GPU-accelerated vGPU surfaces locally and doesn't show the flicker.
const display = String(env.DISPLAY || '')
if (display.includes(':') && display.split(':')[0]) {
return `x11-forwarding (DISPLAY=${display})`
}
@@ -98,13 +77,15 @@ function detectRemoteDisplay(options: { env?: NodeJS.ProcessEnv; platform?: Node
// RDP sessions report SESSIONNAME like "RDP-Tcp#7"; the local console is
// "Console".
const sessionName = String(env.SESSIONNAME || '')
if (/^rdp-/i.test(sessionName)) {
return `rdp (SESSIONNAME=${sessionName})`
}
if (/^rdp-/i.test(sessionName)) return `rdp (SESSIONNAME=${sessionName})`
}
return null
}
export { bundledRuntimeImportCheck, detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment }
module.exports = {
bundledRuntimeImportCheck,
detectRemoteDisplay,
isWindowsBinaryPathInWsl,
isWslEnvironment
}

View File

@@ -1,12 +1,14 @@
import assert from 'node:assert/strict'
import test from 'node:test'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
import {
const {
bundledRuntimeImportCheck,
detectRemoteDisplay,
isWindowsBinaryPathInWsl,
isWslEnvironment
} from './bootstrap-platform'
} = require('./bootstrap-platform.cjs')
test('isWslEnvironment detects WSL2 env vars on linux', () => {
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
@@ -83,3 +85,27 @@ test('detectRemoteDisplay honors the HERMES_DESKTOP_DISABLE_GPU override both wa
null
)
})
test('packaged electron entrypoints do not require unpackaged npm modules', () => {
const electronDir = __dirname
const entrypoints = ['main.cjs', 'preload.cjs', 'bootstrap-platform.cjs']
// - electron: provided by the electron runtime, always resolvable in packaged builds.
// - node-pty: hoisted by workspace dedup AND shipped via extraResources to
// resources/native-deps/node-pty (see scripts/stage-native-deps.cjs). main.cjs
// has a try/catch fallback at line ~38 that resolves the staged copy when the
// bare require fails in the packaged asar, so the bare require itself is by
// design rather than an oversight.
const allowedBareRequires = new Set(['electron', 'node-pty'])
const requirePattern = /require\(['"]([^'"]+)['"]\)/g
for (const entrypoint of entrypoints) {
const source = fs.readFileSync(path.join(electronDir, entrypoint), 'utf8')
const bareRequires = Array.from(source.matchAll(requirePattern))
.map(match => match[1])
.filter(specifier => !specifier.startsWith('node:'))
.filter(specifier => !specifier.startsWith('.'))
.filter(specifier => !allowedBareRequires.has(specifier))
assert.deepEqual(bareRequires, [], `${entrypoint} has unpackaged runtime requires`)
}
})

View File

@@ -1,14 +1,16 @@
'use strict'
/**
* bootstrap-runner.ts
* bootstrap-runner.cjs
*
* Drives apps/desktop's first-launch install of Hermes Agent by spawning
* scripts/install.ps1 stage-by-stage and streaming progress events back to
* the renderer.
*
* Wired from electron/main.ts:
* import { runBootstrap }from './bootstrap-runner.ts'
* Wired from electron/main.cjs:
* const { runBootstrap } = require('./bootstrap-runner.cjs')
* const result = await runBootstrap({
* installStamp, // INSTALL_STAMP from main.ts (may be null in dev)
* installStamp, // INSTALL_STAMP from main.cjs (may be null in dev)
* activeRoot, // ACTIVE_HERMES_ROOT
* sourceRepoRoot, // SOURCE_REPO_ROOT (for dev install.ps1 lookup)
* hermesHome, // HERMES_HOME
@@ -32,11 +34,11 @@
* no UI consumes them yet)
*/
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import https from 'node:https'
import path from 'node:path'
const fs = require('node:fs')
const fsp = require('node:fs/promises')
const path = require('node:path')
const https = require('node:https')
const { spawn } = require('node:child_process')
const IS_WINDOWS = process.platform === 'win32'
@@ -44,7 +46,6 @@ function hiddenWindowsChildOptions(options = {}) {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options
}
return { ...options, windowsHide: true }
}
@@ -70,14 +71,10 @@ function installScriptKind() {
}
function resolveLocalInstallScript(sourceRepoRoot) {
if (!sourceRepoRoot) {
return null
}
if (!sourceRepoRoot) return null
const candidate = path.join(sourceRepoRoot, 'scripts', installScriptName())
try {
fs.accessSync(candidate, fs.constants.R_OK)
return candidate
} catch {
return null
@@ -93,14 +90,10 @@ function bootstrapCacheDir(hermesHome) {
// the pinned commit can't be fetched from GitHub (e.g. a locally-built desktop
// app stamped to an unpushed HEAD).
function installedAgentInstallScript(hermesHome) {
if (!hermesHome) {
return null
}
if (!hermesHome) return null
const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName())
try {
fs.accessSync(candidate, fs.constants.R_OK)
return candidate
} catch {
return null
@@ -117,7 +110,6 @@ function downloadInstallScript(commit, destPath) {
// verification beyond "did the file we wrote pass a syntax probe."
const scriptName = installScriptName()
const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/${scriptName}`
return new Promise((resolve, reject) => {
fs.mkdirSync(path.dirname(destPath), { recursive: true })
const tmpPath = destPath + '.tmp'
@@ -137,10 +129,8 @@ function downloadInstallScript(commit, destPath) {
`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`
)
)
return
}
const out2 = fs.createWriteStream(tmpPath)
res2.pipe(out2)
out2.on('finish', () => {
@@ -151,24 +141,18 @@ function downloadInstallScript(commit, destPath) {
out2.on('error', reject)
})
.on('error', reject)
return
}
if (res.statusCode !== 200) {
out.close()
try {
fs.unlinkSync(tmpPath)
} catch {
void 0
}
reject(new Error(`Failed to download ${scriptName}: HTTP ${res.statusCode} from ${url}`))
return
}
res.pipe(out)
out.on('finish', () => {
out.close()
@@ -181,7 +165,6 @@ function downloadInstallScript(commit, destPath) {
} catch {
void 0
}
reject(err)
})
})
@@ -191,7 +174,6 @@ function downloadInstallScript(commit, destPath) {
} catch {
void 0
}
reject(err)
})
})
@@ -205,13 +187,11 @@ async function resolveInstallScript({
_download = downloadInstallScript
}) {
// 1. Dev shortcut: prefer a local checkout's installer so we can iterate
// without pushing. SOURCE_REPO_ROOT comes from main.ts (path.resolve
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve
// of APP_ROOT/../..).
const localScript = resolveLocalInstallScript(sourceRepoRoot)
if (localScript) {
emit({ type: 'log', line: `[bootstrap] using local ${installScriptName()} at ${localScript}` })
return { path: localScript, source: 'local', kind: installScriptKind() }
}
@@ -224,14 +204,12 @@ async function resolveInstallScript({
}
const cached = cachedScriptPath(hermesHome, installStamp.commit)
try {
await fsp.access(cached, fs.constants.R_OK)
emit({
type: 'log',
line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}`
})
return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() }
} catch {
// not cached; download
@@ -241,20 +219,17 @@ async function resolveInstallScript({
type: 'log',
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
})
try {
await _download(installStamp.commit, cached)
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
} catch (err) {
// The pinned commit may not be fetchable from GitHub -- most commonly a
// locally-built desktop app stamped to an unpushed HEAD (see
// write-build-stamp.mjs fromLocalGit). Fall back to the installer that
// write-build-stamp.cjs fromLocalGit). Fall back to the installer that
// ships inside the already-installed agent checkout so dev/self-builds can
// still bootstrap instead of dying with a fatal 404.
const installed = installedAgentInstallScript(hermesHome)
if (installed) {
emit({
type: 'log',
@@ -262,18 +237,15 @@ async function resolveInstallScript({
`[bootstrap] GitHub fetch failed (${err.message}); ` +
`falling back to installed agent ${installScriptName()} at ${installed}`
})
try {
fs.mkdirSync(path.dirname(cached), { recursive: true })
fs.copyFileSync(installed, cached)
return { path: cached, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
} catch {
// Cache copy failed (read-only FS, etc.) -- use the source path directly.
return { path: installed, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
}
}
throw err
}
}
@@ -299,41 +271,31 @@ function powershellUnderRoot(root) {
function resolveWindowsPowerShell() {
for (const v of ['SystemRoot', 'windir']) {
const root = process.env[v]
if (root) {
const candidate = powershellUnderRoot(root)
try {
if (fs.statSync(candidate).isFile()) {
return candidate
}
if (fs.statSync(candidate).isFile()) return candidate
} catch {
void 0
}
}
}
const pathDirs = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean)
for (const exe of ['powershell.exe', 'pwsh.exe']) {
for (const dir of pathDirs) {
const candidate = path.join(dir, exe)
try {
if (fs.statSync(candidate).isFile()) {
return candidate
}
if (fs.statSync(candidate).isFile()) return candidate
} catch {
void 0
}
}
}
return 'powershell.exe'
}
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome }: any = {}) {
return new Promise<any>((resolve, reject) => {
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
return new Promise((resolve, reject) => {
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
@@ -357,14 +319,12 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
const onAbort = () => {
killed = true
try {
child.kill('SIGTERM')
} catch {
void 0
}
}
if (abortSignal) {
if (abortSignal.aborted) {
onAbort()
@@ -382,14 +342,10 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
stdout += chunk
stdoutBuf += chunk
let nl
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1)
if (line) {
emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
}
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
}
})
@@ -398,44 +354,30 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
stderr += chunk
stderrBuf += chunk
let nl
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
stderrBuf = stderrBuf.slice(nl + 1)
if (line) {
emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
}
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
}
})
child.on('error', err => {
if (abortSignal) {
abortSignal.removeEventListener('abort', onAbort)
}
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
reject(err)
})
child.on('close', (code, signal) => {
if (abortSignal) {
abortSignal.removeEventListener('abort', onAbort)
}
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
// Flush any trailing bytes
if (stdoutBuf) {
emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' } as any)
}
if (stderrBuf) {
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' } as any)
}
resolve({ stdout, stderr, code, signal, killed } as any)
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
resolve({ stdout, stderr, code, signal, killed })
})
})
}
function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome }: any = {}) {
return new Promise<any>((resolve, reject) => {
function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
return new Promise((resolve, reject) => {
const child = spawn('bash', [scriptPath, ...args], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
@@ -450,14 +392,12 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
const onAbort = () => {
killed = true
try {
child.kill('SIGTERM')
} catch {
void 0
}
}
if (abortSignal) {
if (abortSignal.aborted) {
onAbort()
@@ -474,14 +414,10 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
stdout += chunk
stdoutBuf += chunk
let nl
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1)
if (line) {
emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
}
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
}
})
@@ -490,36 +426,22 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
stderr += chunk
stderrBuf += chunk
let nl
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
stderrBuf = stderrBuf.slice(nl + 1)
if (line) {
emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
}
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
}
})
child.on('error', err => {
if (abortSignal) {
abortSignal.removeEventListener('abort', onAbort)
}
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
reject(err)
})
child.on('close', (code, signal) => {
if (abortSignal) {
abortSignal.removeEventListener('abort', onAbort)
}
if (stdoutBuf) {
emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
}
if (stderrBuf) {
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
}
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
resolve({ stdout, stderr, code, signal, killed })
})
})
@@ -534,60 +456,48 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
// instead of falling back to install.ps1's default ($Branch = "main").
function buildPinArgs(installStamp) {
const args = []
if (installStamp && installStamp.commit) {
args.push('-Commit', installStamp.commit)
}
if (installStamp && installStamp.branch) {
args.push('-Branch', installStamp.branch)
}
return args
}
function buildPosixPinArgs({ installStamp, activeRoot, hermesHome }) {
const args = ['--dir', activeRoot, '--hermes-home', hermesHome]
if (installStamp && installStamp.branch) {
args.push('--branch', installStamp.branch)
}
if (installStamp && installStamp.commit) {
args.push('--commit', installStamp.commit)
}
return args
}
async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, activeRoot, installStamp }) {
const isPosix = installerKind === 'posix'
const args = isPosix
? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
: ['-Manifest', ...buildPinArgs(installStamp)]
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
emit,
stageName: '__manifest__',
hermesHome
})
if (result.code !== 0) {
throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`
)
}
// The manifest is the LAST JSON line on stdout (install.ps1 may print
// banner / info lines first depending on Console.OutputEncoding effects).
// Find the last line that parses as JSON with a `stages` field.
const lines = result.stdout.split(/\r?\n/).filter(Boolean)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const parsed = JSON.parse(lines[i])
if (parsed && Array.isArray(parsed.stages)) {
return parsed
}
@@ -595,7 +505,6 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
void 0
}
}
throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`
)
@@ -606,11 +515,9 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
// for the double-emit bug we addressed in the install.ps1 PR).
function parseStageResult(stdout) {
const lines = stdout.split(/\r?\n/).filter(Boolean)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const parsed = JSON.parse(lines[i])
if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') {
return parsed
}
@@ -618,7 +525,6 @@ function parseStageResult(stdout) {
void 0
}
}
return null
}
@@ -627,7 +533,6 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
emit({ type: 'stage', name: stage.name, state: 'running' })
const isPosix = installerKind === 'posix'
const args = isPosix
? [
'--stage',
@@ -637,7 +542,6 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })
]
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)]
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
emit,
stageName: stage.name,
@@ -650,7 +554,6 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
if (result.killed) {
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, error: 'cancelled by user' }
emit(ev)
return ev
}
@@ -665,26 +568,20 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
error: `${isPosix ? 'install.sh --stage' : 'install.ps1 -Stage'} ${stage.name} produced no JSON result frame (exit=${result.code})`,
json: null
}
emit(ev)
return ev
}
if (json.ok && json.skipped) {
const ev = { type: 'stage', name: stage.name, state: 'skipped', durationMs, json }
emit(ev)
return ev
}
if (json.ok) {
const ev = { type: 'stage', name: stage.name, state: 'succeeded', durationMs, json }
emit(ev)
return ev
}
const ev = {
type: 'stage',
name: stage.name,
@@ -693,9 +590,7 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
json,
error: json.reason || `exit code ${result.code}`
}
emit(ev)
return ev
}
@@ -708,7 +603,6 @@ function openRunLog(logRoot) {
const ts = new Date().toISOString().replace(/[:.]/g, '-')
const logPath = path.join(logRoot, `bootstrap-${ts}.log`)
const stream = fs.createWriteStream(logPath, { flags: 'a' })
return { path: logPath, stream }
}
@@ -725,7 +619,7 @@ async function runBootstrap(opts) {
logRoot,
onEvent,
abortSignal,
writeMarker // callback to write the bootstrap-complete marker; main.ts provides
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides
} = opts
// Bail before spawning anything if the user already cancelled — otherwise an
@@ -739,7 +633,6 @@ async function runBootstrap(opts) {
void 0
}
}
return { ok: false, cancelled: true }
}
@@ -753,11 +646,8 @@ async function runBootstrap(opts) {
} catch {
void 0
}
try {
if (typeof onEvent === 'function') {
onEvent(ev)
}
if (typeof onEvent === 'function') onEvent(ev)
} catch (err) {
// Don't let a subscriber bug crash the bootstrap
runLog.stream.write(`emit error: ${err && err.message}\n`)
@@ -787,7 +677,6 @@ async function runBootstrap(opts) {
activeRoot,
installStamp
})
emit({
type: 'manifest',
stages: manifest.stages,
@@ -801,10 +690,8 @@ async function runBootstrap(opts) {
for (const stage of manifest.stages) {
if (abortSignal && abortSignal.aborted) {
emit({ type: 'failed', error: 'bootstrap cancelled by user' })
return { ok: false, cancelled: true }
}
const ev = await runStage({
scriptPath: scriptInfo.path,
installerKind,
@@ -815,11 +702,9 @@ async function runBootstrap(opts) {
abortSignal,
installStamp
})
if (ev.state === 'failed') {
emit({ type: 'failed', stage: stage.name, error: (ev as any).error || 'stage failed' })
return { ok: false, failedStage: stage.name, error: (ev as any).error }
emit({ type: 'failed', stage: stage.name, error: ev.error || 'stage failed' })
return { ok: false, failedStage: stage.name, error: ev.error }
}
}
@@ -828,14 +713,11 @@ async function runBootstrap(opts) {
pinnedCommit: installStamp ? installStamp.commit : null,
pinnedBranch: installStamp ? installStamp.branch : null
}
const marker = typeof writeMarker === 'function' ? writeMarker(markerPayload) : markerPayload
emit({ type: 'complete', marker })
return { ok: true, marker }
} catch (err) {
emit({ type: 'failed', error: err.message || String(err) })
return { ok: false, error: err.message || String(err) }
} finally {
try {
@@ -846,12 +728,12 @@ async function runBootstrap(opts) {
}
}
export {
cachedScriptPath,
installedAgentInstallScript,
module.exports = {
runBootstrap,
// Exposed for testability
parseStageResult,
resolveInstallScript,
resolveLocalInstallScript,
runBootstrap
resolveInstallScript,
installedAgentInstallScript,
cachedScriptPath
}

View File

@@ -1,10 +1,15 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
const assert = require('node:assert/strict')
const test = require('node:test')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
import { cachedScriptPath, installedAgentInstallScript, resolveInstallScript, runBootstrap } from './bootstrap-runner'
const {
runBootstrap,
resolveInstallScript,
installedAgentInstallScript,
cachedScriptPath
} = require('./bootstrap-runner.cjs')
const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
@@ -17,7 +22,6 @@ test('runBootstrap bails immediately when the signal is already aborted', async
controller.abort()
const events = []
const result = await runBootstrap({
installStamp: null,
activeRoot: '/tmp/hermes-runner-test',
@@ -38,7 +42,6 @@ test('runBootstrap bails immediately when the signal is already aborted', async
test('installedAgentInstallScript resolves the installer in the agent checkout', () => {
const home = mkTmpHome()
try {
assert.equal(installedAgentInstallScript(home), null, 'absent before the checkout exists')
@@ -56,7 +59,6 @@ test('installedAgentInstallScript resolves the installer in the agent checkout',
test('resolveInstallScript prefers a cached script without touching the network', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
const cached = cachedScriptPath(home, commit)
@@ -64,7 +66,6 @@ test('resolveInstallScript prefers a cached script without touching the network'
fs.writeFileSync(cached, '#!/bin/sh\necho cached\n')
const logs = []
const result = await resolveInstallScript({
installStamp: { commit },
sourceRepoRoot: null,
@@ -81,7 +82,6 @@ test('resolveInstallScript prefers a cached script without touching the network'
test('resolveInstallScript falls back to the installed agent checkout on a 404', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
// Seed the installed agent checkout so the fallback has something to resolve.
@@ -91,7 +91,6 @@ test('resolveInstallScript falls back to the installed agent checkout on a 404',
fs.writeFileSync(installed, '#!/bin/sh\necho fallback\n')
const logs = []
const result = await resolveInstallScript({
installStamp: { commit },
sourceRepoRoot: null,
@@ -118,7 +117,6 @@ test('resolveInstallScript falls back to the installed agent checkout on a 404',
test('resolveInstallScript rethrows when the 404 fallback is unavailable', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
// No installed agent checkout seeded -> nothing to fall back to.

View File

@@ -1,13 +1,13 @@
/**
* connection-config.ts
* connection-config.cjs
*
* Pure, electron-free helpers for the desktop's remote-gateway connection
* config: URL normalization, WS-URL construction (token vs OAuth ticket),
* auth-mode classification, and the auth-mode coercion rules.
*
* Kept standalone (no `import 'electron'`) so it can be unit-tested with
* `node --test` same pattern as backend-probes.ts / bootstrap-platform.ts.
* main.ts requires these and wires them into the electron-coupled IPC layer.
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` same pattern as backend-probes.cjs / bootstrap-platform.cjs.
* main.cjs requires these and wires them into the electron-coupled IPC layer.
*
* Background on the two auth models a remote gateway can use:
* - 'token': legacy static dashboard session token. REST uses an
@@ -45,7 +45,6 @@ function normalizeRemoteBaseUrl(rawUrl) {
}
let parsed
try {
parsed = new URL(value)
} catch (error) {
@@ -84,7 +83,7 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
* exercise the same transport the app actually uses.
*
* The OAuth ticket-minter is injected (`mintTicket(baseUrl) -> Promise<ticket>`)
* so this stays electron-free and unit-testable; main.ts passes the real
* so this stays electron-free and unit-testable; main.cjs passes the real
* `mintGatewayWsTicket`.
*
* Return semantics:
@@ -94,7 +93,7 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
* - oauth, mint fails THROWS (NOT a skip)
*
* The oauth-mint-failure throw is the important case: the real boot path
* (resolveRemoteBackend in main.ts) treats a mint failure as a hard
* (resolveRemoteBackend in main.cjs) treats a mint failure as a hard
* "session expired" auth error and refuses to connect. Swallowing it here
* would re-introduce the exact false-positive this test exists to catch
* HTTP /api/status passes, the test reports "reachable", then the renderer
@@ -106,16 +105,13 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
* @param {{ mintTicket: (baseUrl: string) => Promise<string> }} deps
* @returns {Promise<string|null>}
*/
async function resolveTestWsUrl(baseUrl, authMode, token, deps: any = {}) {
async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
if (authMode === 'oauth') {
const mintTicket = deps.mintTicket
if (typeof mintTicket !== 'function') {
throw new Error('resolveTestWsUrl: a mintTicket function is required in OAuth mode.')
}
let ticket
try {
ticket = await mintTicket(baseUrl)
} catch (error) {
@@ -123,19 +119,15 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps: any = {}) {
'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' +
'(it may have expired). Open Settings → Gateway and sign in again.'
)
;(err as any).needsOauthLogin = true
err.needsOauthLogin = true
err.cause = error
throw err
}
return buildGatewayWsUrlWithTicket(baseUrl, ticket)
}
if (!token) {
return null
}
return buildGatewayWsUrl(baseUrl, token)
}
@@ -156,19 +148,17 @@ function normAuthMode(mode) {
*
* The config may carry a `profiles` map keyed by name; an entry counts as an
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
* is the raw stored secret; main.ts decrypts it. Returns
* is the raw stored secret; main.cjs decrypts it. Returns
* `{ url, authMode, token } | null`.
*/
function profileRemoteOverride(config, profile) {
const key = connectionScopeKey(profile)
const entry = key ? config?.profiles?.[key] : null
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
return null
}
const url = String(entry.url || '').trim()
if (!url) {
return null
}
@@ -182,21 +172,18 @@ function profileRemoteOverride(config, profile) {
* query parameter. Local pooled backends and per-profile remote overrides do not
* need this: they already run against a backend scoped to the target profile.
*/
function pathWithGlobalRemoteProfile(path, profile, opts: any = {}) {
function pathWithGlobalRemoteProfile(path, profile, opts = {}) {
const scopedProfile = connectionScopeKey(profile)
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
return path
}
const rawPath = String(path || '')
if (!rawPath) {
return path
}
let parsed
try {
parsed = new URL(rawPath, 'http://hermes.local')
} catch {
@@ -237,18 +224,9 @@ function authModeFromStatus(statusBody) {
* Returns 'oauth' | 'token'.
*/
function resolveAuthMode(inputAuthMode, existingAuthMode) {
if (inputAuthMode === 'oauth') {
return 'oauth'
}
if (inputAuthMode === 'token') {
return 'token'
}
if (existingAuthMode === 'oauth') {
return 'oauth'
}
if (inputAuthMode === 'oauth') return 'oauth'
if (inputAuthMode === 'token') return 'token'
if (existingAuthMode === 'oauth') return 'oauth'
return 'token'
}
@@ -264,10 +242,7 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) {
* need to know whether an unexpired access token is present right now.
*/
function cookiesHaveSession(cookies) {
if (!Array.isArray(cookies)) {
return false
}
if (!Array.isArray(cookies)) return false
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
}
@@ -285,27 +260,24 @@ function cookiesHaveSession(cookies) {
* the RT is also dead/revoked).
*/
function cookiesHaveLiveSession(cookies) {
if (!Array.isArray(cookies)) {
return false
}
if (!Array.isArray(cookies)) return false
return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name)))
}
export {
module.exports = {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveLiveSession,
cookiesHaveSession,
normalizeRemoteBaseUrl,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
tokenPreview
}

View File

@@ -1,7 +1,7 @@
/**
* Tests for electron/connection-config.ts.
* Tests for electron/connection-config.cjs.
*
* Run with: node --test electron/connection-config.test.ts
* Run with: node --test electron/connection-config.test.cjs
* (Wire into npm test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the remote-gateway connection settings:
@@ -10,26 +10,26 @@
* and the OAuth session-cookie detector.
*/
import assert from 'node:assert/strict'
import test from 'node:test'
const test = require('node:test')
const assert = require('node:assert/strict')
import {
const {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveLiveSession,
cookiesHaveSession,
normalizeRemoteBaseUrl,
cookiesHaveLiveSession,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
tokenPreview
} from './connection-config'
} = require('./connection-config.cjs')
// --- connectionScopeKey / normAuthMode ---
@@ -73,7 +73,6 @@ test('profileRemoteOverride returns the per-profile remote with defaulted auth m
coder: { mode: 'remote', url: ' https://coder.example.com/hermes ', token: { value: 'sek' } }
}
}
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
url: 'https://coder.example.com/hermes',
authMode: 'token',
@@ -366,7 +365,6 @@ test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => {
const url = await resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
mintTicket: async () => 'tkt-9'
})
assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9')
})
@@ -378,14 +376,13 @@ test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validatio
throw new Error('401 ticket mint failed')
}
}),
(err: any) => {
err => {
// Actionable, points the user at re-auth, and preserves the cause + flag
// the boot overlay uses to offer a sign-in prompt.
assert.match(err.message, /WebSocket ticket/i)
assert.match(err.message, /sign in again/i)
assert.equal(err.needsOauthLogin, true)
assert.ok(err.cause instanceof Error)
return true
}
)

View File

@@ -9,39 +9,29 @@
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
async function fetchPublicText(url, options: any = {}) {
async function fetchPublicText(url, options = {}) {
const { protocol } = new URL(url)
if (protocol !== 'http:' && protocol !== 'https:') {
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
if (error.name === 'TimeoutError') {
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
}
throw error
})
const text = await res.text()
if (!res.ok) {
throw new Error(`${res.status}: ${text || res.statusText}`)
}
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
return text
}
function extractInjectedDashboardToken(html) {
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
if (!match) {
return null
}
if (!match) return null
try {
return JSON.parse(match[1])
} catch {
@@ -53,13 +43,11 @@ function dashboardIndexUrl(baseUrl) {
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
}
async function resolveServedDashboardToken(baseUrl, fallbackToken, options: any = {}) {
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
const fetchText = options.fetchText || fetchPublicText
const html = await fetchText(dashboardIndexUrl(baseUrl), {
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
})
const servedToken = extractInjectedDashboardToken(html)
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
@@ -88,7 +76,6 @@ function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
return spawnToken
})
@@ -101,10 +88,10 @@ async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, labe
return servedToken
}
export {
module.exports = {
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
adoptServedDashboardToken,
dashboardIndexUrl,
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,

View File

@@ -1,21 +1,21 @@
/**
* Tests for electron/dashboard-token.ts.
* Tests for electron/dashboard-token.cjs.
*
* Run with: node --test electron/dashboard-token.test.ts
* Run with: node --test electron/dashboard-token.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*/
import assert from 'node:assert/strict'
import test from 'node:test'
const test = require('node:test')
const assert = require('node:assert/strict')
import {
const {
adoptServedDashboardToken,
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,
resolveServedDashboardToken
} from './dashboard-token'
} = require('./dashboard-token.cjs')
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
@@ -39,11 +39,9 @@ test('dashboardIndexUrl preserves dashboard path prefixes', () => {
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
const logs = []
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async url => {
assert.equal(url, 'http://127.0.0.1:9120/')
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
},
rememberLog: line => logs.push(line)
@@ -102,9 +100,8 @@ test('isForeignBackendToken only flags a mismatched token from a dead child', ()
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
]
for (const [input, expected] of cases) {
assert.equal(isForeignBackendToken(input as any), expected, JSON.stringify(input))
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
}
})
@@ -131,7 +128,6 @@ test('adoptServedDashboardToken refuses a foreign token when our child is dead',
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
const logs = []
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => true,
fetchText: async () => {

View File

@@ -1,14 +1,14 @@
/**
* desktop-uninstall.ts
* desktop-uninstall.cjs
*
* Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map
* the three user-facing uninstall modes to the `hermes uninstall` CLI flags,
* resolve the running app bundle/exe so a detached cleanup script can remove
* it after the app quits, and build that cleanup script for each OS.
*
* Kept standalone (no ` import 'electron'`) so it can be unit-tested with
* `node --test` same pattern as connection-config.ts / backend-probes.ts.
* main.ts requires these and wires them into the electron-coupled IPC layer.
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` same pattern as connection-config.cjs / backend-probes.cjs.
* main.cjs requires these and wires them into the electron-coupled IPC layer.
*
* The three modes mirror the CLI's options exactly:
* - 'gui' remove ONLY the Chat GUI, keep the agent + all user data.
@@ -23,10 +23,10 @@
* app bundle (locked on macOS/Windows while the process is alive). So we hand
* the work to a detached child that waits for this app's PID to exit, runs the
* Python uninstall, then removes the app bundle then the app quits. Same
* shape as the self-update swap-and-relaunch flow already in main.ts.
* shape as the self-update swap-and-relaunch flow already in main.cjs.
*/
import path from 'node:path'
const path = require('node:path')
const UNINSTALL_MODES = ['gui', 'lite', 'full']
@@ -41,7 +41,6 @@ function uninstallArgsForMode(mode) {
if (!UNINSTALL_MODES.includes(mode)) {
throw new Error(`Unknown uninstall mode: ${mode}`)
}
return ['-m', 'hermes_cli.uninstall', '--mode', mode]
}
@@ -66,12 +65,9 @@ function modeRemovesUserData(mode) {
* Returns null when we can't confidently identify a removable bundle (e.g.
* running from a dev checkout, or a system-package install we must not rmtree).
*/
function resolveRemovableAppPath(execPath, platform, env: any = {}) {
function resolveRemovableAppPath(execPath, platform, env = {}) {
const exe = String(execPath || '')
if (!exe) {
return null
}
if (!exe) return null
// Use the path flavor that matches the TARGET platform, not the host running
// this code — so the Windows branch parses backslash paths correctly even
@@ -83,36 +79,22 @@ function resolveRemovableAppPath(execPath, platform, env: any = {}) {
const macOsDir = p.dirname(exe) // …/Contents/MacOS
const contents = p.dirname(macOsDir) // …/Contents
const appBundle = p.dirname(contents) // …/Hermes.app
if (appBundle.endsWith('.app')) {
return appBundle
}
if (appBundle.endsWith('.app')) return appBundle
return null
}
if (platform === 'win32') {
// NSIS per-user installs Hermes.exe directly in the install dir.
const dir = p.dirname(exe)
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) {
return dir
}
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) return dir
return null
}
// Linux: an AppImage exposes its own path via the APPIMAGE env var.
if (env.APPIMAGE) {
return env.APPIMAGE
}
if (env.APPIMAGE) return env.APPIMAGE
// Unpacked electron-builder tree: …/linux-unpacked/hermes
const dir = p.dirname(exe)
if (/-unpacked$/.test(dir)) {
return dir
}
if (/-unpacked$/.test(dir)) return dir
return null
}
@@ -139,7 +121,6 @@ function shouldRemoveAppBundle(isPackaged, appPath) {
*/
function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
const q = s => `'${String(s).replace(/'/g, `'\\''`)}'`
const lines = [
'#!/bin/bash',
'set -u',
@@ -154,21 +135,16 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
'fi',
`export HERMES_HOME=${q(hermesHome)}`
]
if (pythonPath) {
lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`)
}
lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`)
if (appPath) {
lines.push(`rm -rf ${q(appPath)} || true`)
}
// Self-delete the script.
lines.push('rm -f "$0" 2>/dev/null || true')
lines.push('')
return lines.join('\n')
}
@@ -204,18 +180,15 @@ function buildWindowsCleanupScript({
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be
// a problem, but Hermes install paths don't use them.
const q = s => `"${String(s).replace(/"/g, '')}"`
const lines = [
'@echo off',
'setlocal enableextensions',
`set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`,
`set "PID=${pid}"`
]
if (pythonPath) {
lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`)
}
lines.push(
'set /a waited=0',
':waitloop',
@@ -233,7 +206,6 @@ function buildWindowsCleanupScript({
`cd /d ${q(agentRoot)}`,
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}`
)
if (appPath) {
lines.push(
'set /a tries=0',
@@ -248,20 +220,18 @@ function buildWindowsCleanupScript({
':rmdone'
)
}
lines.push('del "%~f0"')
lines.push('')
return lines.join('\r\n')
}
export {
module.exports = {
UNINSTALL_MODES,
buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
UNINSTALL_MODES,
uninstallArgsForMode
}

View File

@@ -1,7 +1,7 @@
/**
* Tests for electron/desktop-uninstall.ts.
* Tests for electron/desktop-uninstall.cjs.
*
* Run with: node --test electron/desktop-uninstall.test.ts
* Run with: node --test electron/desktop-uninstall.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the desktop Chat GUI uninstaller: the
@@ -9,19 +9,19 @@
* cleanup-script builders (POSIX + Windows).
*/
import assert from 'node:assert/strict'
import test from 'node:test'
const test = require('node:test')
const assert = require('node:assert/strict')
import {
const {
UNINSTALL_MODES,
buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
UNINSTALL_MODES,
uninstallArgsForMode
} from './desktop-uninstall'
} = require('./desktop-uninstall.cjs')
// --- uninstallArgsForMode ---
@@ -132,7 +132,6 @@ test('buildPosixCleanupScript waits for the PID, runs the uninstall module, remo
appPath: '/opt/hermes/linux-unpacked',
hermesHome: '/home/x/.hermes'
})
assert.match(script, /^#!\/bin\/bash/)
assert.match(script, /pid=4321/)
assert.match(script, /kill -0 "\$pid"/)
@@ -153,7 +152,6 @@ test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/fu
appPath: null,
hermesHome: '/home/x/.hermes'
})
// System python + source on PYTHONPATH so import hermes_cli works while the
// venv is torn down.
assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/)
@@ -170,7 +168,6 @@ test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', (
appPath: null,
hermesHome: '/h'
})
assert.doesNotMatch(script, /export PYTHONPATH/)
})
@@ -184,7 +181,6 @@ test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => {
appPath: null,
hermesHome: '/h'
})
assert.doesNotMatch(script, /rm -rf '\//)
// Still runs the uninstall.
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/)
@@ -200,7 +196,6 @@ test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () =
appPath: null,
hermesHome: '/h'
})
// The apostrophe is closed-escaped-reopened so the shell sees the literal.
assert.match(script, /'\/home\/o'\\''brien\/python'/)
})
@@ -217,7 +212,6 @@ test('buildWindowsCleanupScript waits (bounded) for PID, runs uninstall, rmdir b
appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes',
hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes'
})
assert.match(script, /@echo off/)
assert.match(script, /set "PID=9988"/)
// PYTHONPATH set so a system python can import hermes_cli from source.
@@ -244,7 +238,6 @@ test('buildWindowsCleanupScript omits PYTHONPATH + rmdir when not needed (gui, n
appPath: null,
hermesHome: 'C:\\h'
})
assert.doesNotMatch(script, /rmdir/)
assert.doesNotMatch(script, /set "PYTHONPATH=/)
})

View File

@@ -1,8 +1,9 @@
import { session } from 'electron'
'use strict'
const { session } = require('electron')
const EMBED_SESSION_PARTITION = 'persist:hermes-embed'
const EMBED_REFERER = 'https://www.youtube.com/'
const YOUTUBE_REFERER_HOST_RE =
/(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i
@@ -22,7 +23,6 @@ function installEmbedRefererForSession(embedSession) {
if (!YOUTUBE_REFERER_HOST_RE.test(host)) {
callback({ requestHeaders: details.requestHeaders })
return
}
@@ -45,4 +45,4 @@ function installEmbedReferer() {
}
}
export { installEmbedReferer }
module.exports = { installEmbedReferer }

View File

@@ -1,7 +1,8 @@
import fs from 'node:fs'
import path from 'node:path'
'use strict'
import { resolveDirectoryForIpc } from './hardening'
const fs = require('node:fs')
const path = require('node:path')
const { resolveDirectoryForIpc } = require('./hardening.cjs')
const FS_READDIR_STAT_CONCURRENCY = 16
@@ -36,9 +37,7 @@ function direntIsSymbolicLink(dirent) {
}
function shouldStatDirent(dirent) {
if (direntIsDirectory(dirent)) {
return false
}
if (direntIsDirectory(dirent)) return false
return direntIsSymbolicLink(dirent) || !direntIsFile(dirent)
}
@@ -71,13 +70,13 @@ async function mapWithStatConcurrency(items, mapper) {
}
const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length)
const workers = Array.from({ length: workerCount } as any, () => runWorker())
const workers = Array.from({ length: workerCount }, () => runWorker())
await Promise.all(workers)
return results
}
async function readDirForIpc(dirPath, options: any = {}) {
async function readDirForIpc(dirPath, options = {}) {
const fsImpl = options.fs || fs
let resolved
@@ -103,4 +102,6 @@ async function readDirForIpc(dirPath, options: any = {}) {
}
}
export { readDirForIpc }
module.exports = {
readDirForIpc
}

View File

@@ -1,17 +1,19 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { pathToFileURL } from 'node:url'
'use strict'
import { readDirForIpc } from './fs-read-dir'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { pathToFileURL } = require('node:url')
const { readDirForIpc } = require('./fs-read-dir.cjs')
function mkTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-'))
}
function fakeDirent(name, flags: any = {}) {
function fakeDirent(name, flags = {}) {
return {
name,
isDirectory: () => Boolean(flags.directory),
@@ -107,12 +109,10 @@ test('readDirForIpc accepts file URLs for directories', async () => {
test('readDirForIpc returns invalid-path for blank or non-string input', async () => {
let readdirCalls = 0
const fsImpl = {
promises: {
readdir: async () => {
readdirCalls += 1
return []
}
}
@@ -126,12 +126,10 @@ test('readDirForIpc returns invalid-path for blank or non-string input', async (
test('readDirForIpc rejects Windows device paths before readdir', async () => {
let readdirCalls = 0
const fsImpl = {
promises: {
readdir: async () => {
readdirCalls += 1
return []
}
}
@@ -226,7 +224,6 @@ test('readDirForIpc allows expanding symlink or junction directories outside the
fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok')
const linkPath = path.join(root, 'outside-link')
try {
fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
} catch (error) {
@@ -255,7 +252,6 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th
const input = path.join('virtual-root')
const resolved = path.resolve(input)
const statCalls = []
const fsImpl = {
promises: {
readdir: async () => [
@@ -270,11 +266,9 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th
}
statCalls.push(fullPath)
if (fullPath.endsWith(`${path.sep}linked-dir`)) {
return { isDirectory: () => true }
}
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
}
}
@@ -307,15 +301,12 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
let peak = 0
let releaseStats
let markFirstStatStarted
const statsReleased = new Promise(resolve => {
releaseStats = resolve
})
const firstStatStarted = new Promise(resolve => {
markFirstStatStarted = resolve
})
const fsImpl = {
promises: {
readdir: async () => [
@@ -335,7 +326,6 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
active -= 1
const name = path.basename(fullPath)
if (name === failedName) {
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
}

View File

@@ -36,16 +36,13 @@ const DEFAULT_READY_GRACE_MS = 750
* Attempt a live WebSocket connection and classify the outcome.
*
* @param {string} wsUrl - Fully-formed ws(s):// URL including the credential.
* @param {object} [options]
* @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor.
* @param {number} [options.connectTimeoutMs]
* @param {number} [options.readyGraceMs]
* @returns {Promise<{ ok: boolean, reason?: string }>}
*/
function probeGatewayWebSocket<T>(
wsUrl: string,
options: {
WebSocketImpl?: any
connectTimeoutMs?: number
readyGraceMs?: number
} = {}
) {
function probeGatewayWebSocket(wsUrl, options = {}) {
const WebSocketImpl = options.WebSocketImpl
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS
@@ -57,7 +54,7 @@ function probeGatewayWebSocket<T>(
})
}
return new Promise<any>(resolve => {
return new Promise(resolve => {
let settled = false
let opened = false
let connectTimer = null
@@ -69,7 +66,6 @@ function probeGatewayWebSocket<T>(
clearTimeout(connectTimer)
connectTimer = null
}
if (graceTimer !== null) {
clearTimeout(graceTimer)
graceTimer = null
@@ -77,18 +73,14 @@ function probeGatewayWebSocket<T>(
}
const finish = result => {
if (settled) {
return
}
if (settled) return
settled = true
clearTimers()
try {
socket?.close?.()
} catch {
// ignore — best effort teardown
}
resolve(result)
}
@@ -99,14 +91,11 @@ function probeGatewayWebSocket<T>(
ok: false,
reason: error instanceof Error ? error.message : String(error)
})
return
}
const onOpen = () => {
if (settled) {
return
}
if (settled) return
opened = true
// Upgrade accepted. Give the server a brief window to reject the
// credential post-handshake (early close) before declaring success.
@@ -129,10 +118,7 @@ function probeGatewayWebSocket<T>(
}
const onClose = event => {
if (settled) {
return
}
if (settled) return
if (opened) {
// Opened, then closed inside the grace window: the upgrade was accepted
// but the session was refused (e.g. ws-ticket/token rejected, or a
@@ -141,10 +127,8 @@ function probeGatewayWebSocket<T>(
ok: false,
reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).')
})
return
}
finish({
ok: false,
reason: closeReason(event, 'The gateway closed the WebSocket before it opened.')
@@ -170,10 +154,8 @@ function probeGatewayWebSocket<T>(
function addListener(socket, type, handler) {
if (typeof socket.addEventListener === 'function') {
socket.addEventListener(type, handler)
return
}
// Node's global WebSocket implements addEventListener; this fallback keeps the
// helper usable with the `ws` package's EventEmitter shape too.
if (typeof socket.on === 'function') {
@@ -182,43 +164,25 @@ function addListener(socket, type, handler) {
}
function extractErrorReason(event) {
if (!event) {
return ''
}
if (event instanceof Error) {
return event.message
}
if (!event) return ''
if (event instanceof Error) return event.message
const err = event.error || event.message
if (err instanceof Error) {
return err.message
}
if (typeof err === 'string') {
return err
}
if (err instanceof Error) return err.message
if (typeof err === 'string') return err
return ''
}
function closeReason(event, fallback) {
const code = event && typeof event.code === 'number' ? event.code : null
const reason = event && typeof event.reason === 'string' ? event.reason.trim() : ''
if (code && reason) {
return `${fallback} (code ${code}: ${reason})`
}
if (code) {
return `${fallback} (code ${code})`
}
if (reason) {
return `${fallback} (${reason})`
}
if (code && reason) return `${fallback} (code ${code}: ${reason})`
if (code) return `${fallback} (code ${code})`
if (reason) return `${fallback} (${reason})`
return fallback
}
export { DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READY_GRACE_MS, probeGatewayWebSocket }
module.exports = {
DEFAULT_CONNECT_TIMEOUT_MS,
DEFAULT_READY_GRACE_MS,
probeGatewayWebSocket
}

View File

@@ -1,7 +1,7 @@
/**
* Tests for electron/gateway-ws-probe.ts.
* Tests for electron/gateway-ws-probe.cjs.
*
* Run with: node --test electron/gateway-ws-probe.test.ts
* Run with: node --test electron/gateway-ws-probe.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* The probe drives a real WebSocket handshake for the "Test remote" button.
@@ -9,20 +9,16 @@
* outcome (open, frame, error, early close, never-opens) without a network.
*/
import assert from 'node:assert/strict'
import test from 'node:test'
const test = require('node:test')
const assert = require('node:assert/strict')
import { probeGatewayWebSocket } from './gateway-ws-probe'
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
// Minimal WebSocket double: records listeners synchronously (the probe attaches
// them in its executor) and exposes emit() so the test can replay events.
function makeFakeWs(): { FakeWs: new (url: string) => any; instances: any[] } {
function makeFakeWs() {
const instances = []
class FakeWs {
url: string
closed = false
listeners: Record<string, any[]> = {}
constructor(url) {
this.url = url
this.listeners = {}
@@ -36,12 +32,9 @@ function makeFakeWs(): { FakeWs: new (url: string) => any; instances: any[] } {
this.closed = true
}
emit(type, event) {
for (const fn of this.listeners[type] || []) {
fn(event)
}
for (const fn of this.listeners[type] || []) fn(event)
}
}
return { FakeWs, instances }
}
@@ -58,13 +51,11 @@ test('probe resolves ok when the socket opens and stays open', async () => {
test('probe resolves ok immediately when a frame arrives', async () => {
const { FakeWs, instances } = makeFakeWs()
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', {
WebSocketImpl: FakeWs,
connectTimeoutMs: 1_000,
readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer
})
instances[0].emit('open')
instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' })
const result = await promise
@@ -104,13 +95,11 @@ test('probe fails when the gateway accepts then immediately closes (auth rejecte
test('probe times out when the socket never opens', async () => {
const { FakeWs } = makeFakeWs()
const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', {
WebSocketImpl: FakeWs,
connectTimeoutMs: 20,
readyGraceMs: 10
})
assert.equal(result.ok, false)
assert.match(result.reason, /Timed out/)
})

View File

@@ -1,12 +1,14 @@
'use strict'
// Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
// — no native addon, so it just works for anyone who pulls main (no
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
// first scan stays fast. Results are cached by the backend after the first run.
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const fsp = fs.promises
@@ -34,14 +36,14 @@ async function mapLimit(items, limit, fn) {
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) } as any, worker))
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
}
/**
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
*/
async function scanGitRepos(roots, options: any = {}) {
async function scanGitRepos(roots, options = {}) {
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
const found = new Map()
@@ -52,7 +54,6 @@ async function scanGitRepos(roots, options: any = {}) {
}
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
@@ -72,7 +73,6 @@ async function scanGitRepos(roots, options: any = {}) {
}
const subdirs = []
for (const entry of entries) {
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
// known heavy trees.
@@ -93,4 +93,4 @@ async function scanGitRepos(roots, options: any = {}) {
return [...found.entries()].map(([root, label]) => ({ label, root }))
}
export { scanGitRepos }
module.exports = { scanGitRepos }

View File

@@ -1,16 +1,37 @@
'use strict'
// Git ops backing the coding rail + Codex-style review pane. Built on `simple-git`
// (a maintained wrapper around the system git binary — same git the rest of the
// app shells to, no native build) so we read structured status()/diffSummary()
// results instead of hand-parsing porcelain. Reads degrade to null/empty on a
// non-repo / remote backend; mutations reject so the renderer can toast.
import { execFile } from 'node:child_process'
import fs from 'node:fs/promises'
import path from 'node:path'
const { execFile } = require('node:child_process')
const fs = require('node:fs/promises')
const path = require('node:path')
import simpleGit from 'simple-git'
// `simple-git` is a pure-JS runtime dep that workspace dedup hoists into the
// repo-root node_modules. Packaged builds set `files:` in package.json, which
// excludes node_modules from the asar, so the normal require() fails at launch
// (issue #52735: "Cannot find module 'simple-git'"). We ship the dep's
// closure under resources/native-deps/vendor/node_modules/ via extraResources
// + scripts/stage-native-deps.cjs, and resolve from there when the hoisted
// require() isn't reachable. The `vendor/` nesting matters: electron-builder
// drops a node_modules dir at the root of an extraResources copy but keeps a
// nested one. Dev mode never hits the fallback -- Node's normal lookup finds
// the hoisted copy.
let simpleGit
try {
simpleGit = require('simple-git')
} catch {
const resourcesPath = process.resourcesPath
if (!resourcesPath) {
throw new Error("git-review IPC: 'simple-git' not found and no resourcesPath to fall back to")
}
simpleGit = require(path.join(resourcesPath, 'native-deps', 'vendor', 'node_modules', 'simple-git'))
}
import { resolveRequestedPathForIpc } from './hardening'
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000
const COMMIT_CONTEXT_UNTRACKED_MAX = 80
@@ -31,7 +52,7 @@ function ghEnv(ghBin) {
// Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on
// availability/auth without a throw. gh missing/unauthed → ok:false.
function runGh(args, cwd, ghBin): Promise<{ ok: boolean; stdout: string }> {
function runGh(args, cwd, ghBin) {
return new Promise(resolve => {
execFile(
ghBin || 'gh',
@@ -239,11 +260,10 @@ async function reviewList(repoPath, scope, baseRef, gitBin) {
const range = scope === 'branch' ? `${base}...HEAD` : base
const summary = await git.diffSummary([range])
const files = summary.files.map(file => ({
path: resolveRenamePath(file.file),
added: 'insertions' in file ? file.insertions : 0,
removed: 'deletions' in file ? file.deletions : 0,
added: file.binary ? 0 : file.insertions,
removed: file.binary ? 0 : file.deletions,
status: 'M',
staged: false
}))
@@ -271,7 +291,6 @@ async function reviewList(repoPath, scope, baseRef, gitBin) {
git.diffSummary(['--cached']),
git.diffSummary([])
])
const stagedCounts = countsByPath(staged)
const unstagedCounts = countsByPath(unstaged)
@@ -476,7 +495,6 @@ async function reviewCommitContext(repoPath, gitBin) {
const safe = args => git.diff(args).catch(() => '')
let status
try {
status = await git.status()
} catch {
@@ -492,11 +510,9 @@ async function reviewCommitContext(repoPath, gitBin) {
// Untracked files have no diff — list them so new files aren't invisible.
const untracked = status.not_added || []
if (untracked.length > 0) {
const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX)
const omitted = untracked.length - visible.length
const note =
`\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` +
(omitted > 0 ? `# ... ${omitted} more omitted\n` : '')
@@ -591,7 +607,6 @@ async function repoStatus(repoPath, gitBin) {
// fail soft and hide the coding rail instead of spamming IPC handler errors.
try {
const stat = await fs.stat(cwd)
if (!stat.isDirectory()) {
return null
}
@@ -600,13 +615,11 @@ async function repoStatus(repoPath, gitBin) {
}
let git
try {
git = gitFor(cwd, gitBin)
} catch {
return null
}
let status
try {
@@ -617,7 +630,6 @@ async function repoStatus(repoPath, gitBin) {
}
const detached = typeof status.detached === 'boolean' ? status.detached : !status.current
const files = status.files.map(file => ({
path: file.path,
staged: isStaged(file),
@@ -659,12 +671,10 @@ async function repoStatus(repoPath, gitBin) {
// can't stall the probe.
try {
const untracked = status.not_added.slice(0, 500)
for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
const batch = await Promise.all(
untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path))
)
result.added += batch.reduce((sum, n) => sum + n, 0)
}
} catch {
@@ -674,7 +684,7 @@ async function repoStatus(repoPath, gitBin) {
return result
}
export {
module.exports = {
branchBase,
fileDiffVsHead,
repoStatus,
@@ -685,8 +695,8 @@ export {
reviewDiff,
reviewList,
reviewPush,
reviewRevert,
reviewRevParse,
reviewRevert,
reviewShipInfo,
reviewStage,
reviewUnstage

View File

@@ -1,7 +1,9 @@
import assert from 'node:assert/strict'
import test from 'node:test'
'use strict'
import { resolveRenamePath } from './git-review-ops'
const assert = require('node:assert/strict')
const test = require('node:test')
const { resolveRenamePath } = require('./git-review-ops.cjs')
test('resolveRenamePath: plain path is unchanged', () => {
assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts')

View File

@@ -1,7 +1,8 @@
import fs from 'node:fs'
import path from 'node:path'
'use strict'
import { resolveRequestedPathForIpc } from './hardening'
const fs = require('node:fs')
const path = require('node:path')
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
function findGitRoot(start, fsImpl = fs) {
let dir = start
@@ -27,7 +28,7 @@ function findGitRoot(start, fsImpl = fs) {
return null
}
async function gitRootForIpc(startPath, options: { fs?: typeof fs } = {}) {
async function gitRootForIpc(startPath, options = {}) {
const fsImpl = options.fs || fs
let resolved
@@ -47,4 +48,7 @@ async function gitRootForIpc(startPath, options: { fs?: typeof fs } = {}) {
}
}
export { findGitRoot, gitRootForIpc }
module.exports = {
findGitRoot,
gitRootForIpc
}

View File

@@ -1,11 +1,13 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { pathToFileURL } from 'node:url'
'use strict'
import { gitRootForIpc } from './git-root'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { pathToFileURL } = require('node:url')
const { gitRootForIpc } = require('./git-root.cjs')
function mkTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-'))

View File

@@ -1,14 +1,16 @@
'use strict'
// Git-driven worktree operations for the desktop "Start work" flow: spin up a
// fresh worktree the lightest way (`git worktree add -b`), list real worktrees,
// and remove them. Git is the source of truth; the renderer just drives these.
import { execFile } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
const path = require('node:path')
const fs = require('node:fs')
const { execFile } = require('node:child_process')
import { resolveRequestedPathForIpc } from './hardening'
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
function runGit(gitBin, args, cwd): Promise<string> {
function runGit(gitBin, args, cwd) {
return new Promise((resolve, reject) => {
execFile(
gitBin,
@@ -304,7 +306,6 @@ async function listBranches(repoPath, gitBin) {
['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'],
resolved
)
const trees = await listWorktrees(resolved, gitBin)
const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path]))
const trunk = await defaultBranch(gitBin, resolved)
@@ -337,7 +338,7 @@ async function switchBranch(repoPath, branch, gitBin) {
return { branch: target }
}
export {
module.exports = {
addWorktree,
ensureGitRepo,
listBranches,

View File

@@ -1,18 +1,20 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
'use strict'
import {
const assert = require('node:assert/strict')
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const {
addWorktree,
ensureGitRepo,
listBranches,
parseWorktrees,
sanitizeBranch,
switchBranch
} from './git-worktree-ops'
} = require('./git-worktree-ops.cjs')
test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => {
assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes')

View File

@@ -1,7 +1,7 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { fileURLToPath } = require('node:url')
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024
@@ -13,7 +13,6 @@ const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx'])
function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) {
const fallback =
Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS
const parsed = Number(timeoutMs)
if (Number.isFinite(parsed) && parsed > 0) {
@@ -63,7 +62,6 @@ function sensitiveFileBlockReason(filePath) {
const normalized = String(filePath || '')
.replace(/\\/g, '/')
.toLowerCase()
const basename = path.basename(normalized)
const ext = path.extname(basename)
@@ -89,7 +87,6 @@ function sensitiveFileBlockReason(filePath) {
if (basename.startsWith('.env.')) {
const suffix = basename.slice('.env.'.length)
if (!SAFE_ENV_SUFFIXES.has(suffix)) {
return `${basename} is blocked because it appears to contain environment secrets.`
}
@@ -110,10 +107,9 @@ function sensitiveFileBlockReason(filePath) {
return null
}
function ipcPathError(code: any, message: string): Error & { code: any } {
const error = new Error(message) as Error & { code: any }
;(error as any).code = code
function ipcPathError(code, message) {
const error = new Error(message)
error.code = code
return error
}
@@ -133,7 +129,6 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
}
const normalized = raw.replace(/\\/g, '/').toLowerCase()
if (
normalized.startsWith('//?/') ||
normalized.startsWith('//./') ||
@@ -146,7 +141,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
return raw
}
function resolveRequestedPathForIpc(filePath, options: { purpose?: string; baseDir?: fs.PathOrFileDescriptor } = {}) {
function resolveRequestedPathForIpc(filePath, options = {}) {
const purpose = String(options.purpose || 'File read')
let raw = rejectUnsafePathSyntax(filePath, purpose)
@@ -159,21 +154,17 @@ function resolveRequestedPathForIpc(filePath, options: { purpose?: string; baseD
if (/^file:/i.test(raw)) {
let resolvedPath
try {
const parsed = new URL(raw)
if (parsed.protocol !== 'file:') {
throw new Error('not a file URL')
}
resolvedPath = fileURLToPath(parsed)
} catch {
throw ipcPathError('invalid-path', `${purpose} failed: file URL is invalid.`)
}
rejectUnsafePathSyntax(resolvedPath, purpose)
return path.resolve(resolvedPath)
}
@@ -187,16 +178,14 @@ function resolveRequestedPathForIpc(filePath, options: { purpose?: string; baseD
return resolvedPath
}
async function statForIpc(fsImpl: { promises: { stat: typeof fs.promises.stat } }, resolvedPath, purpose, typeLabel) {
async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) {
try {
return await fsImpl.promises.stat(resolvedPath)
} catch (error) {
const code = error && typeof error === 'object' ? error.code : ''
if (code === 'ENOENT' || code === 'ENOTDIR') {
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
}
throw ipcPathError(
code || 'read-error',
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
@@ -212,7 +201,6 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) {
try {
const realPath = await fsImpl.promises.realpath(resolvedPath)
rejectUnsafePathSyntax(realPath, purpose)
return realPath
} catch (error) {
const code = error && typeof error === 'object' ? error.code : ''
@@ -225,20 +213,12 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) {
function rejectSensitiveFilePath(filePath, purpose) {
const blockReason = sensitiveFileBlockReason(filePath)
if (blockReason) {
throw ipcPathError('sensitive-file', `${purpose} blocked for sensitive file: ${blockReason}`)
}
}
async function resolveDirectoryForIpc(
dirPath,
options: {
purpose?: string
baseDir?: fs.PathOrFileDescriptor
fs?: { promises: { stat: typeof fs.promises.stat } }
} = {}
) {
async function resolveDirectoryForIpc(dirPath, options = {}) {
const purpose = String(options.purpose || 'Directory read')
const fsImpl = options.fs || fs
const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose })
@@ -253,16 +233,7 @@ async function resolveDirectoryForIpc(
return { realPath, resolvedPath, stat }
}
async function resolveReadableFileForIpc(
filePath,
options: {
purpose?: string
baseDir?: fs.PathOrFileDescriptor
fs?: typeof fs
blockSensitive?: boolean
maxBytes?: number
} = {}
) {
async function resolveReadableFileForIpc(filePath, options = {}) {
const purpose = String(options.purpose || 'File read')
const fsImpl = options.fs || fs
const resolvedPath = resolveRequestedPathForIpc(filePath, { baseDir: options.baseDir, purpose })
@@ -282,13 +253,11 @@ async function resolveReadableFileForIpc(
}
const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose)
if (options.blockSensitive !== false) {
rejectSensitiveFilePath(realPath, purpose)
}
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
if (maxBytes && stat.size > maxBytes) {
throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
}
@@ -302,15 +271,15 @@ async function resolveReadableFileForIpc(
return { realPath, resolvedPath, stat }
}
export {
module.exports = {
DATA_URL_READ_MAX_BYTES,
DEFAULT_FETCH_TIMEOUT_MS,
TEXT_PREVIEW_SOURCE_MAX_BYTES,
encryptDesktopSecret,
rejectUnsafePathSyntax,
resolveDirectoryForIpc,
resolveReadableFileForIpc,
resolveRequestedPathForIpc,
resolveTimeoutMs,
sensitiveFileBlockReason,
TEXT_PREVIEW_SOURCE_MAX_BYTES
sensitiveFileBlockReason
}

View File

@@ -1,11 +1,11 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { pathToFileURL } from 'node:url'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { pathToFileURL } = require('node:url')
import {
const {
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret,
resolveDirectoryForIpc,
@@ -13,12 +13,11 @@ import {
resolveRequestedPathForIpc,
resolveTimeoutMs,
sensitiveFileBlockReason
} from './hardening'
} = require('./hardening.cjs')
async function rejectsWithCode(promise, code: string) {
await assert.rejects(promise, (error: any) => {
async function rejectsWithCode(promise, code) {
await assert.rejects(promise, error => {
assert.equal(error?.code, code)
return true
})
}
@@ -77,9 +76,8 @@ test('path helpers reject blank non-string NUL and Windows device syntax', async
for (const devicePath of devicePaths) {
assert.throws(
() => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }),
(error: any) => {
error => {
assert.equal(error?.code, 'device-path')
return true
}
)
@@ -88,9 +86,8 @@ test('path helpers reject blank non-string NUL and Windows device syntax', async
assert.throws(
() => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }),
(error: any) => {
error => {
assert.equal(error?.code, 'invalid-path')
return true
}
)
@@ -134,23 +131,19 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
maxBytes: 256,
purpose: 'File preview'
})
assert.equal(fromRelative.resolvedPath, textPath)
assert.equal(fromRelative.stat.size, 11)
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
purpose: 'File preview'
})
assert.equal(fromFileUrl.resolvedPath, textPath)
const spacedPath = path.join(tempDir, 'notes with spaces.txt')
fs.writeFileSync(spacedPath, 'space ok', 'utf8')
const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), {
purpose: 'File preview'
})
assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath)
await assert.rejects(
@@ -191,11 +184,9 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity',
const envTemplatePath = path.join(tempDir, '.env.example')
fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
purpose: 'File preview'
})
assert.equal(envTemplate.resolvedPath, envTemplatePath)
})
@@ -238,10 +229,8 @@ test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', as
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
return
}
throw error
}
@@ -279,10 +268,8 @@ test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t =
} catch (error) {
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
return
}
throw error
}

View File

@@ -1,9 +1,11 @@
'use strict'
// Hidden BrowserWindow used by tier-2 link-title resolution: when curl can't
// read a page <title> (bot walls, JS-rendered pages), we briefly load the URL
// in an offscreen window and read its title. That window loads arbitrary
// user-linked pages, so it must never emit sound or trigger real downloads.
export function linkTitleWindowOptions(partitionSession) {
function linkTitleWindowOptions(partitionSession) {
return {
show: false,
width: 1280,
@@ -23,7 +25,7 @@ export function linkTitleWindowOptions(partitionSession) {
// Create the offscreen title-fetch window and immediately mute it. Without the
// mute, autoplaying media on the loaded page (e.g. a YouTube link) leaks ~2s of
// audio every time a session containing such links is re-rendered. See #49505.
export function createLinkTitleWindow(BrowserWindow, partitionSession) {
function createLinkTitleWindow(BrowserWindow, partitionSession) {
const window = new BrowserWindow(linkTitleWindowOptions(partitionSession))
try {
@@ -39,7 +41,7 @@ export function createLinkTitleWindow(BrowserWindow, partitionSession) {
// Cancel any download the title-fetch window triggers. Without this, a link
// artifact URL served with Content-Disposition: attachment auto-downloads every
// time the Artifacts page renders and fetchLinkTitle loads it.
export function guardLinkTitleSession(partitionSession) {
function guardLinkTitleSession(partitionSession) {
try {
partitionSession.on('will-download', (_event, item) => item.cancel())
} catch {
@@ -50,19 +52,20 @@ export function guardLinkTitleSession(partitionSession) {
// Read the page title from a title-fetch window. Callers schedule this from
// timers that can fire after finish() destroys the window, so every access must
// guard isDestroyed and swallow Electron's "Object has been destroyed" throws.
export function readLinkTitleWindowTitle(window) {
function readLinkTitleWindowTitle(window) {
try {
if (!window || window.isDestroyed()) {
return ''
}
if (!window || window.isDestroyed()) return ''
const contents = window.webContents
if (!contents || contents.isDestroyed()) {
return ''
}
if (!contents || contents.isDestroyed()) return ''
return contents.getTitle() || ''
} catch {
return ''
}
}
module.exports = {
createLinkTitleWindow,
guardLinkTitleSession,
linkTitleWindowOptions,
readLinkTitleWindowTitle
}

View File

@@ -1,16 +1,15 @@
import assert from 'node:assert/strict'
import test from 'node:test'
const assert = require('node:assert/strict')
const test = require('node:test')
import {
const {
createLinkTitleWindow,
guardLinkTitleSession,
linkTitleWindowOptions,
readLinkTitleWindowTitle
} from './link-title-window'
} = require('./link-title-window.cjs')
function makeFakeBrowserWindow() {
const calls = { audioMuted: [] }
const FakeBrowserWindow = function (options) {
this.options = options
this.webContents = {

View File

@@ -14,4 +14,7 @@ function setJsonRequestHeaders(request) {
request.setHeader('Content-Type', 'application/json')
}
export { serializeJsonBody, setJsonRequestHeaders }
module.exports = {
serializeJsonBody,
setJsonRequestHeaders
}

View File

@@ -1,13 +1,13 @@
/**
* Tests for OAuth-session Electron net.request helpers.
*
* Run with: node --test electron/oauth-net-request.test.ts
* Run with: node --test electron/oauth-net-request.test.cjs
*/
import assert from 'node:assert/strict'
import test from 'node:test'
const test = require('node:test')
const assert = require('node:assert/strict')
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
test('serializeJsonBody returns undefined for absent bodies', () => {
assert.equal(serializeJsonBody(undefined), undefined)
@@ -21,7 +21,6 @@ test('serializeJsonBody JSON-encodes request bodies', () => {
test('setJsonRequestHeaders does not set Electron-restricted Content-Length', () => {
const headers = []
const request = {
setHeader(name, value) {
headers.push([name, value])

View File

@@ -1,33 +0,0 @@
/**
* Regression coverage for the OAuth-session Electron net.request path.
*
* Electron net rejects manual Content-Length/Host headers with
* net::ERR_INVALID_ARGUMENT. Node HTTP helpers may still set Content-Length;
* this guard is scoped to fetchJsonViaOauthSession only.
*/
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
function extractFetchJsonViaOauthSession() {
const start = source.indexOf('function fetchJsonViaOauthSession')
const end = source.indexOf('// Mint a single-use WS ticket', start)
assert.notEqual(start, -1, 'fetchJsonViaOauthSession should exist')
assert.notEqual(end, -1, 'fetchJsonViaOauthSession boundary should exist')
return source.slice(start, end)
}
test('OAuth Electron net request does not set forbidden Content-Length header', () => {
const fn = extractFetchJsonViaOauthSession()
assert.match(fn, /electronNet\.request/)
assert.doesNotMatch(fn, /setHeader\(['"]Content-Length['"]/)
assert.match(fn, /request\.write\(body\)/)
})

View File

@@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer, webUtils } from 'electron'
const { contextBridge, ipcRenderer, webUtils } = require('electron')
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
@@ -24,14 +24,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
onState: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:pet-overlay:state', listener)
return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener)
},
// Main renderer subscribes to overlay control messages.
onControl: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:pet-overlay:control', listener)
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
}
},
@@ -80,19 +78,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
pickDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:pick')
},
zoom: {
// Current zoom of this window, as { level, percent }.
get: () => ipcRenderer.invoke('hermes:zoom:get'),
setPercent: percent => ipcRenderer.send('hermes:zoom:set-percent', percent),
// Fires on every zoom change, including the Ctrl/Cmd +/-/0 shortcuts,
// so the settings UI can stay in sync with the keyboard.
onChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:zoom:changed', listener)
return () => ipcRenderer.removeListener('hermes:zoom:changed', listener)
}
},
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
@@ -135,80 +120,68 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
const channel = `hermes:terminal:${id}:data`
const listener = (_event, payload) => callback(payload)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener)
},
onExit: (id, callback) => {
const channel = `hermes:terminal:${id}:exit`
const listener = (_event, payload) => callback(payload)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener)
}
},
onClosePreviewRequested: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:close-preview-requested', listener)
return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener)
},
onOpenUpdatesRequested: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:open-updates', listener)
return () => ipcRenderer.removeListener('hermes:open-updates', listener)
},
onDeepLink: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:deep-link', listener)
return () => ipcRenderer.removeListener('hermes:deep-link', listener)
},
signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'),
onWindowStateChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:window-state-changed', listener)
return () => ipcRenderer.removeListener('hermes:window-state-changed', listener)
},
onFocusSession: callback => {
const listener = (_event, sessionId) => callback(sessionId)
ipcRenderer.on('hermes:focus-session', listener)
return () => ipcRenderer.removeListener('hermes:focus-session', listener)
},
onNotificationAction: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:notification-action', listener)
return () => ipcRenderer.removeListener('hermes:notification-action', listener)
},
onPreviewFileChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:preview-file-changed', listener)
return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener)
},
onBackendExit: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:backend-exit', listener)
return () => ipcRenderer.removeListener('hermes:backend-exit', listener)
},
onPowerResume: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:power-resume', listener)
return () => ipcRenderer.removeListener('hermes:power-resume', listener)
},
onBootProgress: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:boot-progress', listener)
return () => ipcRenderer.removeListener('hermes:boot-progress', listener)
},
// First-launch bootstrap progress -- emitted by the install.ps1 stage
// runner in main.ts (apps/desktop/electron/bootstrap-runner.ts).
// runner in main.cjs (apps/desktop/electron/bootstrap-runner.cjs).
// Renderer's install overlay subscribes to live events and queries the
// current snapshot via getBootstrapState() to recover after a devtools
// reload mid-bootstrap.
@@ -219,7 +192,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
onBootstrapEvent: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:bootstrap:event', listener)
return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener)
},
getVersion: () => ipcRenderer.invoke('hermes:version'),
@@ -236,7 +208,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
onProgress: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:updates:progress', listener)
return () => ipcRenderer.removeListener('hermes:updates:progress', listener)
}
},

View File

@@ -1,11 +1,11 @@
'use strict'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ELECTRON_DIR = import.meta.dirname
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
@@ -17,7 +17,7 @@ function readElectronFile(name) {
// ---------------------------------------------------------------------------
test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
const source = readElectronFile('main.ts')
const source = readElectronFile('main.cjs')
// Locate the function definition and its closing brace.
const fnStart = source.indexOf('async function prepareProfileDeleteRequest(')
@@ -36,7 +36,7 @@ test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
})
test('hermes:api handler routes profile-delete requests to the primary backend', () => {
const source = readElectronFile('main.ts')
const source = readElectronFile('main.cjs')
// The handler must capture prepareProfileDeleteRequest's return value.
assert.match(

View File

@@ -1,9 +1,9 @@
// Secondary "session windows" — one extra OS window per chat so a user can
// work with multiple chats side by side. The pure, Electron-free pieces live
// here so they can be unit-tested with node --test (mirroring how the rest of
// electron/*.ts splits testable logic out of the main.ts monolith).
// electron/*.cjs splits testable logic out of the main.cjs monolith).
import { pathToFileURL } from 'node:url'
const { pathToFileURL } = require('node:url')
// Secondary windows open at the minimum usable size — a compact side panel for
// subagent watch / cmd-click session pop-out, not a second full desktop.
@@ -12,7 +12,7 @@ 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.ts used to be
// 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.
//
@@ -21,7 +21,7 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// 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: string) {
function chatWindowWebPreferences(preloadPath) {
return {
preload: preloadPath,
contextIsolation: true,
@@ -42,7 +42,7 @@ function chatWindowWebPreferences(preloadPath: string) {
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
// session): the renderer resumes it lazily so the gateway never builds an agent
// just to stream into it.
function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch, newSession }: any = {}) {
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) {
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
@@ -115,7 +115,7 @@ function createSessionWindowRegistry() {
}
}
export {
module.exports = {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,

View File

@@ -1,7 +1,11 @@
import assert from 'node:assert/strict'
import test from 'node:test'
const assert = require('node:assert/strict')
const test = require('node:test')
import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows'
const {
buildSessionWindowUrl,
chatWindowWebPreferences,
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
@@ -92,7 +96,6 @@ test('registry opens one window per session and focuses on re-open', () => {
const registry = createSessionWindowRegistry()
let built = 0
const win = makeFakeWindow()
const factory = () => {
built += 1
@@ -142,7 +145,6 @@ test('registry rebuilds a fresh window after the previous one was destroyed', ()
let built = 0
const second = makeFakeWindow()
const result = registry.openOrFocus('s1', () => {
built += 1
@@ -156,7 +158,6 @@ test('registry rebuilds a fresh window after the previous one was destroyed', ()
test('registry ignores empty / non-string session ids', () => {
const registry = createSessionWindowRegistry()
let built = 0
const factory = () => {
built += 1

View File

@@ -1,4 +1,6 @@
export const OVERLAY_FALLBACK_WIDTH = 144
'use strict'
const OVERLAY_FALLBACK_WIDTH = 144
/**
* Static pre-layout reservation (px) for the right-side native window-controls
@@ -14,18 +16,15 @@ export const OVERLAY_FALLBACK_WIDTH = 144
*
* @param {{ isMac?: boolean }} opts
*/
export function nativeOverlayWidth({ isWindows = false, isWsl = false, isMac = false } = {}) {
if (isMac) {
return 0
}
function nativeOverlayWidth({ isMac = false } = {}) {
if (isMac) return 0
return OVERLAY_FALLBACK_WIDTH
}
// macOS Tahoe ships as Darwin 25 (Sequoia is 24); the Darwin number is truthful,
// unlike the product version which macOS reports as 16 or 26 depending on the
// build SDK.
export const MACOS_TAHOE_DARWIN_MAJOR = 25
const MACOS_TAHOE_DARWIN_MAJOR = 25
/**
* Height (px) to pass to `titleBarOverlay` on macOS. Tahoe (Darwin 25+)
@@ -37,6 +36,8 @@ export const MACOS_TAHOE_DARWIN_MAJOR = 25
*
* @param {{ darwinMajor?: number, titlebarHeight?: number }} opts
*/
export function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) {
function macTitleBarOverlayHeight({ darwinMajor = 0, titlebarHeight = 0 } = {}) {
return darwinMajor >= MACOS_TAHOE_DARWIN_MAJOR ? 0 : titlebarHeight
}
module.exports = { MACOS_TAHOE_DARWIN_MAJOR, OVERLAY_FALLBACK_WIDTH, macTitleBarOverlayHeight, nativeOverlayWidth }

View File

@@ -1,12 +1,12 @@
import assert from 'node:assert/strict'
import test from 'node:test'
const assert = require('node:assert/strict')
const test = require('node:test')
import {
const {
MACOS_TAHOE_DARWIN_MAJOR,
OVERLAY_FALLBACK_WIDTH,
macTitleBarOverlayHeight,
nativeOverlayWidth,
OVERLAY_FALLBACK_WIDTH
} from './titlebar-overlay-width'
nativeOverlayWidth
} = require('./titlebar-overlay-width.cjs')
// This static reservation is only the pre-layout FALLBACK. Once laid out the
// renderer reads the exact width from navigator.windowControlsOverlay

View File

@@ -1,3 +1,5 @@
'use strict'
// Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful
// number worth computing. On a SHALLOW checkout (installer clones with
// --depth 1) the local history often shares no merge-base with the freshly
@@ -17,14 +19,10 @@ function shouldCountCommits({ isShallow, hasMergeBase }) {
// (developers / Docker dev images) keep the exact count path unchanged.
function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) {
if (!shouldCountCommits({ isShallow, hasMergeBase })) {
if (currentSha && targetSha && currentSha === targetSha) {
return 0
}
if (currentSha && targetSha && currentSha === targetSha) return 0
return 1 // behind by an unknown amount — show a generic "update available"
}
return Number.parseInt(countStr, 10) || 0
}
export { resolveBehindCount, shouldCountCommits }
module.exports = { resolveBehindCount, shouldCountCommits }

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveBehindCount, shouldCountCommits } from './update-count'
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
// FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0`
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus

View File

@@ -16,20 +16,20 @@
*
* This module holds the PURE, side-effect-light logic (path, pid liveness,
* parse + staleness) so it is unit-testable without booting Electron. The
* polling/boot-progress wrapper lives in main.ts where the boot-progress and
* polling/boot-progress wrapper lives in main.cjs where the boot-progress and
* log sinks are.
*/
import fs from 'fs'
import path from 'path'
const fs = require('fs')
const path = require('path')
// Even with a live-looking PID, never treat a marker older than this as a live
// update. A full update (git pull + pip + desktop rebuild) is minutes, not tens
// of minutes; past this the marker is almost certainly stale (e.g. the OS
// recycled the pid onto an unrelated process), so the gate self-heals.
export const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000
const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000
export function markerPath(hermesHome) {
function markerPath(hermesHome) {
return path.join(hermesHome, '.hermes-update-in-progress')
}
@@ -37,14 +37,10 @@ export function markerPath(hermesHome) {
// not deliver a signal — it just probes existence/permission. ESRCH => dead;
// EPERM => alive but owned by another user (still "alive" for our purposes).
// Injectable `kill` keeps it unit-testable.
export function isPidAlive(pid, kill: typeof process.kill = process.kill.bind(process)) {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
function isPidAlive(pid, kill = process.kill.bind(process)) {
if (!Number.isInteger(pid) || pid <= 0) return false
try {
kill(pid, 0)
return true
} catch (err) {
return Boolean(err && err.code === 'EPERM')
@@ -63,21 +59,9 @@ export function isPidAlive(pid, kill: typeof process.kill = process.kill.bind(pr
* Pure-ish: file I/O against the given path, plus an injectable pid probe and
* clock for tests.
*/
export function readLiveUpdateMarker(
hermesHome,
{
kill,
now = Date.now,
maxAgeMs = UPDATE_MARKER_MAX_AGE_MS
}: {
now?: () => number
maxAgeMs?: number
kill?: typeof process.kill
} = {}
) {
function readLiveUpdateMarker(hermesHome, { kill, now = Date.now, maxAgeMs = UPDATE_MARKER_MAX_AGE_MS } = {}) {
const file = markerPath(hermesHome)
let raw
try {
raw = fs.readFileSync(file, 'utf8')
} catch {
@@ -96,43 +80,14 @@ export function readLiveUpdateMarker(
} catch {
void 0
}
return null
}
return { pid, ageMs }
}
/**
* Write the update-in-progress marker *from the desktop* before handing off
* to the detached updater.
*
* The Tauri-based hermes-setup.exe takes several seconds to initialise its
* window and reach the Rust `run_update` entry point where it writes the
* marker itself. During that gap the desktop's `app.quit()` teardown kills
* the backend child, the renderer's WebSocket drops, and the renderer
* immediately calls `ensureBackend()` `waitForUpdateToFinish()`. Because
* the updater hasn't written the marker yet, the gate sees no live update
* and spawns a *new* backend which re-locks `.pyd` files in the venv.
* When the updater finally reaches the venv-rebuild stage it finds those
* files locked and the update bricks.
*
* Fix: the desktop writes the marker itself, using the spawned updater's
* PID, immediately after `spawn()`. The updater's `UpdateMarkerGuard` will
* later overwrite it with its own PID that's fine, the marker body is
* the same format and `readLiveUpdateMarker` only cares that *some* live
* pid owns it. When the updater finishes it deletes the marker as before.
* If the updater never starts (spawn failure) the marker still contains a
* real PID, so `readLiveUpdateMarker` will self-heal once that PID exits.
*/
export function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) {
const file = markerPath(hermesHome)
const startedAt = Math.floor(now() / 1000)
try {
fs.writeFileSync(file, `${pid}\n${startedAt}\n`, 'utf8')
} catch {
// Best-effort: if we can't write the marker, proceed anyway. The
// updater will write its own when it reaches run_update.
}
module.exports = {
UPDATE_MARKER_MAX_AGE_MS,
markerPath,
isPidAlive,
readLiveUpdateMarker
}

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