diff --git a/.dockerignore b/.dockerignore index ee3947b2533a..f6fbbc9f137c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -63,3 +63,45 @@ data/ # Compose/profile runtime state (bind-mounted; avoid ownership/secret issues) hermes-config/ runtime/ + +# ---------- Not needed inside the Docker image ---------- + +# Desktop app source (Tauri/Electron); never installed in the container +apps/ + +# Test suite — not shipped in production images +tests/ + +# Documentation site (Docusaurus) and supplementary docs +website/ +docs/ + +# Assets only used by the GitHub README +assets/ +infographic/ + +# Plugin-level docs (hermes-achievements ships docs/ but the runtime doesn't read them) +plugins/hermes-achievements/docs/ + +# Nix / Homebrew / AUR packaging metadata — irrelevant to Docker +nix/ +flake.nix +flake.lock +packaging/ + +# Design and planning documents +plans/ +.plans/ + +# ACP registry manifest (icon + agent.json) — not consumed at runtime +acp_registry/ + +# Repo-level dotfiles that are git-only or dev-tooling config +.env.example +.envrc +.gitattributes +.hadolint.yaml +.mailmap + +# Top-level LICENSE (not matched by *.md); not needed inside the container +LICENSE diff --git a/.github/pr-screenshots/telegram-overflow/topic-final-response-clipped.jpg b/.github/pr-screenshots/telegram-overflow/topic-final-response-clipped.jpg new file mode 100644 index 000000000000..2f3529648e79 Binary files /dev/null and b/.github/pr-screenshots/telegram-overflow/topic-final-response-clipped.jpg differ diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 9b3e6426652c..5b3c61db8fb3 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -44,7 +44,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 20 + node-version: 22 cache: npm cache-dependency-path: website/package-lock.json diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml index 49111b5ac095..7001c0b74393 100644 --- a/.github/workflows/docs-site-checks.yml +++ b/.github/workflows/docs-site-checks.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 20 + node-version: 22 cache: npm cache-dependency-path: website/package-lock.json diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2755641073a1..cc7d099fd934 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -55,15 +55,31 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + with: + # Persist uv's download/wheel cache (~/.cache/uv) across runs. + # Keyed on the dependency manifests, so the cache is reused until + # pyproject.toml or uv.lock changes. `uv sync` still runs every + # time, but resolves from the warm cache instead of re-downloading + # and re-building wheels. + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock - name: Set up Python 3.11 run: uv python install 3.11 - name: Install dependencies - run: | - uv venv .venv --python 3.11 - source .venv/bin/activate - uv pip install -e ".[all,dev]" + # `uv sync --locked` installs the exact pinned set from uv.lock (and + # fails if the lock is out of sync with pyproject.toml), giving a + # reproducible env. It also creates .venv itself, so no separate + # `uv venv` step is needed. + run: uv sync --locked --python 3.11 --extra all --extra dev + + - name: Minimize uv cache + # Optimized for CI: prunes pre-built wheels that are cheap to + # re-download, keeping the persisted cache small and fast to restore. + run: uv cache prune --ci - name: Run tests (slice ${{ matrix.slice }}/6) # Per-file isolation via scripts/run_tests_parallel.py: discovers @@ -161,15 +177,31 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + with: + # Persist uv's download/wheel cache (~/.cache/uv) across runs. + # Keyed on the dependency manifests, so the cache is reused until + # pyproject.toml or uv.lock changes. `uv sync` still runs every + # time, but resolves from the warm cache instead of re-downloading + # and re-building wheels. + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock - name: Set up Python 3.11 run: uv python install 3.11 - name: Install dependencies - run: | - uv venv .venv --python 3.11 - source .venv/bin/activate - uv pip install -e ".[all,dev]" + # `uv sync --locked` installs the exact pinned set from uv.lock (and + # fails if the lock is out of sync with pyproject.toml), giving a + # reproducible env. It also creates .venv itself, so no separate + # `uv venv` step is needed. + run: uv sync --locked --python 3.11 --extra all --extra dev + + - name: Minimize uv cache + # Optimized for CI: prunes pre-built wheels that are cheap to + # re-download, keeping the persisted cache small and fast to restore. + run: uv cache prune --ci - name: Packaged-wheel i18n smoke test run: | diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 000000000000..f3dcc71efdb4 --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,25 @@ +# .github/workflows/typecheck.yml +name: Typecheck + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + typecheck: + runs-on: ubuntu-latest + strategy: + matrix: + package: + [ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared] + fail-fast: false # report all failures, not just the first one + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run --prefix ${{ matrix.package }} typecheck diff --git a/.gitignore b/.gitignore index 1efce4b83f1a..2935832db3be 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,9 @@ website/static/api/skills-index.json # every build). website/static/api/skills.json website/static/api/skills-meta.json +# automation-blueprints-index.json is a build artifact emitted by +# website/scripts/extract-automation-blueprints.py during prebuild. +website/static/api/automation-blueprints-index.json models-dev-upstream/ # Local editor / agent tooling (machine-specific; keep in global config, not the repo) @@ -114,6 +117,12 @@ docs/superpowers/* # treat it as a local edit and autostash it on every run (#38529). .hermes-bootstrap-complete +# Interrupted-update breadcrumb + recovery lock written next to the shared venv +# by `hermes update` / launch-time self-heal. Runtime state, never a code change +# — ignore so `git status` stays clean and update's autostash skips them. +.update-incomplete +.update-incomplete.lock + # Tool Search live-test harness output — non-deterministic model transcripts, # regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo. scripts/out/ diff --git a/AGENTS.md b/AGENTS.md index 15cd7536ef13..e032f7654474 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,201 @@ Instructions for AI coding assistants and developers working on the hermes-agent **Never give up on the right solution.** +## What Hermes Is + +Hermes is a personal AI agent that runs the same agent core across a CLI, a +messaging gateway (Telegram, Discord, Slack, and ~20 other platforms), a TUI, +and an Electron desktop app. It learns across sessions (memory + skills), +delegates to subagents, runs scheduled jobs, and drives a real terminal and +browser. It is extended primarily through **plugins and skills**, not by +growing the core. + +Two properties shape almost every design decision and are the lens for +reviewing any change: + +- **Per-conversation prompt caching is sacred.** A long-lived conversation + reuses a cached prefix every turn. Anything that mutates past context, + swaps toolsets, or rebuilds the system prompt mid-conversation invalidates + that cache and multiplies the user's cost. We do not do it (the one + exception is context compression). +- **The core is a narrow waist; capability lives at the edges.** Every model + tool we add is sent on every API call, so the bar for a new *core* tool is + high. Most new capability should arrive as a CLI command + skill, a + service-gated tool, or a plugin — not as core surface. + +## Contribution Rubric — What We Want / What We Don't + +This is the project's intent layer. Use it two ways: + +1. **For humans and for your own work** — what gets merged and what gets + rejected, so a contribution aims at the target. +2. **For automated review (the triage sweeper)** — guidance on when a PR is + safe to close on the three allowed reasons (`implemented_on_main`, + `cannot_reproduce`, `incoherent`) and, just as important, **when NOT to + close** one. Taste-based "we don't want this / out of scope" closes are NOT + an automated decision — those stay with a human maintainer. The sweeper's + job here is to recognize design intent and *avoid wrongly closing a + legitimate contribution*, not to make the won't-implement call itself. + +Read the balance right: Hermes ships a **lot** — most merges are bug fixes to +real reported behavior, and the product surface (platforms, channels, +providers, models, desktop/TUI features) expands aggressively and on purpose. +The restraint below is aimed squarely at the **core agent + the model tool +schema**, the one place where every addition is paid for on every API call. +"Smallest footprint" governs *how a capability is wired into the core*, NOT +whether the product is allowed to grow. We are expansive at the edges and +conservative at the waist. + +### What we want + +- **Fix real bugs, well.** The bulk of what lands is `fix(...)` against an + actual reported symptom. A good fix reproduces the symptom on current + `main`, points to the exact line where it manifests, and fixes the whole bug + class — sibling call paths included — not just the one site the reporter hit. +- **Expand reach at the edges.** New platform adapters, channels, providers, + models, and desktop/TUI/dashboard features are welcome and land routinely, + including large ones (a new messaging channel, a session-cap feature, a + Windows PTY bridge). Breadth in the product is a goal, not a footprint + concern — as long as it integrates with the existing setup/config UX + (`hermes tools`, `hermes setup`, auto-install) rather than bolting on a raw + env var. +- **Refactor god-files into clean modules.** Extracting a multi-thousand-line + cluster out of `cli.py` / `run_agent.py` / `gateway/run.py` into a focused + mixin or module is wanted work, even when the diff is huge and mechanical + (large `+N/-N` refactors merge regularly). The "every line traces to the + request" test applies to *feature* PRs; a declared refactor's request IS the + extraction. +- **Keep the core narrow.** New *model tools* are the expensive exception — + every tool ships on every API call. Prefer, in order: extend existing code → + CLI command + skill → service-gated tool (`check_fn`) → plugin → MCP server + in the catalog → new core tool (last resort). See "The Footprint Ladder." +- **Extend, don't duplicate.** Before adding a module/manager/hook, check + whether existing infrastructure already covers the use case. When several PRs + integrate the same *category*, design one shared interface instead of merging + them one at a time (see the ABC + orchestrator note under the Footprint + Ladder). +- **Behavior contracts over snapshots.** Tests should assert how two pieces of + data must relate (invariants), not freeze a current value (model lists, + config version literals, enumeration counts). See "Don't write + change-detector tests." +- **E2E validation, not just green unit mocks.** For anything touching + resolution chains, config propagation, security boundaries, remote + backends, or file/network I/O, exercise the real path with real imports + against a temp `HERMES_HOME`. Mocks hide integration bugs. +- **Cache-, alternation-, and invariant-safe.** Preserve prompt caching, strict + message role alternation (never two same-role messages in a row; never a + synthetic user message injected mid-loop), and a system prompt that is + byte-stable for the life of a conversation. +- **Contributor credit preserved.** Salvage external work by cherry-picking + (rebase-merge) so authorship survives in git history; don't reimplement from + scratch when you can build on top. + +### What we don't want (rejected even when well-built) + +- **Speculative infrastructure.** Hooks, callbacks, or extension points with no + concrete consumer. Adding a hook is easy; removing one after plugins depend + on it is hard. A hook is NOT speculative if a contributor has a real, stated + use case — even if the consumer ships separately. +- **New `HERMES_*` env vars for non-secret config.** `.env` is for secrets + only (API keys, tokens, passwords). All behavioral settings — timeouts, + thresholds, feature flags, display prefs — go in `config.yaml`. Bridge to an + internal env var if the mechanism needs one, but user-facing docs point to + `config.yaml`. Reject PRs that tell users to "set X in your .env" unless X + is a credential. +- **A new core tool when terminal + file already do the job, or when a skill + would.** If the only barrier is file visibility on a remote backend, fix the + mount, not the toolset. +- **Lazy-reading escape hatches on instructional tools.** No `offset`/`limit` + pagination on tools that load content the agent must read fully (skills, + prompts, playbooks). Models will read page 1 and skip the rest. +- **"Fixes" that destroy the feature they secure.** A mitigation that kills the + feature's purpose is the wrong mitigation. Read the original commit's intent + (`git log -p -S`) before restricting behavior; find a fix that preserves the + feature. +- **Outbound telemetry / usage attribution without opt-in gating.** No new + analytics, third-party identifier tagging, or attribution tags until a + generic user-facing opt-in (config gate + setup prompt + `hermes tools` + toggle) exists. Park behind a label, do not merge. +- **Change-detector tests, cache-breaking mid-conversation, dead code wired in + without E2E proof, and plugins that touch core files.** Plugins live in their + own directory and work within the ABCs/hooks we provide; if a plugin needs + more, widen the generic plugin surface, don't special-case it in core. + +### Before you call it a bug — verify the premise (and when NOT to close) + +The most common reason a well-written PR gets closed is not code quality — it +is that the change is built on a **wrong premise**, or it treats an +**intentional design as a gap**. These patterns cut both ways: they tell a +human reviewer what to scrutinize, and they tell the automated sweeper when a +PR is NOT safe to close as `implemented_on_main` / `cannot_reproduce` (when in +doubt, leave it open for a human). They are distilled from real closes. + +- **"Intentional design, not a gap."** A limitation that looks like an + oversight is often deliberate. Before "fixing" a missing link or a + restriction, ask whether the isolation IS the design. Example: profiles are + independent islands on purpose — a PR adding live config inheritance from the + default profile was closed because coupling profiles together is exactly what + the design prevents (the copy-at-creation `--clone` path already covers the + legitimate "start from my default" case). Read the original commit's intent + (`git log -p -S ""`) before assuming something is unfinished. +- **"The premise doesn't hold against how X actually works."** A PR's + justification frequently rests on a wrong mental model of an existing + mechanism. Trace the real code/runtime before accepting the rationale. Two + real closes: a rate-limit "re-probe during cooldown" PR (the breaker only + trips on a *confirmed-empty* account bucket, so re-probing just hammers a + bucket we've already proven empty); a usage-accumulation fix whose new branch + **never executes at runtime** because an earlier guard already popped the + state it depended on. If you can't point to the exact line where the bug + manifests AND show the fix changes that line's behavior, you haven't verified + the premise. +- **"This fix was wrong — the absence/omission was deliberate."** Adding the + obvious-looking missing piece can break things the omission was protecting. + Example: restoring "missing" `__init__.py` files made a test tree importable + as a dotted package that shadowed the real plugin, deleting its `register()` + at import time. The absence was load-bearing. +- **"Overreached / resurrected an approach we'd moved past."** Scope creep that + supersedes an agreed-on base, or revives a direction the maintainers + deliberately closed, gets rejected even when the code works. Keep the change + to the narrow piece that was actually agreed; offer the rest as a focused + follow-up. + +The throughline: **verify the claim AND the intent against the codebase before +writing or merging a fix.** A confirmed reproduction on current `main` plus a +line-level account of where the fix acts beats a plausible-sounding rationale +every time. When in doubt about intent, it is cheaper to ask than to ship a +fix that fights the design. + +### The Footprint Ladder (new capability decision) + +Each rung adds more permanent surface than the one above. Choose the highest +(least-footprint) rung that correctly solves the problem: + +1. **Extend existing code** — the capability is a variation of something that + already exists. Zero new surface. +2. **CLI command + skill** — manages config/state/infra expressible as shell + commands. The agent runs `hermes ` guided by a skill. Zero + model-tool footprint. Default choice for subscriptions, scheduled tasks, + service setup. Examples: `hermes webhook`, `hermes cron`, `hermes tools`. +3. **Service-gated tool (`check_fn`)** — needs structured params/returns AND + only appears when a prerequisite is configured. Zero footprint otherwise. + Examples: Home Assistant tools (gated on token), memory-provider tools. +4. **Plugin** — third-party/niche/user-specific capability that doesn't ship in + core. Lives in `~/.hermes/plugins/` or a pip package, discovered at runtime. +5. **MCP server (in the catalog)** — if the capability genuinely needs to be a + tool (structured I/O the agent invokes) but isn't core-fundamental, prefer + building it as an MCP server and adding it to the MCP catalog over growing + the core toolset. The agent connects to it through the built-in MCP client; + zero permanent core-schema footprint, and it's reusable by any MCP host. +6. **New core tool** — only when the capability is fundamental, broadly useful + to nearly every user, and unreachable via terminal + file (or an MCP server). + Examples of correct core tools: terminal, read_file, web_search, + browser_navigate. + +When 3+ open PRs try to integrate the same *category* of thing (memory +backends, providers, notifiers), don't merge them one at a time — design an +ABC + orchestrator, wrap the existing built-in as the first provider, and turn +the competing PRs into plugins against that interface. + ## Development Environment ```bash @@ -264,7 +459,7 @@ npm install # first time npm run dev # watch mode (rebuilds hermes-ink + tsx --watch) npm start # production npm run build # full build (hermes-ink + tsc) -npm run type-check # typecheck only (tsc --noEmit) +npm run typecheck # typecheck only (tsc --noEmit) npm run lint # eslint npm run fmt # prettier npm test # vitest @@ -302,9 +497,11 @@ A **separate** chat surface from both the classic CLI and the dashboard's embedd ## Adding New Tools -For most custom or local-only tools, do **not** edit Hermes core. Use the plugin -route instead: create `~/.hermes/plugins//plugin.yaml` and -`~/.hermes/plugins//__init__.py`, then register tools with +Before adding any tool, settle the footprint question first (see "The +Footprint Ladder" in the Contribution Rubric): most capabilities should NOT +be core tools. For custom or local-only tools, do **not** edit Hermes core. +Use the plugin route instead: create `~/.hermes/plugins//plugin.yaml` +and `~/.hermes/plugins//__init__.py`, then register tools with `ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be enabled or disabled without touching `tools/` or `toolsets.py`. diff --git a/Dockerfile b/Dockerfile index 92522c5c41a1..be358ac53439 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright # hermes process, the dashboard, and per-profile gateways. RUN apt-get update && \ apt-get install -y --no-install-recommends \ - ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \ + ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \ rm -rf /var/lib/apt/lists/* # ---------- s6-overlay install ---------- @@ -146,9 +146,9 @@ RUN npm install --prefer-offline --no-audit && \ # # `uv sync --frozen --no-install-project --extra all --extra messaging` # installs the deps reachable through the composite `[all]` extra -# (handpicked set intended for the production image), plus gateway -# messaging adapters that should work in the published image without a -# first-boot lazy install. We do NOT use `--all-extras`: +# (handpicked set intended for the production image — excludes `[dev]`), +# plus gateway messaging adapters that should work in the published image +# without a first-boot lazy install. We do NOT use `--all-extras`: # that would pull in `[rl]` (atroposlib + tinker + torch + wandb from # git), `[yc-bench]` (another git dep), and `[termux-all]` (Android # redundancy), none of which belong in the published container. @@ -164,19 +164,30 @@ RUN npm install --prefer-offline --no-audit && \ # image update and recall/retain then fails with # `ModuleNotFoundError: No module named 'hindsight_client'` (#38128). # +# The Matrix gateway's deps ([matrix] extra) are baked in because +# python-olm (transitive via mautrix[encryption]) builds from source on +# Python/image combinations without usable wheels. The Docker image is +# Linux-only, so keeping the native libolm/build-toolchain packages here +# avoids the cross-platform failures that kept [matrix] out of [all] +# while still making Matrix work in the published container. Fixes #30399. +# # The editable link is created after the source copy below. COPY pyproject.toml uv.lock ./ RUN touch ./README.md -RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight +RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix + +# ---------- Frontend build (cached independently from Python source) ---------- +# Copy only the frontend source trees first so that Python-only changes don't +# invalidate the (relatively slow) web + ui-tui build layer. +COPY web/ web/ +COPY ui-tui/ ui-tui/ +RUN cd web && npm run build && \ + cd ../ui-tui && npm run build # ---------- Source code ---------- # .dockerignore excludes node_modules, so the installs above survive. COPY --chown=hermes:hermes . . -# Build browser dashboard and terminal UI assets. -RUN cd web && npm run build && \ - cd ../ui-tui && npm run build - # ---------- Permissions ---------- # Make install dir world-readable so any HERMES_UID can read it at runtime. # The venv needs to be traversable too. diff --git a/MANIFEST.in b/MANIFEST.in index a6749adc2cf7..5d5a1b1b271b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ graft skills graft optional-skills +graft optional-mcps graft locales # Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the # PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs diff --git a/README.md b/README.md index a8db8cb2c29b..b65a11baf8fa 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@

# Hermes Agent ☤ - +

+ Hermes Agent | Hermes Desktop +

Documentation Discord diff --git a/agent/account_usage.py b/agent/account_usage.py index 2795eb24125a..da02af3c478c 100644 --- a/agent/account_usage.py +++ b/agent/account_usage.py @@ -145,7 +145,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]: account info to show (fail-open: caller just shows nothing). """ try: - from hermes_cli.nous_account import nous_portal_billing_url + from hermes_cli.nous_account import nous_portal_topup_url if account_info is None or not getattr(account_info, "logged_in", False): return None @@ -213,7 +213,8 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]: if not windows and not details: return None - details.append(f"Manage / top up: {nous_portal_billing_url(account_info)}") + details.append(f"Top up: {nous_portal_topup_url(account_info)}") + details.append("(or run /credits)") plan = getattr(sub, "plan", None) if sub is not None else None return AccountUsageSnapshot( @@ -337,6 +338,93 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]: return None +@dataclass(frozen=True) +class CreditsView: + """Surface-agnostic data for the ``/credits`` command. + + One portal fetch, one parse — consumed identically by the CLI panel, the + gateway button, and any other money surface. Fail-open: when not logged in + or the portal is unreachable, ``logged_in`` is False / ``topup_url`` is None + and callers degrade gracefully. + """ + + logged_in: bool + balance_lines: tuple[str, ...] = () + identity_line: Optional[str] = None + topup_url: Optional[str] = None + depleted: bool = False + + +def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView: + """Build the /credits view: balance block + identity line + top-up URL. + + Reuses the same account fetch + snapshot + URL builder as the /usage credits + block, so the numbers always match. The balance block is the rendered + snapshot MINUS its trailing top-up/command-hint lines (the /credits surface + supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``. + """ + not_logged_in = CreditsView(logged_in=False) + try: + from hermes_cli.auth import get_provider_auth_state + + tok = (get_provider_auth_state("nous") or {}).get("access_token") + if not (isinstance(tok, str) and tok.strip()): + return not_logged_in + except Exception: + return not_logged_in + + try: + import concurrent.futures + + from hermes_cli.nous_account import ( + get_nous_portal_account_info, + nous_portal_topup_url, + ) + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + account = pool.submit(get_nous_portal_account_info, force_fresh=True).result( + timeout=timeout + ) + except Exception: + logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True) + return not_logged_in + + if account is None or not getattr(account, "logged_in", False): + return not_logged_in + + snapshot = build_nous_credits_snapshot(account) + # Balance lines = the snapshot block minus the two trailing affordance lines + # ("Top up: " + "(or run /credits)") that build_nous_credits_snapshot + # appends for the /usage surface. /credits renders its own button/panel. + balance_lines: list[str] = [] + if snapshot is not None: + rendered = render_account_usage_lines(snapshot, markdown=markdown) + balance_lines = [ + line + for line in rendered + if not line.lstrip().startswith("Top up:") + and not line.lstrip().startswith("(or run") + ] + + # Identity line — shown before any open (roadmap §4.4). + email = getattr(account, "email", None) + org_name = getattr(account, "org_name", None) + who: list[str] = [] + if email: + who.append(str(email)) + if org_name: + who.append(f"org {org_name}") + identity_line = ("Topping up as " + " / ".join(who)) if who else None + + return CreditsView( + logged_in=True, + balance_lines=tuple(balance_lines), + identity_line=identity_line, + topup_url=nous_portal_topup_url(account), + depleted=getattr(account, "paid_service_access", None) is False, + ) + + def _resolve_codex_usage_url(base_url: str) -> str: normalized = (base_url or "").strip().rstrip("/") if not normalized: diff --git a/agent/agent_init.py b/agent/agent_init.py index 30bb6d837053..96bfe3d873f0 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -187,6 +187,7 @@ def init_agent( thinking_callback: callable = None, reasoning_callback: callable = None, clarify_callback: callable = None, + read_terminal_callback: callable = None, step_callback: callable = None, stream_delta_callback: callable = None, interim_assistant_callback: callable = None, @@ -417,6 +418,7 @@ def init_agent( agent.thinking_callback = thinking_callback agent.reasoning_callback = reasoning_callback agent.clarify_callback = clarify_callback + agent.read_terminal_callback = read_terminal_callback agent.step_callback = step_callback agent.stream_delta_callback = stream_delta_callback agent.interim_assistant_callback = interim_assistant_callback diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index f9bfb7a4319e..742af1453807 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -49,7 +49,7 @@ def _ra(): AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset( - {"todo", "session_search", "memory", "clarify", "delegate_task"} + {"todo", "session_search", "memory", "clarify", "read_terminal", "delegate_task"} ) @@ -679,15 +679,28 @@ def recover_with_credential_pool( # long-running TUI sessions stuck on stale tokens until the user # exited and reopened. is_entitlement = agent._is_entitlement_failure(error_context, status_code) + _auth_haystack = " ".join( + str(error_context.get(k) or "").lower() + for k in ("message", "reason", "code", "error") + if isinstance(error_context, dict) + ) + if ( + not is_entitlement + and status_code == 403 + and "oauth authentication is currently not allowed for this organization" in _auth_haystack + ): + is_entitlement = True + if ( + not is_entitlement + and status_code == 403 + and (agent.provider or "") == "anthropic" + and getattr(agent, "api_mode", "") == "anthropic_messages" + ): + is_entitlement = True if not is_entitlement and status_code == 403 and (agent.provider or "") == "xai-oauth": - _disambiguator_haystack = " ".join( - str(error_context.get(k) or "").lower() - for k in ("message", "reason", "code", "error") - if isinstance(error_context, dict) - ) _is_xai_auth_failure = ( - "[wke=unauthenticated:" in _disambiguator_haystack - or "oauth2 access token could not be validated" in _disambiguator_haystack + "[wke=unauthenticated:" in _auth_haystack + or "oauth2 access token could not be validated" in _auth_haystack ) if not _is_xai_auth_failure: is_entitlement = True @@ -1784,6 +1797,17 @@ def _execute(next_args: dict) -> Any: ), next_args, ) + elif function_name == "read_terminal": + def _execute(next_args: dict) -> Any: + from tools.read_terminal_tool import read_terminal_tool as _read_terminal_tool + return _finish_agent_tool( + _read_terminal_tool( + start_line=next_args.get("start_line"), + count=next_args.get("count"), + callback=getattr(agent, "read_terminal_callback", None), + ), + next_args, + ) elif function_name == "delegate_task": def _execute(next_args: dict) -> Any: return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 389b21831fb5..8476ef67f57f 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -73,20 +73,50 @@ def _get_anthropic_sdk(): "minimal": "low", } -# Models that accept the "xhigh" output_config.effort level. Opus 4.7 added -# xhigh as a distinct level between high and max; older adaptive-thinking -# models (4.6) reject it with a 400. Keep this substring list in sync with -# the Anthropic migration guide as new model families ship. -_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8") - -# Models where extended thinking is deprecated/removed (4.6+ behavior: adaptive -# is the only supported mode; 4.7 additionally forbids manual thinking entirely -# and drops temperature/top_p/top_k). -_ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7", "4-8", "4.8") - -# Models where temperature/top_p/top_k return 400 if set to non-default values. -# This is the Opus 4.7 contract; future 4.x+ models are expected to follow it. -_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8") +# ── Anthropic thinking-mode classification ──────────────────────────── +# Claude 4.6 replaced budget-based extended thinking with *adaptive* thinking, +# and 4.7 additionally forbids the manual ``thinking`` block entirely and drops +# temperature/top_p/top_k. Newer Claude releases (4.8, and named models like +# claude-fable-5) follow the same modern contract — but they share no common +# version substring, so an allowlist of version numbers ("4.6", "4.7", …) goes +# stale the moment a model ships without a recognized number and silently +# routes it down the legacy manual-thinking path. +# +# Instead we DEFAULT unknown Claude models to the modern contract and keep an +# explicit *legacy* list of the older Claude families that still require manual +# thinking. This mirrors _get_anthropic_max_output's "default to newest" design +# (future models are unlikely to regress to the older contract), so each new +# Claude release works without a code change. +# +# Non-Claude Anthropic-Messages models (minimax, qwen3, GLM, …) are NOT Claude, +# so they fall through to the legacy path automatically — exactly what those +# manual-thinking endpoints need. + +# Older Claude families that DON'T support adaptive thinking (manual thinking +# with budget_tokens only). Substring-matched against the model name. +_LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS = ( + "claude-3", # 3, 3.5, 3.7 + "claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1", + "claude-sonnet-4-0", "claude-sonnet-4.0", + "claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs + "claude-opus-4-5", "claude-opus-4.5", + "claude-sonnet-4-5", "claude-sonnet-4.5", + "claude-haiku-4-5", "claude-haiku-4.5", +) + +# Older Claude families that DON'T accept the "xhigh" effort level (4.6 only +# supports low/medium/high/max). xhigh arrived with Opus 4.7. Adaptive models +# not in this list (4.7, 4.8, fable, future) accept xhigh. +_NO_XHIGH_CLAUDE_SUBSTRINGS = ( + "claude-opus-4-6", "claude-opus-4.6", + "claude-sonnet-4-6", "claude-sonnet-4.6", +) + + +def _is_claude_model(model: str | None) -> bool: + return "claude" in (model or "").lower() + + _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6") # ── Max output token limits per Anthropic model ─────────────────────── @@ -94,6 +124,8 @@ def _get_anthropic_sdk(): # max_tokens as a mandatory field. Previously we hardcoded 16384, which # starves thinking-enabled models (thinking tokens count toward the limit). _ANTHROPIC_OUTPUT_LIMITS = { + # Mythos-class named models (claude-fable-5, …) — 1M context, reasoning + "claude-fable": 128_000, # Claude 4.8 "claude-opus-4-8": 128_000, # Claude 4.7 @@ -208,8 +240,17 @@ def _resolve_anthropic_messages_max_tokens( def _supports_adaptive_thinking(model: str) -> bool: - """Return True for Claude 4.6+ models that support adaptive thinking.""" - return any(v in model for v in _ADAPTIVE_THINKING_SUBSTRINGS) + """Return True for Claude models that use adaptive thinking (4.6+). + + Defaults *unknown* Claude models to adaptive (the modern contract) and + only returns False for the explicit legacy list of older Claude families + that require manual budget-based thinking. Non-Claude Anthropic-Messages + models (minimax, qwen3, …) return False so they keep the manual path. + """ + if not _is_claude_model(model): + return False + m = model.lower() + return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS) def _supports_xhigh_effort(model: str) -> bool: @@ -219,18 +260,33 @@ def _supports_xhigh_effort(model: str) -> bool: Pre-4.7 adaptive models (Opus/Sonnet 4.6) only accept low/medium/high/max and reject xhigh with an HTTP 400. Callers should downgrade xhigh→max when this returns False. + + Defaults unknown adaptive Claude models to accepting xhigh (4.7+ contract); + only the 4.6 family and legacy manual-thinking models are excluded. """ - return any(v in model for v in _XHIGH_EFFORT_SUBSTRINGS) + if not _supports_adaptive_thinking(model): + return False + m = model.lower() + return not any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS) def _forbids_sampling_params(model: str) -> bool: """Return True for models that 400 on any non-default temperature/top_p/top_k. - Opus 4.7 explicitly rejects sampling parameters; later Claude releases are - expected to follow suit. Callers should omit these fields entirely rather - than passing zero/default values (the API rejects anything non-null). + Opus 4.7 introduced this restriction; later Claude releases follow it. + Defaults unknown Claude models to forbidding sampling params (the modern + contract). The 4.6 family still accepts them, and the legacy manual-thinking + families (4.5 and older) accept them too, so both are excluded. Non-Claude + models are unaffected. Callers should omit these fields entirely rather than + passing zero/default values (the API rejects anything non-null). """ - return any(v in model for v in _NO_SAMPLING_PARAMS_SUBSTRINGS) + if not _is_claude_model(model): + return False + m = model.lower() + # 4.6 family is adaptive but still accepts sampling params. + if any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS): + return False + return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS) def _supports_fast_mode(model: str) -> bool: @@ -1515,6 +1571,15 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]: if ptype == "input_text": block: Dict[str, Any] = {"type": "text", "text": part.get("text", "")} + elif ptype == "text": + # A stored Anthropic text block. Rebuild from whitelisted fields only — + # SDK response text blocks carry output-only siblings (parsed_output, + # citations=None) that the Messages INPUT schema rejects with HTTP 400 + # "Extra inputs are not permitted". Do NOT dict(part) it verbatim. + block = {"type": "text", "text": part.get("text", "")} + cits = part.get("citations") + if isinstance(cits, list) and cits: + block["citations"] = cits elif ptype in {"image_url", "input_image"}: image_value = part.get("image_url", {}) url = image_value.get("url", "") if isinstance(image_value, dict) else str(image_value or "") @@ -1629,6 +1694,58 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]: return out +def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Strip output-only fields from a stored Anthropic content block so it is + valid as REQUEST input on replay. + + The SDK response objects carry output-only attributes that the Messages + *input* schema forbids ("Extra inputs are not permitted"): text blocks get + ``parsed_output``/``citations`` (when null), tool_use blocks get ``caller``, + etc. ``normalize_response`` captured blocks verbatim via ``_to_plain_data``, + so these leak back as input on the next turn → HTTP 400. + + Whitelist per type (NOT a blacklist) so future SDK output-only fields can't + reintroduce the bug. Returns a clean block, or None to drop it. + """ + if not isinstance(b, dict): + return None + btype = b.get("type") + if btype == "text": + out: Dict[str, Any] = {"type": "text", "text": b.get("text", "")} + # citations is input-valid ONLY when it's a non-empty list; the SDK + # emits citations=None on responses, which the input schema rejects. + cits = b.get("citations") + if isinstance(cits, list) and cits: + out["citations"] = cits + if isinstance(b.get("cache_control"), dict): + out["cache_control"] = b["cache_control"] + return out + if btype == "thinking": + out = {"type": "thinking", "thinking": b.get("thinking", "")} + if b.get("signature"): + out["signature"] = b["signature"] + return out + if btype == "redacted_thinking": + # Only valid with its data payload; drop if missing. + return {"type": "redacted_thinking", "data": b["data"]} if b.get("data") else None + if btype == "tool_use": + out = { + "type": "tool_use", + "id": _sanitize_tool_id(b.get("id", "")), + "name": b.get("name", ""), + "input": b.get("input", {}), + } + if isinstance(b.get("cache_control"), dict): + out["cache_control"] = b["cache_control"] + return out + if btype == "image": + src = b.get("source") + return {"type": "image", "source": src} if isinstance(src, dict) else None + # Unknown/unsupported block type on the input path — drop rather than risk + # another "Extra inputs are not permitted". + return None + + def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: """Convert an assistant message to Anthropic content blocks. @@ -1636,6 +1753,55 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: reasoning_content injection for Kimi/DeepSeek endpoints. """ content = m.get("content", "") + # Anthropic interleaved-thinking fast path: when this turn carries a + # verbatim, order-preserving block list (set by normalize_response only + # for turns that interleave SIGNED thinking with tool_use), replay it. + # Each block is run through _sanitize_replay_block to strip output-only + # SDK fields (parsed_output, caller, citations=None, …) that the Messages + # INPUT schema forbids — replaying them verbatim caused HTTP 400 "Extra + # inputs are not permitted" (text.parsed_output). Block ORDER is preserved + # (the reason this channel exists); only forbidden sibling fields are + # dropped, leaving thinking signatures and tool_use id/name/input intact. + ordered_blocks = m.get("anthropic_content_blocks") + if isinstance(ordered_blocks, list) and ordered_blocks: + # Re-source each tool_use input from the stored tool_calls map rather + # than the captured block. The ordered-blocks list captures tool_use + # input from the RAW API response (normalize_response), which is NOT + # credential-redacted; tool_calls[].function.arguments IS redacted at + # storage time (build_assistant_message, #19798). Replaying the raw + # block input would resurrect a secret the model inlined into a tool + # call (e.g. terminal(command="curl -H 'Authorization: Bearer sk-...'") + # onto the wire, even though the same value is redacted everywhere else + # in history. Keying by sanitized tool id preserves interleave order + # (the reason this channel exists) while swapping in the redacted + # input. Adapted from #36071 (replay-time tool-input re-sourcing). + redacted_input_by_id: Dict[str, Any] = {} + for tc in m.get("tool_calls", []) or []: + if not isinstance(tc, dict): + continue + fn = tc.get("function", {}) or {} + raw_args = fn.get("arguments", "{}") + try: + parsed_args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args + except (json.JSONDecodeError, ValueError): + parsed_args = {} + redacted_input_by_id[_sanitize_tool_id(tc.get("id", ""))] = parsed_args + replayed: List[Dict[str, Any]] = [] + for b in ordered_blocks: + clean = _sanitize_replay_block(b) + if clean is None: + continue + if clean.get("type") == "tool_use": + # Override raw (un-redacted) input with the redacted copy when + # we have one for this id; fall back to the sanitized block + # input only if the tool_call is missing (shape mismatch). + redacted = redacted_input_by_id.get(clean.get("id", "")) + if redacted is not None: + clean["input"] = redacted + replayed.append(clean) + if replayed: + return {"role": "assistant", "content": replayed} + blocks = _extract_preserved_thinking_blocks(m) if content: if isinstance(content, list): diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index c47c3a4a1d26..c6e00340e7e1 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -102,7 +102,7 @@ def __repr__(self): from agent.credential_pool import load_pool from hermes_cli.config import get_hermes_home from hermes_constants import OPENROUTER_BASE_URL -from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars +from utils import base_url_host_matches, base_url_hostname, model_forces_max_completion_tokens, normalize_proxy_env_vars logger = logging.getLogger(__name__) @@ -4300,13 +4300,15 @@ def get_auxiliary_extra_body() -> dict: return _nous_extra_body() if auxiliary_is_nous else {} -def auxiliary_max_tokens_param(value: int) -> dict: +def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> dict: """Return the correct max tokens kwarg for the auxiliary client's provider. - + OpenRouter and local models use 'max_tokens'. Direct OpenAI with newer - models (gpt-4o, o-series, gpt-5+) requires 'max_completion_tokens'. + models (gpt-4o, gpt-4.1, gpt-5+, o-series) requires 'max_completion_tokens'. The Codex adapter translates max_tokens internally, so we use max_tokens - for it as well. + for it as well. Pass ``model`` so third-party OpenAI-compatible endpoints + fronting the newer families are also recognised — URL-only detection + misses the case where a custom base URL serves e.g. ``gpt-5.4``. """ custom_base = _current_custom_base_url() or_key = os.getenv("OPENROUTER_API_KEY") @@ -4316,6 +4318,9 @@ def auxiliary_max_tokens_param(value: int) -> dict: and _read_nous_auth() is None and base_url_hostname(custom_base) in {"api.openai.com", "api.githubcopilot.com"}): return {"max_completion_tokens": value} + # ...and for any caller serving a newer OpenAI-family model by name. + if model_forces_max_completion_tokens(model): + return {"max_completion_tokens": value} return {"max_tokens": value} diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py index 12c7afb8c187..e3abba8436f7 100644 --- a/agent/bedrock_adapter.py +++ b/agent/bedrock_adapter.py @@ -208,6 +208,41 @@ def is_stale_connection_error(exc: BaseException) -> bool: return False +def is_streaming_access_denied_error(exc: BaseException) -> bool: + """Return True when AWS denied the ``bedrock:InvokeModelWithResponseStream`` action. + + IAM policies scoped to ``bedrock:InvokeModel`` only (a common least-privilege + setup) reject ``converse_stream()`` with an ``AccessDeniedException`` whose + message names the streaming action, e.g.:: + + User: arn:aws:iam::123456789012:user/x is not authorized to perform: + bedrock:InvokeModelWithResponseStream on resource: ... + + This is permanent for the session — retrying the stream can never succeed — + so callers should flip to the non-streaming ``converse()`` path (which maps + to ``bedrock:InvokeModel``) instead of burning retries. + + Detection is deliberately message-based: boto3 surfaces this as a + ``ClientError`` with ``Error.Code == "AccessDeniedException"``, and the + AnthropicBedrock SDK wraps the same AWS response in its own exception + types, but both preserve the action name in the message. + """ + msg = str(exc).lower() + if "invokemodelwithresponsestream" not in msg: + return False + # ClientError with an explicit access-denied code is the canonical form. + try: + from botocore.exceptions import ClientError + except ImportError: # pragma: no cover — botocore always present with boto3 + ClientError = None # type: ignore[assignment] + if ClientError is not None and isinstance(exc, ClientError): + code = (getattr(exc, "response", None) or {}).get("Error", {}).get("Code", "") + return code in ("AccessDeniedException", "UnauthorizedException") + # Wrapped forms (e.g. AnthropicBedrock SDK PermissionDeniedError) — match + # on the authorization-failure phrasing AWS uses. + return "not authorized" in msg or "accessdenied" in msg + + # --------------------------------------------------------------------------- # AWS credential detection # --------------------------------------------------------------------------- @@ -1003,6 +1038,16 @@ def call_converse_stream( try: response = client.converse_stream(**kwargs) except Exception as exc: + if is_streaming_access_denied_error(exc): + # IAM allows bedrock:InvokeModel but not + # InvokeModelWithResponseStream — permanent for this session. + # Fall back to the non-streaming converse() path. + logger.info( + "bedrock: converse_stream denied by IAM on (region=%s, model=%s) — " + "falling back to non-streaming converse().", + region, model, + ) + return normalize_converse_response(client.converse(**kwargs)) if is_stale_connection_error(exc): logger.warning( "bedrock: stale-connection error on converse_stream(region=%s, " diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index ce066d55640c..1ee1702b45e8 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -952,6 +952,18 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic if preserved: msg["reasoning_details"] = preserved + # Anthropic interleaved-thinking replay: when a turn interleaves signed + # thinking blocks with tool_use, the parallel reasoning_details + + # tool_calls fields lose the cross-type ordering, and reconstruction + # front-loads thinking — reordering signed blocks and triggering HTTP 400 + # ("thinking ... blocks in the latest assistant message cannot be + # modified"). Carry the verbatim ordered block list so the adapter can + # replay the latest assistant message unchanged. See + # agent/transports/anthropic.py and agent/anthropic_adapter.py. + ordered_blocks = getattr(assistant_message, "anthropic_content_blocks", None) + if ordered_blocks: + msg["anthropic_content_blocks"] = ordered_blocks + # Codex Responses API: preserve encrypted reasoning items for # multi-turn continuity. These get replayed as input on the next turn. codex_items = getattr(assistant_message, "codex_reasoning_items", None) @@ -1603,6 +1615,8 @@ def _bedrock_call(): _get_bedrock_runtime_client, invalidate_runtime_client, is_stale_connection_error, + is_streaming_access_denied_error, + normalize_converse_response, stream_converse_with_callbacks, ) region = api_kwargs.pop("__bedrock_region__", "us-east-1") @@ -1611,6 +1625,29 @@ def _bedrock_call(): try: raw_response = client.converse_stream(**api_kwargs) except Exception as _bedrock_exc: + # IAM policies scoped to bedrock:InvokeModel only (no + # InvokeModelWithResponseStream) reject converse_stream() + # with AccessDeniedException. That denial is permanent for + # the session — fall back to the non-streaming converse() + # inline (it maps to bedrock:InvokeModel) and disable + # streaming for subsequent calls so we don't re-fail every + # turn. + if is_streaming_access_denied_error(_bedrock_exc): + agent._disable_streaming = True + agent._safe_print( + "\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream — " + "falling back to non-streaming InvokeModel.\n" + " Grant that action to restore streaming output.\n" + ) + logger.info( + "bedrock: converse_stream denied by IAM (%s) — " + "using non-streaming converse() for this session.", + type(_bedrock_exc).__name__, + ) + result["response"] = normalize_converse_response( + client.converse(**api_kwargs) + ) + return # Evict the cached client on stale-connection failures # so the outer retry loop builds a fresh client/pool. if is_stale_connection_error(_bedrock_exc): @@ -1698,6 +1735,14 @@ def _close_request_client_once(reason: str) -> None: # poll loop uses this to detect stale connections that keep receiving # SSE keep-alive pings but no actual data. last_chunk_time = {"t": time.time()} + # Stale-stream patience, shared between the httpx socket read timeout + # (built in ``_call_chat_completions`` below) and the stale-stream detector + # (computed further down, before the worker thread starts). Initialized + # here so the read-timeout builder can floor itself at the stale value and + # never fire before the detector. ``None`` until the detector value is + # resolved, so the builder degrades to its plain default if it ever runs + # first. + _stream_stale_timeout = None def _fire_first_delta(): if not first_delta_fired["done"] and on_first_delta: @@ -1734,6 +1779,26 @@ def _call_chat_completions(): "Local provider detected (%s) — stream read timeout raised to %.0fs", agent.base_url, _stream_read_timeout, ) + elif ( + _stream_read_timeout == 120.0 + and _stream_stale_timeout is not None + and _stream_stale_timeout != float("inf") + and _stream_stale_timeout > _stream_read_timeout + ): + # Cloud reasoning models (e.g. Opus) routinely pause mid-stream + # for minutes during extended thinking. The stale-stream + # detector is deliberately scaled up to tolerate this (180–300s, + # see the stale-timeout block below), but the raw httpx socket + # read timeout defaulted to a flat 120s and fired *first* — + # tearing down a healthy reasoning stream before the stale + # detector (which owns retry + diagnostics) could act. Keep the + # socket read timeout in step with the detector so it no longer + # preempts it. + _stream_read_timeout = _stream_stale_timeout + logger.debug( + "Cloud reasoning stream — read timeout raised to %.0fs to " + "match stale-stream detector", _stream_read_timeout, + ) # Cap connect/pool at 60s even when provider timeout is higher. # connect/pool cover TCP handshake, not model inference. _conn_cap = min(_base_timeout, 60.0) if _provider_timeout_cfg is not None else 30.0 @@ -2384,9 +2449,34 @@ def _call(): "stream" in _err_lower and "not supported" in _err_lower ) - if _is_stream_unsupported: + # AWS Bedrock (AnthropicBedrock SDK path): IAM policies + # with bedrock:InvokeModel but not + # InvokeModelWithResponseStream reject messages.stream() + # with a permission error naming the streaming action. + # Permanent for the session — flip to non-streaming + # (messages.create() maps to bedrock:InvokeModel). + _is_bedrock_stream_denied = False + if ( + not _is_stream_unsupported + and "invokemodelwithresponsestream" in _err_lower + ): + # Cheap message pre-check before importing the + # adapter — bedrock_adapter triggers a lazy boto3 + # install at import time, which must not run for + # unrelated providers' stream errors. + from agent.bedrock_adapter import ( + is_streaming_access_denied_error, + ) + _is_bedrock_stream_denied = ( + is_streaming_access_denied_error(e) + ) + if _is_stream_unsupported or _is_bedrock_stream_denied: agent._disable_streaming = True agent._safe_print( + "\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream. " + "Switching to non-streaming.\n" + " Grant that action to restore streaming output.\n" + if _is_bedrock_stream_denied else "\n⚠ Streaming is not supported for this " "model/provider. Switching to non-streaming.\n" " To avoid this delay, set display.streaming: false " diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index 943131f55924..a2678a6da362 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -127,14 +127,21 @@ def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> Lis return converted -def _summarize_user_message_for_log(content: Any) -> str: - """Return a short text summary of a user message for logging/trajectory. +def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str: + """Flatten message content to a plain-text summary. Multimodal messages arrive as a list of ``{type:"text"|"image_url", ...}`` - parts from the API server. Logging, spinner previews, and trajectory - files all want a plain string — this helper extracts the first chunk of - text and notes any attached images. Returns an empty string for empty - lists and ``str(content)`` for unexpected scalar types. + parts from the API server. Several consumers want a plain string: + + - Logging, spinner previews, and trajectory files (the default ``sep=" "``). + - External memory providers, which feed the text to regexes + (``sanitize_context``) and text APIs — a raw list crashes the sync with + ``expected string or bytes-like object, got 'list'`` (use ``sep="\\n"``). + + Text parts are joined with ``sep``; images become a ``[N image(s)]`` marker + so the turn isn't recorded as if the attachment never existed. Returns an + empty string for empty lists and ``str(content)`` for unexpected scalar + types. """ if content is None: return "" @@ -157,7 +164,7 @@ def _summarize_user_message_for_log(content: Any) -> str: text_bits.append(text) elif ptype in {"image_url", "input_image"}: image_count += 1 - summary = " ".join(text_bits).strip() + summary = sep.join(text_bits).strip() if image_count: note = f"[{image_count} image{'s' if image_count != 1 else ''}]" summary = f"{note} {summary}" if summary else note diff --git a/agent/coding_context.py b/agent/coding_context.py new file mode 100644 index 000000000000..ede0dc1528ab --- /dev/null +++ b/agent/coding_context.py @@ -0,0 +1,738 @@ +"""Coding-context awareness — base Hermes, every interactive surface. + +When the user runs Hermes inside a code workspace (CLI, TUI, desktop app, or an +editor over ACP), Hermes shifts into a **coding posture**. This module is the +single place that decides whether we're in that posture and what it implies, +so the rest of the codebase never re-derives "are we coding?" on its own. + +Architecture — one seam, many consumers +---------------------------------------- +The posture is modelled as a frozen :class:`RuntimeMode` selected from a small +:class:`ContextProfile` registry (today: ``coding`` and ``general``). A profile +is *data* — it declares the toolset to collapse to, the operating brief to +inject, and hints for other domains (model routing, memory, subagents). Every +domain reads the same resolved object instead of probing git/config itself: + + * **System prompt** — ``RuntimeMode.system_blocks()`` → the operating brief + + a live git/workspace snapshot (``agent/system_prompt.py``). + * **Toolset** — ``RuntimeMode.toolset_selection()`` → the ``coding`` toolset + plus the user's enabled MCP servers (``cli.py`` / ``tui_gateway``). Only + under the opt-in ``focus`` mode: the default posture is prompt-only and + never touches the user's configured toolsets (toolsets like messaging / + smart-home / music are off-by-default anyway, and someone who explicitly + enabled image-gen or Spotify shouldn't lose it for being in a git repo). + * **Delegation** — subagents inherit the parent's toolset and run through the + same prompt builder, so the coding posture propagates to children for free. + * **Model / memory / compression** — declared on the profile + (``model_hint``, ``memory_policy``) as the extension seam; consumers read + ``mode.profile`` rather than re-deciding. + +Cache safety +------------ +The mode is resolved **once** and is immutable. The workspace snapshot is built +once at prompt-build time and baked into the *stable* system-prompt tier — never +re-probed per turn (that would shatter the prompt cache). Branch and dirty state +drift mid-session, so the brief tells the model to re-check with ``git`` before +acting on the snapshot. A ``/coding`` flip therefore only takes effect next +session (deferred), the same contract as ``/skills install`` vs ``--now``. + +Activation (config ``agent.coding_context``): + + * ``auto`` (default) — posture (brief + snapshot) on an interactive coding + surface sitting in a code workspace (git repo or recognised project root). + Prompt-only; toolsets and the skill index untouched. + * ``focus`` — like ``auto``, but additionally collapses the toolset to the + ``coding`` set + enabled MCP servers and demotes non-coding skill + categories to names-only in the prompt's skill index (no skill is ever + hidden). Explicit opt-in for a lean schema. + * ``on`` — force the posture anywhere (incl. non-workspaces). Prompt-only. + * ``off`` — disable entirely. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger("hermes.coding_context") + +CODING_TOOLSET = "coding" + +# Surfaces where a coding posture makes sense under ``auto``. Messaging +# platforms (telegram, discord, slack, …) are intentionally absent — a chat bot +# in a group is not pair-programming. +INTERACTIVE_CODING_PLATFORMS = {"cli", "tui", "acp", "desktop", ""} + +# Project-root signals that mark a directory as a code workspace even when it +# isn't (yet) a git repo. Cheap filename checks — no parsing. +_PROJECT_MARKERS = ( + "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", + "package.json", "tsconfig.json", "deno.json", + "Cargo.toml", "go.mod", "pom.xml", "build.gradle", "build.gradle.kts", + "Gemfile", "composer.json", "mix.exs", "pubspec.yaml", + "CMakeLists.txt", "Makefile", "Dockerfile", + "AGENTS.md", "CLAUDE.md", ".cursorrules", +) + +# Agent-instruction files surfaced separately from manifests in the snapshot. +_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules") + +# Lockfile → package manager, checked in priority order. +_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv")) +_JS_LOCKFILES = ( + ("pnpm-lock.yaml", "pnpm"), ("bun.lockb", "bun"), ("bun.lock", "bun"), + ("yarn.lock", "yarn"), ("package-lock.json", "npm"), +) + +# package.json scripts / Makefile targets worth surfacing as verify commands. +_VERIFY_TARGETS = ("test", "tests", "lint", "typecheck", "check", "build", "fmt", "format") +_MAX_VERIFY_COMMANDS = 8 +_MAX_FACT_FILE_BYTES = 256 * 1024 + +_GIT_TIMEOUT = 2.5 + + +# Per-model edit-format steering. Matching the edit tool format to how a model +# was trained reduces mistakes and wasted reasoning (OpenAI/Codex handle +# patch-style diffs best; Anthropic models — and most open-weight coding +# models, whose RL scaffolds use str_replace-style editors — do best with +# string-replacement). Our `patch` tool exposes both: mode="patch" (V4A +# multi-file) and mode="replace" (find-and-swap). We nudge each family toward +# its native format. Unknown families get nothing (the brief's neutral wording +# stands). Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS. +# +# GPT/Codex get V4A for ALL edits, single-file included: in codex-rs, +# apply_patch (V4A — apply_patch.lark) is the ONLY file editor, no +# str_replace-style tool exists, and the shipped model prompts say to use +# apply_patch even "for single file edits" — so a replace-mode nudge would +# steer those models toward a format their first-party harness never taught +# them. +_EDIT_FORMAT_GUIDANCE: dict[str, tuple[tuple[str, ...], str]] = { + "patch": ( + ("gpt", "codex"), + "- Edit format: author new files with `write_file`; for edits to " + "existing code use `patch` with `mode='patch'` (V4A diff) — including " + "single-file edits. It's the edit format you handle most reliably.", + ), + "replace": ( + ("claude", "sonnet", "opus", "haiku", + "gemini", "gemma", "deepseek", "qwen", "kimi", "glm", "grok", + "hermes", "llama", "mistral", "devstral", "minimax"), + "- Edit format: author new files with `write_file`; for edits to " + "existing code prefer `patch` in `mode='replace'` — match a unique " + "snippet and swap it. Reach for `mode='patch'` (V4A) only when an edit " + "genuinely spans several files at once.", + ), +} + + +def _model_family(model: Optional[str]) -> Optional[str]: + """Classify a model id into an edit-format family key, or ``None``. + + Used to steer the coding posture toward the edit tool format a model was + trained on. Family-agnostic by design: an unrecognised model gets ``None`` + and the operating brief's neutral edit wording applies. + """ + if not model: + return None + lowered = model.lower() + for family, (needles, _line) in _EDIT_FORMAT_GUIDANCE.items(): + if any(n in lowered for n in needles): + return family + return None + + +def _edit_format_line(model: Optional[str]) -> str: + """The edit-format guidance line for this model's family (``""`` if none).""" + family = _model_family(model) + if family is None: + return "" + return _EDIT_FORMAT_GUIDANCE[family][1] + + +# Operating brief for the coding posture. Tool names referenced here (read_file, +# search_files, patch, write_file, terminal, todo) are in the coding toolset and +# in _HERMES_CORE_TOOLS, so they're present on every surface this fires on. +CODING_AGENT_GUIDANCE = ( + "You are a coding agent pairing with the user inside their codebase. " + "Operate like a careful senior engineer.\n" + "\n" + "Gather context first:\n" + "- Read the relevant files with `read_file` and locate code with " + "`search_files` before changing anything. Trace a symbol to its definition " + "and usages rather than guessing its shape.\n" + "- Batch independent lookups: when several reads/searches don't depend on " + "each other, issue them together in one turn instead of one at a time.\n" + "- Never invent files, symbols, APIs, or imports. If you haven't seen it in " + "the repo, go look. Don't assume a library is available — check the project " + "manifest (pyproject.toml / package.json / Cargo.toml / go.mod) and how " + "neighbouring files import it.\n" + "\n" + "Make changes through the tools, not the chat:\n" + "- Edit with `patch`/`write_file`. Do NOT print code blocks to the user as " + "a substitute for editing — apply the change, then summarise it. Only show " + "code when the user explicitly asks to see it.\n" + "- Match the project's existing style and conventions; AGENTS.md / " + "CLAUDE.md / .cursorrules already in context win over your defaults. Touch " + "only what the task needs — no drive-by refactors, renames, or reformatting " + "— and add any imports/dependencies your code requires.\n" + "- If an edit fails to apply, re-read the file to get the current exact " + "contents before retrying — don't repeat a stale patch. If the same region " + "fails twice, rewrite the enclosing function or file with `write_file` " + "instead of attempting a third patch.\n" + "\n" + "Verify, and know when to stop:\n" + "- Use `terminal` for git, builds, tests, and inspection. Run the relevant " + "tests/linter/build and confirm they pass before claiming the work is done.\n" + "- Terminal state persists across calls: current directory and exported " + "environment variables carry forward. Activate a virtualenv or export setup " + "vars once, then reuse that state instead of re-sourcing it before every " + "test command.\n" + "- Fix root causes, not symptoms: when you find a bug, check sibling call " + "paths for the same flaw and fix the class, not just the reported site.\n" + "- When fixing linter/type errors on a file, stop after about three " + "attempts on the same file and ask the user rather than looping.\n" + "- Track multi-step work with `todo`. Reference code as `path:line` instead " + "of pasting whole files.\n" + "\n" + "Respect the user's repo: don't commit, push, or rewrite history unless " + "asked, and never read, print, or commit secrets — leave `.env` and " + "credential files alone unless the user explicitly asks. The Workspace " + "block below is a snapshot from session start — re-run `git status`/" + "`git branch` before relying on it. Be concise: lead with the change or " + "answer, not a preamble." +) + + +# ── Context profiles (declarative posture definitions) ────────────────────── + + +@dataclass(frozen=True) +class ContextProfile: + """A named operating posture. Pure data — consumers read these fields. + + ``toolset`` — collapse to this toolset (+ enabled MCP) when no explicit + selection is pinned; ``None`` keeps the platform default. + ``guidance`` — operating brief injected into the stable system prompt; + ``""`` injects nothing. + ``model_hint`` — routing preference key for smart model routing + (extension seam; not yet consumed by the router). + ``memory_policy``— memory namespace/weighting hint (extension seam). + ``compact_skill_categories`` — skill categories DEMOTED to names-only in + the system-prompt skill index under the opt-in ``focus`` + mode. Never hidden: every skill name stays visible + (so memory-anchored recall keeps working) — only the + descriptions are dropped to cut index noise. Deny-list + semantics so unknown/custom categories keep full + entries. + """ + + name: str + toolset: Optional[str] = None + guidance: str = "" + model_hint: Optional[str] = None + memory_policy: str = "default" + compact_skill_categories: tuple[str, ...] = () + + +# Skill categories that are clearly not part of a coding workflow. Demoted to +# names-only in the prompt's skill index under the opt-in ``focus`` mode only +# (deny-list — anything not listed here, incl. custom user categories, keeps +# full entries). Coding-adjacent categories (devops, github, mcp, +# data-science, diagramming, research, security, …) are intentionally absent. +_NON_CODING_SKILL_CATEGORIES = ( + "apple", "communication", "cooking", "creative", "email", "finance", + "gaming", "gifs", "health", "media", "music", "note-taking", + "productivity", "shopping", "smart-home", "social-media", "travel", + "yuanbao", +) + + +GENERAL_PROFILE = ContextProfile(name="general") +CODING_PROFILE = ContextProfile( + name="coding", + toolset=CODING_TOOLSET, + guidance=CODING_AGENT_GUIDANCE, + model_hint="coding", + memory_policy="project", + compact_skill_categories=_NON_CODING_SKILL_CATEGORIES, +) + +_PROFILES: dict[str, ContextProfile] = { + GENERAL_PROFILE.name: GENERAL_PROFILE, + CODING_PROFILE.name: CODING_PROFILE, +} + + +def get_profile(name: str) -> ContextProfile: + """Return a registered profile, falling back to ``general``.""" + return _PROFILES.get(name, GENERAL_PROFILE) + + +# ── Helpers ───────────────────────────────────────────────────────────────── + + +def _coding_mode(config: Optional[dict[str, Any]]) -> str: + """Return the normalized ``agent.coding_context`` mode (auto/focus/on/off).""" + if config is None: + try: + from hermes_cli.config import load_config + + config = load_config() + except Exception: + config = {} + raw = ((config or {}).get("agent", {}) or {}).get("coding_context", "auto") + mode = str(raw).strip().lower() + if mode in {"focus", "strict", "lean"}: + return "focus" + if mode in {"on", "true", "yes", "1", "always"}: + return "on" + if mode in {"off", "false", "no", "0", "never"}: + return "off" + return "auto" + + +def _resolve_cwd(cwd: Optional[str | Path]) -> Path: + if cwd: + return Path(cwd).expanduser() + try: + from agent.runtime_cwd import resolve_agent_cwd + + return resolve_agent_cwd() + except Exception: + return Path(os.getcwd()) + + +def _git_root(cwd: Path) -> Optional[Path]: + current = cwd.resolve() + for parent in [current, *current.parents]: + if (parent / ".git").exists(): + return parent + return None + + +def _home() -> Optional[Path]: + try: + return Path.home().resolve() + except (OSError, RuntimeError): + return None + + +def _marker_root(cwd: Path) -> Optional[Path]: + """Nearest ancestor that looks like a project root, or ``None``. + + Walks up at most a few levels so a manifest in the workspace root counts + even when the user is in a subdirectory. ``$HOME`` itself is skipped — a + Makefile or AGENTS.md sitting in the home directory is global user config, + not a project-root signal. + """ + current = cwd.resolve() + home = _home() + for depth, parent in enumerate([current, *current.parents]): + if depth > 6: + break + if parent == home: + continue + for marker in _PROJECT_MARKERS: + if (parent / marker).exists(): + return parent + return None + + +def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str: + """Resolve which profile applies. + + ``auto``/``focus``: coding when the surface is interactive AND the cwd is a + code workspace (a git repo or a recognised project root). ``on``: always + coding. ``off``: always general. + + A git repo rooted at ``$HOME`` (the dotfiles pattern) is NOT a workspace + signal — without the guard, every session anywhere under a dotfiles-managed + home directory would silently flip to the coding posture. + + Detection is intentionally not memoized: it's a handful of ``stat`` calls, + and callers resolve the mode once per session anyway. Caching here would + risk a stale posture if a long-lived process (gateway/TUI) serves sessions + from different working directories. + """ + if mode == "off": + return GENERAL_PROFILE.name + if mode == "on": + return CODING_PROFILE.name + if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS: + return GENERAL_PROFILE.name + cwd = Path(cwd_str) + git_root = _git_root(cwd) + if git_root is not None and git_root == _home(): + git_root = None # dotfiles repo at $HOME — not a code workspace + if git_root is not None or _marker_root(cwd) is not None: + return CODING_PROFILE.name + return GENERAL_PROFILE.name + + +# ── RuntimeMode (the seam) ────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class RuntimeMode: + """The resolved operating posture for a session. Immutable by construction. + + Built once via :func:`resolve_runtime_mode` and consumed by every domain + that cares about the coding/general distinction. Never mutate or re-resolve + mid-session — that would break the prompt cache. + """ + + profile: ContextProfile + surface: str + cwd: Path + # The normalized ``agent.coding_context`` mode this posture was resolved + # under (auto/focus/on/off). Toolset collapse is gated on ``focus``. + config_mode: str = "auto" + # The model id this session runs (e.g. "anthropic/claude-opus-4.8"). Used + # only to steer edit-format guidance toward the model's family — see + # ``_edit_format_line``. Fixed for the session, so cache-safe. + model: Optional[str] = None + + @property + def kind(self) -> str: + return self.profile.name + + @property + def is_coding(self) -> bool: + return self.profile.name == CODING_PROFILE.name + + def toolset_selection(self, config: Optional[dict[str, Any]] = None) -> Optional[list[str]]: + """Toolset list for this posture, or ``None`` to keep the platform default. + + Non-``None`` only under the opt-in ``focus`` mode. The default posture + is prompt-only: most strippable toolsets are off-by-default anyway, and + a user who explicitly enabled one (image-gen for frontend/game assets, + messaging for build notifications, …) keeps it while coding. + + Callers apply this only when the user hasn't pinned an explicit + selection (``--toolsets``, ``HERMES_TUI_TOOLSETS``, …); they never + override a pin. Returns the profile's toolset plus enabled MCP servers. + """ + if self.config_mode != "focus": + return None + if self.profile.toolset is None: + return None + return [self.profile.toolset, *_enabled_mcp_servers(config)] + + def system_blocks(self) -> list[str]: + """Stable system-prompt blocks for this posture (brief + workspace). + + The operating brief carries a model-family edit-format nudge appended + to it (one cached string, not a separate block) so the model is steered + toward the `patch` mode it handles best — see ``_edit_format_line``. + """ + if not self.is_coding: + return [] + blocks: list[str] = [] + if self.profile.guidance: + brief = self.profile.guidance + edit_line = _edit_format_line(self.model) + if edit_line: + brief = f"{brief}\n{edit_line}" + blocks.append(brief) + workspace = build_coding_workspace_block(self.cwd) + if workspace: + blocks.append(workspace) + return blocks + + def compact_skill_categories(self) -> frozenset[str]: + """Skill categories to demote to names-only in the prompt's skill index. + + Gated on the opt-in ``focus`` mode, like the toolset collapse: the + default posture leaves the skill index untouched. Users who didn't ask + for a lean prompt keep full entries for every category — index changes + under ``auto`` proved too surprising in practice, even names-only ones + (a demoted description is information the model no longer weighs when + deciding what to load). + + Demoted — never hidden — even under ``focus``. An earlier revision + fully pruned these categories from the index, which caused silent + capability loss in a real workflow: agent-created skills are the + model's accumulated project memory (server-ops runbooks, learned + pitfalls, …), and models do not reliably reach for ``skills_list`` to + rediscover what the index stopped showing them. Names-only keeps every + skill loadable on recall while still cutting the description noise. + """ + if not self.is_coding or self.config_mode != "focus": + return frozenset() + return frozenset(self.profile.compact_skill_categories) + + +def resolve_runtime_mode( + *, + platform: Optional[str] = None, + cwd: Optional[str | Path] = None, + config: Optional[dict[str, Any]] = None, + model: Optional[str] = None, +) -> RuntimeMode: + """Resolve the operating posture once. Cheap — a handful of ``stat`` calls. + + This is the single entry point every domain should call. The returned + object is immutable and safe to cache for the session. Detection itself is + intentionally *not* memoized (see ``_detect_profile_name``) so a long-lived + process can't pin a stale posture; callers resolve once per session and + hold the result. ``model`` is recorded only to steer edit-format guidance; + it never affects detection. + """ + resolved_cwd = _resolve_cwd(cwd) + mode = _coding_mode(config) + name = _detect_profile_name( + mode, (platform or "").strip().lower(), str(resolved_cwd) + ) + return RuntimeMode( + profile=get_profile(name), + surface=platform or "", + cwd=resolved_cwd, + config_mode=mode, + model=model, + ) + + +# ── Back-compat surface (thin wrappers over RuntimeMode) ──────────────────── + + +def is_coding_context( + *, + platform: Optional[str] = None, + cwd: Optional[str | Path] = None, + config: Optional[dict[str, Any]] = None, +) -> bool: + """Whether Hermes should operate in its coding posture right now.""" + return resolve_runtime_mode(platform=platform, cwd=cwd, config=config).is_coding + + +def coding_selection( + *, + platform: Optional[str] = None, + cwd: Optional[str | Path] = None, + config: Optional[dict[str, Any]] = None, +) -> Optional[list[str]]: + """Toolset selection for the coding posture. + + ``None`` unless the user opted into ``focus`` mode AND the posture is + active — the default coding posture never overrides configured toolsets. + """ + return resolve_runtime_mode( + platform=platform, cwd=cwd, config=config + ).toolset_selection(config) + + +def coding_system_blocks( + *, + platform: Optional[str] = None, + cwd: Optional[str | Path] = None, + config: Optional[dict[str, Any]] = None, + model: Optional[str] = None, +) -> list[str]: + """Stable system-prompt blocks for the current posture (empty when general). + + ``model`` steers the brief's edit-format nudge toward the model's family. + """ + return resolve_runtime_mode( + platform=platform, cwd=cwd, config=config, model=model + ).system_blocks() + + +def coding_compact_skill_categories( + *, + platform: Optional[str] = None, + cwd: Optional[str | Path] = None, + config: Optional[dict[str, Any]] = None, +) -> frozenset[str]: + """Skill categories the active posture demotes to names-only in the index. + + Empty outside the coding posture and outside the opt-in ``focus`` mode — + the default posture never touches the skill index. Under ``focus``, + demoted — never hidden: every skill name stays in the index and remains + loadable via ``skill_view`` / ``skills_list``; only descriptions are + dropped. + """ + return resolve_runtime_mode( + platform=platform, cwd=cwd, config=config + ).compact_skill_categories() + + +def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]: + """Names of MCP servers the user has enabled — kept in the coding posture. + + MCP servers (figma, browser, tophat, …) are explicitly configured and part + of the coding workflow, not noise to strip. + """ + try: + from hermes_cli.config import read_raw_config + from hermes_cli.tools_config import _parse_enabled_flag + + servers = read_raw_config().get("mcp_servers") or {} + return [ + str(name) + for name, cfg in servers.items() + if isinstance(cfg, dict) + and _parse_enabled_flag(cfg.get("enabled", True), default=True) + ] + except Exception: + return [] + + +# ── git/workspace probe ───────────────────────────────────────────────────── + + +def _git(cwd: Path, *args: str) -> str: + try: + out = subprocess.run( + ["git", "-C", str(cwd), *args], + capture_output=True, + text=True, + timeout=_GIT_TIMEOUT, + ) + except (OSError, subprocess.SubprocessError): + return "" + return out.stdout.strip() if out.returncode == 0 else "" + + +def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]: + """Parse ``git status --porcelain=2 --branch`` into branch + counts.""" + branch: dict[str, str] = {} + counts = {"staged": 0, "modified": 0, "untracked": 0, "conflicts": 0} + for line in porcelain.splitlines(): + if line.startswith("# branch.head"): + branch["head"] = line.split(maxsplit=2)[-1] + elif line.startswith("# branch.upstream"): + branch["upstream"] = line.split(maxsplit=2)[-1] + elif line.startswith("# branch.ab"): + parts = line.split() + branch["ahead"], branch["behind"] = parts[2].lstrip("+"), parts[3].lstrip("-") + elif line.startswith(("1 ", "2 ")): + xy = line.split(maxsplit=2)[1] + if xy[0] != ".": + counts["staged"] += 1 + if xy[1] != ".": + counts["modified"] += 1 + elif line.startswith("u "): + counts["conflicts"] += 1 + elif line.startswith("? "): + counts["untracked"] += 1 + return branch, counts + + +def _read_small(path: Path) -> str: + """Read a small text file, or ``""`` — never raises, never reads huge files.""" + try: + if not path.is_file() or path.stat().st_size > _MAX_FACT_FILE_BYTES: + return "" + return path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + + +def _project_facts(root: Path) -> list[str]: + """Detected project facts for the workspace snapshot. + + The point is to hand the model its *verify loop* up front — which manifest, + which package manager, and the exact test/lint/build commands — instead of + making it rediscover them every session. Cheap: stat calls plus reads of a + couple of small files; built once at prompt-build time (cache-safe). + """ + facts: list[str] = [] + + manifests = [m for m in _PROJECT_MARKERS if m not in _CONTEXT_FILES and (root / m).is_file()] + package_managers = [ + pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file() + ] + if manifests: + line = f"- Project: {', '.join(manifests[:6])}" + if package_managers: + line += f" ({'/'.join(dict.fromkeys(package_managers))})" + facts.append(line) + + verify: list[str] = [] + if (root / "scripts" / "run_tests.sh").is_file(): + verify.append("scripts/run_tests.sh") + if (root / "package.json").is_file(): + try: + scripts = json.loads(_read_small(root / "package.json") or "{}").get("scripts") or {} + except (json.JSONDecodeError, AttributeError): + scripts = {} + js_pm = next((pm for lock, pm in _JS_LOCKFILES if (root / lock).is_file()), "npm") + verify.extend(f"{js_pm} run {name}" for name in _VERIFY_TARGETS if name in scripts) + if (root / "pytest.ini").is_file() or "[tool.pytest" in _read_small(root / "pyproject.toml"): + verify.append("pytest") + makefile = _read_small(root / "Makefile") + if makefile: + verify.extend( + f"make {name}" for name in _VERIFY_TARGETS + if re.search(rf"^{re.escape(name)}\s*:", makefile, re.MULTILINE) + ) + if verify: + deduped = list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS] + facts.append(f"- Verify: {'; '.join(deduped)}") + + context_files = [c for c in _CONTEXT_FILES if (root / c).is_file()] + if context_files: + facts.append(f"- Context files: {', '.join(context_files)}") + + return facts + + +def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str: + """Workspace snapshot for the system prompt (empty outside a workspace). + + Git state (branch/status/commits) when the cwd is in a repo, plus detected + project facts (manifest, package manager, verify commands, context files) + — so marker-only (non-git) projects still get a snapshot. + """ + resolved = _resolve_cwd(cwd) + git_root = _git_root(resolved) + root = git_root or _marker_root(resolved) + if root is None: + return "" + + lines = ["Workspace (snapshot at session start — re-check with `git` before acting on it):"] + lines.append(f"- Root: {root}") + + if git_root is not None: + branch, counts = _parse_status(_git(root, "status", "--porcelain=2", "--branch")) + head = branch.get("head", "") + if head and head != "(detached)": + line = f"- Branch: {head}" + if branch.get("upstream"): + line += f" \u2192 {branch['upstream']}" + ahead, behind = branch.get("ahead", "0"), branch.get("behind", "0") + if ahead != "0" or behind != "0": + line += f" (ahead {ahead}, behind {behind})" + lines.append(line) + elif head == "(detached)": + lines.append("- Branch: (detached HEAD)") + + # Linked worktree: the per-worktree git dir differs from the shared common dir. + # We surface the fact that it's a worktree (so the model knows branches/stashes + # are shared state) but deliberately do NOT expose the primary tree path — + # giving the model a second absolute path causes it to sometimes run commands + # in the wrong directory. + git_dir, common_dir = _git(root, "rev-parse", "--git-dir"), _git(root, "rev-parse", "--git-common-dir") + if git_dir and common_dir and Path(git_dir).resolve() != Path(common_dir).resolve(): + lines.append("- Worktree: linked (git state shared with primary tree)") + + dirty = [f"{n} {label}" for label, n in ( + ("staged", counts["staged"]), ("modified", counts["modified"]), + ("untracked", counts["untracked"]), ("conflicts", counts["conflicts"]), + ) if n] + lines.append(f"- Status: {', '.join(dirty) if dirty else 'clean'}") + + recent = _git(root, "log", "-3", "--pretty=%h %s") + if recent: + lines.append("- Recent commits:") + lines.extend(f" {c}" for c in recent.splitlines()) + + lines.extend(_project_facts(root)) + return "\n".join(lines) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 98d226b46af0..4611616085f8 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -7,7 +7,7 @@ Improvements over v2: - Structured summary template with Resolved/Pending question tracking - Filter-safe summarizer preamble that treats prior turns as source material - - "Remaining Work" replaces "Next Steps" to avoid reading as active instructions + - Historical (reference-only) section headings replace "Next Steps"/"Remaining Work" to avoid reading as active instructions - Clear separator when summary merges into tail message - Iterative summary updates (preserves info across multiple compactions) - Token-budget tail protection instead of fixed message count @@ -34,6 +34,12 @@ logger = logging.getLogger(__name__) +HISTORICAL_TASK_HEADING = "## Historical Task Snapshot" +HISTORICAL_IN_PROGRESS_HEADING = "## Historical In-Progress State" +HISTORICAL_PENDING_ASKS_HEADING = "## Historical Pending User Asks" +HISTORICAL_REMAINING_WORK_HEADING = "## Historical Remaining Work" + + SUMMARY_PREFIX = ( "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted " "into the summary below. This is a handoff from a previous context " @@ -43,12 +49,14 @@ "Respond ONLY to the latest user message that appears AFTER this " "summary — that message is the single source of truth for what to do " "right now. " - "If the latest user message is consistent with the '## Active Task' " - "section, you may use the summary as background. If the latest user " - "message contradicts, supersedes, changes topic from, or in any way " - "diverges from '## Active Task' / '## In Progress' / '## Pending User " - "Asks' / '## Remaining Work', the latest message WINS — discard those " - "stale items entirely and do not 'wrap up the old task first'. " + "Topic overlap with the summary does NOT mean you should resume its " + "task: even on similar topics, the latest user message WINS. Treat ONLY " + "the latest message as the active task and discard stale items from " + f"'{HISTORICAL_TASK_HEADING}' / '{HISTORICAL_IN_PROGRESS_HEADING}' / " + f"'{HISTORICAL_PENDING_ASKS_HEADING}' / " + f"'{HISTORICAL_REMAINING_WORK_HEADING}' entirely — do not 'wrap up' or " + "'finish' work described there unless the latest message explicitly " + "asks for it. " "Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll " "back', 'just verify', 'don't do that anymore', 'never mind', a new " "topic) must immediately end any in-flight work described in the " @@ -68,6 +76,31 @@ # embedded in the body and keeps hijacking replies. Keep newest-first; entries # are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes. _HISTORICAL_SUMMARY_PREFIXES = ( + # Carveout era (#41607/#38364/#42812): "consistent → use as background" + # licensed stale-task resumption on topic overlap. + "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted " + "into the summary below. This is a handoff from a previous context " + "window — treat it as background reference, NOT as active instructions. " + "Do NOT answer questions or fulfill requests mentioned in this summary; " + "they were already addressed. " + "Respond ONLY to the latest user message that appears AFTER this " + "summary — that message is the single source of truth for what to do " + "right now. " + "If the latest user message is consistent with the '## Active Task' " + "section, you may use the summary as background. If the latest user " + "message contradicts, supersedes, changes topic from, or in any way " + "diverges from '## Active Task' / '## In Progress' / '## Pending User " + "Asks' / '## Remaining Work', the latest message WINS — discard those " + "stale items entirely and do not 'wrap up the old task first'. " + "Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll " + "back', 'just verify', 'don't do that anymore', 'never mind', a new " + "topic) must immediately end any in-flight work described in the " + "summary; do not re-surface it in later turns. " + "IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system " + "prompt is ALWAYS authoritative and active — never ignore or deprioritize " + "memory content due to this compaction note. " + "The current session state (files, config, etc.) may reflect work " + "described here — avoid repeating it:", # Pre-#35344: contained the self-contradicting "resume exactly" directive. "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted " "into the summary below. This is a handoff from a previous context " @@ -110,10 +143,18 @@ # become another unbounded transcript copy after the LLM summarizer failed. _FALLBACK_SUMMARY_MAX_CHARS = 8_000 _FALLBACK_TURN_MAX_CHARS = 700 +_AUTO_FOCUS_MAX_TURNS = 3 +_AUTO_FOCUS_TURN_MAX_CHARS = 260 +_AUTO_FOCUS_MAX_CHARS = 700 _PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+") +# MEDIA delivery directives must not reach the summarizer — if one leaks into +# the summary, the downstream model may re-emit it as an active directive on +# the next turn, triggering bogus attachment sends (#14665). +_MEDIA_DIRECTIVE_RE = re.compile(r"MEDIA:\S+") + def _dedupe_append(items: list[str], value: str, *, limit: int) -> None: value = value.strip() @@ -974,6 +1015,7 @@ def _serialize_for_summary(self, turns: List[Dict[str, Any]]) -> str: 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) # Tool results: keep enough content for the summarizer if role == "tool": @@ -1155,7 +1197,7 @@ def _bullets(items: list[str], limit: int = 8) -> str: ) reason_text = f" Summary failure reason: {reason}." if reason else "" - body = f"""## Active Task + body = f"""{HISTORICAL_TASK_HEADING} {active_task} ## Goal @@ -1172,7 +1214,7 @@ def _bullets(items: list[str], limit: int = 8) -> str: ## Active State Unknown from deterministic fallback. Inspect current repository/session state if needed. -## In Progress +{HISTORICAL_IN_PROGRESS_HEADING} {active_task} ## Blocked @@ -1184,13 +1226,13 @@ def _bullets(items: list[str], limit: int = 8) -> str: ## Resolved Questions None recoverable from deterministic fallback. -## Pending User Asks +{HISTORICAL_PENDING_ASKS_HEADING} {active_task} ## Relevant Files {_bullets(relevant_files, limit=12)} -## Remaining Work +{HISTORICAL_REMAINING_WORK_HEADING} Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims. ## Last Dropped Turns @@ -1312,7 +1354,7 @@ def _generate_summary( _temporal_anchoring_rule = "" # Shared structured template (used by both paths). - _template_sections = f"""## Active Task + _template_sections = f"""{HISTORICAL_TASK_HEADING} [THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled input verbatim — the exact words they used. This includes: - Explicit task assignments ("refactor the auth module") @@ -1359,7 +1401,7 @@ def _generate_summary( - Any running processes or servers - Environment details that matter] -## In Progress +{HISTORICAL_IN_PROGRESS_HEADING} [Work currently underway — what was being done when compaction fired] ## Blocked @@ -1371,14 +1413,14 @@ def _generate_summary( ## Resolved Questions [Questions the user asked that were ALREADY answered — include the answer so it is not repeated] -## Pending User Asks -[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write "None."] +{HISTORICAL_PENDING_ASKS_HEADING} +[Questions or requests from the user that have NOT yet been answered or fulfilled. These are STALE — they were from the compacted turns. Write them here for reference only. The agent must NOT act on them unless the latest user message explicitly requests it. If none, write "None."] ## Relevant Files [Files read, modified, or created — with brief note on each] -## Remaining Work -[What remains to be done — framed as context, not instructions] +{HISTORICAL_REMAINING_WORK_HEADING} +[What remains to be done — framed as STALE context for reference only. The agent must NOT resume this work unless the latest user message explicitly asks for it.] ## Critical Context [Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.] @@ -1421,7 +1463,7 @@ def _generate_summary( prompt += f""" FOCUS TOPIC: "{focus_topic}" -The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED].""" +This compaction should PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED].""" try: call_kwargs = { @@ -1590,6 +1632,39 @@ def _is_context_summary_content(content: Any) -> bool: return True return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES) + @classmethod + def _derive_auto_focus_topic( + cls, + messages: List[Dict[str, Any]], + ) -> Optional[str]: + """Infer a compact focus hint from the most recent real user turns.""" + candidates: list[str] = [] + for idx in range(len(messages) - 1, -1, -1): + msg = messages[idx] + if msg.get("role") != "user": + continue + content = msg.get("content") + if cls._is_context_summary_content(content): + continue + text = redact_sensitive_text(_content_text_for_contains(content).strip()) + if not text: + continue + text = " ".join(text.split()) + if len(text) > _AUTO_FOCUS_TURN_MAX_CHARS: + text = text[: _AUTO_FOCUS_TURN_MAX_CHARS - 1].rstrip() + "…" + candidates.append(text) + if len(candidates) >= _AUTO_FOCUS_MAX_TURNS: + break + + if not candidates: + return None + + candidates.reverse() + focus = "Recent user focus:\n" + "\n".join(f"- {item}" for item in candidates) + if len(focus) > _AUTO_FOCUS_MAX_CHARS: + focus = focus[: _AUTO_FOCUS_MAX_CHARS - 1].rstrip() + "…" + return focus + @classmethod def _find_latest_context_summary( cls, @@ -1753,7 +1828,7 @@ def _ensure_last_user_message_in_tail( Context compressor bug (#10896): ``_align_boundary_backward`` can pull ``cut_idx`` past a user message when it tries to keep tool_call/result groups together. If the last user message ends up in the *compressed* - middle region the LLM summariser writes it into "Pending User Asks", + middle region the LLM summariser writes it into "Historical Pending User Asks", but ``SUMMARY_PREFIX`` tells the next model to respond only to user messages *after* the summary — so the task effectively disappears from the active context, causing the agent to stall, repeat completed work, @@ -2037,7 +2112,8 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f ) # Phase 3: Generate structured summary - summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic) + summary_focus_topic = focus_topic or self._derive_auto_focus_topic(messages) + summary = self._generate_summary(turns_to_summarize, focus_topic=summary_focus_topic) # If summary generation failed, behavior splits on # ``abort_on_summary_failure`` (config: compression.abort_on_summary_failure): diff --git a/agent/context_references.py b/agent/context_references.py index dc03ccd740be..6307033d2706 100644 --- a/agent/context_references.py +++ b/agent/context_references.py @@ -246,7 +246,14 @@ def _expand_file_reference( if not path.is_file(): return f"{ref.raw}: path is not a file", None if _is_binary_file(path): - return f"{ref.raw}: binary files are not supported", None + # A binary file can't be inlined as text, but it IS on disk (the agent's + # tools run where this resolves — the local cwd, or the staged copy in a + # remote session workspace). Returning a bare "not supported" warning + # with no content was a dead end: the model saw a failure and gave up + # (told the user the file type wasn't supported). Instead, hand it an + # actionable block — the path, type, size, and a nudge to use its tools — + # so it can read/convert/view the file itself. + return None, _binary_reference_block(ref, path) text = path.read_text(encoding="utf-8") if ref.line_start is not None: @@ -493,6 +500,30 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None: return files[:limit] +def _human_bytes(n: int) -> str: + size = float(n) + for unit in ("B", "KB", "MB", "GB"): + if size < 1024 or unit == "GB": + return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} GB" + + +def _binary_reference_block(ref: ContextReference, path: Path) -> str: + mime, _ = mimetypes.guess_type(path.name) + mime = mime or "application/octet-stream" + try: + size = _human_bytes(path.stat().st_size) + except OSError: + size = "unknown size" + return ( + f"📎 {ref.raw} ({mime}, {size}) — binary file, not inlined as text. " + f"It is available on disk at `{path}`. Use your tools to work with it " + f"(read or convert it, extract its text, or view/render it as needed); " + f"do not tell the user the file type is unsupported." + ) + + def _file_metadata(path: Path) -> str: if _is_binary_file(path): return f"{path.stat().st_size} bytes" diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 73bed6b0670d..8850b7fd565a 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -2221,30 +2221,54 @@ def _perform_api_call(next_api_kwargs): print(f"{agent.log_prefix} • Legacy cleanup: hermes config set ANTHROPIC_TOKEN \"\"") print(f"{agent.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_API_KEY \"\"") - # ── Thinking block signature recovery ───────────────── + # Thinking block signature recovery. + # # Anthropic signs thinking blocks against the full turn - # content. Any upstream mutation (context compression, + # content. Any upstream mutation (context compression, # session truncation, message merging) invalidates the - # signature → HTTP 400. Recovery: strip reasoning_details - # from all messages so the next retry sends no thinking - # blocks at all. One-shot — don't retry infinitely. + # signature and the API replies HTTP 400 ("invalid + # signature" or "cannot be modified"). Recovery strips + # ``reasoning_details`` so the retry sends no thinking + # blocks at all. One-shot per outer loop. + # + # The strip targets ``api_messages``, which is the + # API-call-time list that ``_build_api_kwargs`` consumes + # on every retry. ``api_messages`` was populated once at + # the start of the turn from shallow copies of + # ``messages``, so mutating it does not touch the + # canonical store. The previous implementation popped + # ``reasoning_details`` from ``messages`` instead, which + # had two problems: ``api_messages`` carried its own + # reference to the field through the shallow copy, so the + # retry's wire payload still included thinking blocks and + # the recovery never reached the API; and the mutation + # persisted into ``state.db`` through any subsequent + # ``_persist_session`` call, permanently corrupting the + # conversation. Future turns would replay the stripped + # state, hit the same 400, and the agent would terminate + # with ``max_retries_exhausted``, often spawning + # cascading compaction-ended sessions chained off the + # corrupted parent. if ( classified.reason == FailoverReason.thinking_signature and not _retry.thinking_sig_retry_attempted ): _retry.thinking_sig_retry_attempted = True - for _m in messages: - if isinstance(_m, dict): + _api_stripped = 0 + for _m in api_messages: + if isinstance(_m, dict) and "reasoning_details" in _m: _m.pop("reasoning_details", None) + _api_stripped += 1 agent._vprint( - f"{agent.log_prefix}⚠️ Thinking block signature invalid — " - f"stripped all thinking blocks, retrying...", + f"{agent.log_prefix}⚠️ Thinking block signature invalid, " + f"stripped reasoning_details from api_messages for retry...", force=True, ) logger.warning( "%sThinking block signature recovery: stripped " - "reasoning_details from %d messages", - agent.log_prefix, len(messages), + "reasoning_details from %d api_messages " + "(canonical messages unchanged)", + agent.log_prefix, _api_stripped, ) continue diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index 79d05dbb196c..19e5b1582dae 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -194,17 +194,71 @@ class AgentNotice: id: Optional[str] = None +# ── is_free_tier_model (local-data-only free-model check) ──────────────────── + + +def is_free_tier_model(model: str, base_url: str = "") -> bool: + """Return True when *model* is a Nous free-tier model, using ONLY local data. + + Two signals, both zero-network: + + 1. The ``:free`` suffix — the canonical Nous free SKU marker (e.g. + ``nvidia/nemotron-3-ultra:free``). Free by construction on the API side + (spend is forced to 0 for ``:free`` ids). + 2. A peek into the in-process pricing cache in ``hermes_cli.models`` + (populated when the model picker fetched ``/v1/models`` pricing for + *base_url*). PEEK ONLY — a cache miss never triggers a fetch. This is + CLI/TUI-session best-effort: gateway sessions never run the picker's + pricing fetch, so suppression there rests entirely on the ``:free`` + suffix (which all Nous free SKUs carry). + + Fail-open to False (the depleted notice still shows) on any error: wrongly + showing the warning is recoverable noise; wrongly hiding it on a paid model + would mask a real billing block. + """ + if not model: + return False + if model.endswith(":free"): + return True + if not base_url: + return False + try: + from hermes_cli.models import _is_model_free, _pricing_cache + + # Mirror get_pricing_for_provider's key normalization: the agent's + # Nous base_url is /v1-suffixed (https://inference-api.nousresearch.com/v1) + # but the picker keys _pricing_cache on the pre-/v1 root. + key = base_url.rstrip("/") + if key.endswith("/v1"): + key = key[:-3].rstrip("/") + pricing = _pricing_cache.get(key) + if not pricing: + return False + return _is_model_free(model, pricing) + except Exception: + return False + + # ── evaluate_credits_notices (pure reconciliation function) ────────────────── def evaluate_credits_notices( state: CreditsState, latch: dict, + *, + model_is_free: bool = False, ) -> tuple[list[AgentNotice], list[str]]: """Reconcile credits notices against the latch. Mutates ``latch`` IN PLACE. latch = {"active": set[str], "seen_below_90": bool, "usage_band": Optional[int]}. + ``model_is_free``: True when the session's active model is a Nous free-tier + model (see :func:`is_free_tier_model`). Suppresses the ``credits.depleted`` + notice — a depleted account on a free model can keep inferencing, so the + error banner is noise (and confuses free-tier users who never had credits). + Suppression does NOT emit the "restored" success notice; that fires only on + a genuine ``paid_access`` flip back to True. + Returns ``(to_show: list[AgentNotice], to_clear: list[str])``. Caller emits to_clear FIRST, then to_show. @@ -232,6 +286,16 @@ def evaluate_credits_notices( for band in CREDITS_USAGE_BANDS: # ascending → last match wins = highest if uf >= band[0]: current_band = band + # Top-up suppression: when the account holds purchased (top-up) credits, + # the subscription-cap gauge is the wrong denominator — warning "90% used" + # at a user sitting on $50 of top-up is noise (and it previously stuck + # PERMANENTLY alongside grant_spent at >=100%). Suppress the usage band + # entirely; the cap-reached case is covered by the grant_spent info notice + # below, which already names the remaining top-up balance. A top-up landing + # mid-session flips current_band → None and the clear path below removes + # any showing band line. + if state.purchased_micros > 0: + current_band = None grant_cond = ( state.denominator_kind == "subscription_cap" and uf is not None @@ -284,10 +348,14 @@ def evaluate_credits_notices( active.discard("credits.grant_spent") # ── depleted ───────────────────────────────────────────────────────────── - if depleted_cond and "credits.depleted" not in active: + # Suppressed while the active model is free: inference still works there, + # so the error banner would just alarm users (free-tier users especially, + # who never had paid credits to "lose"). + show_depleted = depleted_cond and not model_is_free + if show_depleted and "credits.depleted" not in active: to_show.append( AgentNotice( - text="✕ Credit access paused · run /usage for balance", + text="✕ Credit access paused · run /credits to top up", level="error", kind=CREDITS_NOTICE_KIND, key="credits.depleted", @@ -295,20 +363,23 @@ def evaluate_credits_notices( ) ) active.add("credits.depleted") - elif "credits.depleted" in active and not depleted_cond: + elif "credits.depleted" in active and not show_depleted: to_clear.append("credits.depleted") active.discard("credits.depleted") - # Recovery: also emit the success notice - to_show.append( - AgentNotice( - text="✓ Credit access restored", - level="success", - kind="ttl", - ttl_ms=CREDITS_RESTORED_TTL_MS, - key="credits.restored", - id="credits.restored", + if not depleted_cond: + # Genuine recovery (paid_access flipped back True): also emit the + # success notice. A clear caused by switching to a free model while + # still depleted must NOT claim access was restored. + to_show.append( + AgentNotice( + text="✓ Credit access restored", + level="success", + kind="ttl", + ttl_ms=CREDITS_RESTORED_TTL_MS, + key="credits.restored", + id="credits.restored", + ) ) - ) return (to_show, to_clear) diff --git a/agent/curator.py b/agent/curator.py index 93986da7a759..62630ce453be 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -25,7 +25,6 @@ import logging import os import re -import tempfile import threading from datetime import datetime, timedelta, timezone from pathlib import Path @@ -33,6 +32,7 @@ from hermes_constants import get_hermes_home from tools import skill_usage +from utils import atomic_json_write logger = logging.getLogger(__name__) @@ -97,20 +97,7 @@ def load_state() -> Dict[str, Any]: def save_state(data: Dict[str, Any]) -> None: path = _state_file() try: - path.parent.mkdir(parents=True, exist_ok=True) - fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".curator_state_", suffix=".tmp") - try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False) - f.flush() - os.fsync(f.fileno()) - os.replace(tmp, path) - except BaseException: - try: - os.unlink(tmp) - except OSError: - pass - raise + atomic_json_write(path, data, indent=2, sort_keys=True) except Exception as e: logger.debug("Failed to save curator state: %s", e, exc_info=True) diff --git a/agent/display.py b/agent/display.py index 8514279888ea..84c8509faed2 100644 --- a/agent/display.py +++ b/agent/display.py @@ -858,6 +858,20 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str] return False, "" +def _used_free_parallel(result: str | None) -> bool: + """True when a web result came from Parallel's free Search MCP. + + Only the keyless Parallel path tags its result with ``provider="parallel"``; + the paid REST path and every other provider omit it. Used to label the tool + line "Parallel search" / "Parallel fetch" exactly when the free MCP served + the call. + """ + if not isinstance(result, str) or '"provider"' not in result: + return False + data = safe_json_loads(result) + return isinstance(data, dict) and str(data.get("provider", "")).lower() == "parallel" + + def get_cute_tool_message( tool_name: str, args: dict, duration: float, result: str | None = None, ) -> str: @@ -895,15 +909,17 @@ def _wrap(line: str) -> str: return f"{line}{failure_suffix}" if tool_name == "web_search": - return _wrap(f"┊ 🔍 search {_trunc(args.get('query', ''), 42)} {dur}") + verb = "Parallel search" if _used_free_parallel(result) else "search" + return _wrap(f"┊ 🔍 {verb:<9} {_trunc(args.get('query', ''), 42)} {dur}") if tool_name == "web_extract": + verb = "Parallel fetch" if _used_free_parallel(result) else "fetch" urls = args.get("urls", []) if urls: url = urls[0] if isinstance(urls, list) else str(urls) domain = url.replace("https://", "").replace("http://", "").split("/")[0] extra = f" +{len(urls)-1}" if len(urls) > 1 else "" - return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}") - return _wrap(f"┊ 📄 fetch pages {dur}") + return _wrap(f"┊ 📄 {verb:<9} {_trunc(domain, 35)}{extra} {dur}") + return _wrap(f"┊ 📄 {verb:<9} pages {dur}") if tool_name == "terminal": return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}") if tool_name == "process": diff --git a/agent/error_classifier.py b/agent/error_classifier.py index b5656232e39d..c39c24a6a5d2 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -549,14 +549,32 @@ def _result(reason: FailoverReason, **overrides) -> ClassifiedError: should_fallback=True, ) - # Anthropic thinking block signature invalid (400). + # Anthropic thinking block recovery (400). Two distinct failure modes, + # same recovery (strip all reasoning_details and retry without thinking + # blocks — see the thinking_signature handler in conversation_loop.py): + # 1. Signature mismatch: a thinking block is signed against the full + # turn content; any upstream mutation (context compression, session + # truncation, message merging) invalidates the signature. + # Pattern: "signature" + "thinking". + # 2. Frozen-block mutation: Anthropic rejects any change to the + # thinking/redacted_thinking blocks in the *latest* assistant + # message — "`thinking` or `redacted_thinking` blocks in the latest + # assistant message cannot be modified. These blocks must remain as + # they were in the original response." This carries no "signature" + # token, so the original pattern missed it and the turn hard-aborted + # as a non-retryable client error instead of self-healing. + # Pattern: "thinking" + ("cannot be modified" | "must remain as they were"). # Don't gate on provider — OpenRouter proxies Anthropic errors, so the # provider may be "openrouter" even though the error is Anthropic-specific. - # The message pattern ("signature" + "thinking") is unique enough. + # The combined patterns are unique enough. if ( status_code == 400 - and "signature" in error_msg and "thinking" in error_msg + and ( + "signature" in error_msg + or "cannot be modified" in error_msg + or "must remain as they were" in error_msg + ) ): return _result( FailoverReason.thinking_signature, @@ -966,6 +984,34 @@ def _classify_400( should_fallback=False, ) + # Request-validation errors (unsupported / unknown parameter) MUST be + # checked BEFORE context_overflow. A GPT-5 model rejecting max_tokens + # returns: + # "Unsupported parameter: 'max_tokens' is not supported with this model. + # Use 'max_completion_tokens' instead." + # That string contains the literal substring "max_tokens", which is one of + # the _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is + # misclassified as context_overflow, routed into the compression loop, + # re-sent with the same bad parameter, and ends in "Cannot compress + # further". These errors are deterministic (every retry gets the identical + # rejection), so classify as a non-retryable format_error and fall back. + # + # NOTE: we deliberately do NOT key off the generic ``invalid_request_error`` + # code here — OpenAI stamps that same code on genuine context-overflow 400s, + # so matching it would mis-route real overflows away from compression. The + # unambiguous signals are the explicit "unsupported/unknown parameter" + # message text and the specific parameter-level error codes. + if ( + any(p in error_msg for p in _REQUEST_VALIDATION_PATTERNS + if p != "invalid_request_error") + or error_code_lower in {"unknown_parameter", "unsupported_parameter"} + ): + return result_fn( + FailoverReason.format_error, + retryable=False, + should_fallback=True, + ) + # Context overflow from 400 if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS): return result_fn( diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 25f60a0d9617..3a71e974fdb7 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -141,6 +141,8 @@ def _strip_provider_prefix(model: str) -> str: # fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a # substring of "anthropic/claude-sonnet-4.6"). # OpenRouter-prefixed models resolve via OpenRouter live API or models.dev. + "claude-fable-5": 1000000, + "claude-fable": 1000000, "claude-opus-4-8": 1000000, "claude-opus-4.8": 1000000, "claude-opus-4-7": 1000000, @@ -1810,10 +1812,43 @@ def get_model_context_length( if ctx is not None: 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 + # must win over both models.dev (step 5g) and the hardcoded family catch-all + # (step 8). Before this branch, an OpenRouter selection set + # effective_provider="openrouter", which (a) made the models.dev lookup miss + # brand-new slugs and (b) skipped the step-6 OR fallback (gated on `not + # effective_provider`), so a fresh slug like claude-fable-5 fell through to + # the generic "claude": 200K entry and under-reported a 1M window. Mirrors + # the dedicated Nous/Copilot/GMI branches above. + if effective_provider == "openrouter": + metadata = fetch_model_metadata() + entry = metadata.get(model) + if entry: + or_ctx = entry.get("context_length") + # Guard against the known OpenRouter Kimi-family 32k underreport + # (same class the hardcoded overrides exist to mitigate). + if isinstance(or_ctx, int) and or_ctx > 0 and not ( + or_ctx == 32768 and _model_name_suggests_kimi(model) + ): + return or_ctx + if effective_provider: from agent.models_dev import lookup_models_dev_context ctx = lookup_models_dev_context(effective_provider, model) if ctx: + # MiniMax M3: models.dev reports 512K but actual context is 1M. + # Prefer hardcoded catalog over stale probe value. + if _model_name_suggests_minimax_m3(model): + catalog = DEFAULT_CONTEXT_LENGTHS.get("minimax-m3") + if catalog and ctx < catalog: + logger.info( + "Rejecting models.dev context=%s for %r " + "(MiniMax-M3 underreport); using hardcoded default %s", + ctx, model, f"{catalog:,}", + ) + ctx = catalog return ctx # 6. OpenRouter live API metadata — provider-unaware fallback. diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 26fcfaae32f9..3e7c729c0b91 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -489,6 +489,23 @@ def format_steer_marker(steer_text: str) -> str: "files arrive as downloadable documents. You can also include image " "URLs in markdown format ![alt](url) and they will be sent as photos." ), + "whatsapp_cloud": ( + "You are on a text messaging communication platform, WhatsApp " + "(via Meta's official Business Cloud API). Standard markdown " + "(**bold**, ~~strike~~, # headers, [links](url)) is auto-converted " + "to WhatsApp's native syntax (*bold*, ~strike~, etc.) — feel free " + "to write in markdown. Tables are NOT supported — prefer bullet " + "lists or labeled key:value pairs. " + "You can send media files natively: include MEDIA:/absolute/path/to/file " + "in your response. Images (.jpg, .png) become photo attachments, " + "videos (.mp4) play inline, audio (.mp3, .ogg) sends as voice/audio " + "messages, other files arrive as documents. Image URLs in markdown " + "format ![alt](url) also work. " + "IMPORTANT: this platform has a 24-hour conversation window — if the " + "user hasn't messaged in 24h, free-form replies are refused by Meta " + "(error 131047). This rarely matters for live chat, but is worth " + "knowing if you're scheduling a delayed message." + ), "telegram": ( "You are on a text messaging communication platform, Telegram. " "Standard markdown is automatically converted to Telegram format. " @@ -885,6 +902,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) @@ -1085,11 +1118,12 @@ def _skill_should_show( def build_skills_system_prompt( available_tools: "set[str] | None" = None, available_toolsets: "set[str] | None" = None, + compact_categories: "frozenset[str] | None" = None, ) -> str: """Build a compact skill index for the system prompt. Two-layer cache: - 1. In-process LRU dict keyed by (skills_dir, tools, toolsets) + 1. In-process LRU dict keyed by (skills_dir, tools, toolsets, hidden) 2. Disk snapshot (``.skills_prompt_snapshot.json``) validated by mtime/size manifest — survives process restarts @@ -1099,6 +1133,12 @@ def build_skills_system_prompt( scanned alongside the local ``~/.hermes/skills/`` directory. External dirs are read-only — they appear in the index but new skills are always created in the local dir. Local skills take precedence when names collide. + + ``compact_categories`` (e.g. from the coding posture — see + agent/coding_context.py) demotes whole categories to a names-only line in + the rendered index. Nothing is ever hidden: every skill name stays + visible and loadable via ``skill_view`` / ``skills_list``; only the + descriptions are dropped, and a footer note explains the demotion. """ skills_dir = get_skills_dir() external_dirs = get_all_skills_dirs()[1:] # skip local (index 0) @@ -1123,6 +1163,7 @@ def build_skills_system_prompt( tuple(sorted(str(ts) for ts in (available_toolsets or set()))), _platform_hint, tuple(sorted(disabled)), + tuple(sorted(compact_categories or ())), ) with _SKILLS_PROMPT_CACHE_LOCK: cached = _SKILLS_PROMPT_CACHE.get(cache_key) @@ -1256,18 +1297,44 @@ def build_skills_system_prompt( except Exception as e: logger.debug("Could not read external skill description %s: %s", desc_file, e) + # Posture-driven category demotion (e.g. non-coding skills while pairing + # on code). Demoted categories stay in the index as a single names-only + # line — descriptions are dropped to cut noise, but every skill name + # remains visible so memory-anchored recall ("load ") keeps working. + # NEVER remove entries entirely: agent-created skills are the model's + # project memory, and models don't reach for skills_list to rediscover + # what the index stops showing them. Match on the top-level category + # segment so nested categories ("social-media/twitter") are demoted with + # their parent. + demoted = frozenset( + cat for cat in skills_by_category + if cat.split("/", 1)[0] in (compact_categories or frozenset()) + ) + + hidden_note = "" + if demoted: + hidden_note = ( + "\n(Categories marked [names only] are outside the current coding " + "context, so their descriptions are omitted — the skills work " + "normally and load with skill_view(name) as usual.)" + ) + if not skills_by_category: result = "" else: index_lines = [] for category in sorted(skills_by_category.keys()): + # Deduplicate and sort skills within each category + seen = set() + if category in demoted: + names = sorted({name for name, _ in skills_by_category[category]}) + index_lines.append(f" {category} [names only]: {', '.join(names)}") + continue cat_desc = category_descriptions.get(category, "") if cat_desc: index_lines.append(f" {category}: {cat_desc}") else: index_lines.append(f" {category}:") - # Deduplicate and sort skills within each category - seen = set() for name, desc in sorted(skills_by_category[category], key=lambda x: x[0]): if name in seen: continue @@ -1304,6 +1371,7 @@ def build_skills_system_prompt( "\n" "\n" "Only proceed without loading a skill if genuinely none are relevant to the task." + + hidden_note ) # ── Store in LRU cache ──────────────────────────────────────────── @@ -1367,13 +1435,13 @@ def _status_line(feature) -> str: lines = [ "# Nous Subscription", - "Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browser Use) by default. Modal execution is optional.", + "Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, OpenAI Whisper STT, and browser automation (Browser Use) by default. Modal execution is optional.", "Current capability status:", ] lines.extend(_status_line(feature) for feature in features.items()) lines.extend( [ - "When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys.", + "When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, OpenAI Whisper, or Browser-Use API keys.", "If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.", "Do not mention subscription unless the user asks about it or it directly solves the current missing capability.", "Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.", diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 4038716df487..76f57dfcdbc0 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -191,9 +191,23 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) ) if toolset } + # Focus mode (opt-in) demotes non-coding skill categories to + # names-only in the index (never hidden — skill_view/skills_list + # reach everything, and every name stays visible for recall). The + # default coding posture leaves the index untouched. + _compact_cats = frozenset() + try: + from agent.coding_context import coding_compact_skill_categories + + _compact_cats = coding_compact_skill_categories( + platform=agent.platform, cwd=resolve_context_cwd() + ) + except Exception: + _compact_cats = frozenset() skills_prompt = _r.build_skills_system_prompt( available_tools=agent.valid_tool_names, available_toolsets=avail_toolsets, + compact_categories=_compact_cats or None, ) else: skills_prompt = "" @@ -221,6 +235,26 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) if _env_hints: stable_parts.append(_env_hints) + # Coding posture (base Hermes, any interactive coding surface in a code + # workspace — see agent/coding_context.py). The operating brief + the live + # git/workspace snapshot are built once here and cached for the session; + # the snapshot is never re-probed per turn (that would break the prompt + # cache), so the brief tells the model to re-check git before relying on it. + if agent.valid_tool_names: + try: + from agent.coding_context import coding_system_blocks + + stable_parts.extend( + coding_system_blocks( + platform=agent.platform, + cwd=resolve_context_cwd(), + model=agent.model, + ) + ) + except Exception: + # Coding-context probing must never block prompt build. + pass + # Local Python toolchain probe — names python/pip/uv/PEP-668 state when # something is non-default so the model can pick the right install # strategy without discovering by failure. Emits a single line; emits diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 36cbad4b8862..144a29297826 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -417,7 +417,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # ── Logging / callbacks ────────────────────────────────────────── tool_names_str = ", ".join(name for _, name, _, _, _, _ in parsed_calls) - if not agent.quiet_mode: + if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off": print(f" ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}") for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1): args_str = json.dumps(args, ensure_ascii=False) @@ -702,7 +702,7 @@ def _run_tool(index, tool_call, function_name, function_args, middleware_trace): if agent._should_emit_quiet_tool_messages(): cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result) agent._safe_print(f" {cute_msg}") - elif getattr(agent, "tool_progress_mode", "all") != "off": + elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off": _preview_str = _multimodal_text_summary(function_result) if agent.verbose_logging: print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s") @@ -866,7 +866,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe elif function_name == "skill_manage": agent._iters_since_skill = 0 - if not agent.quiet_mode: + if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off": args_str = json.dumps(function_args, ensure_ascii=False) if agent.verbose_logging: print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())})") @@ -1065,6 +1065,25 @@ def _execute(next_args: dict) -> Any: tool_duration = time.time() - tool_start_time if agent._should_emit_quiet_tool_messages(): agent._vprint(f" {_get_cute_tool_message_impl('clarify', function_args, tool_duration, result=function_result)}") + elif function_name == "read_terminal": + def _execute(next_args: dict) -> Any: + from tools.read_terminal_tool import read_terminal_tool as _read_terminal_tool + return _read_terminal_tool( + start_line=next_args.get("start_line"), + count=next_args.get("count"), + callback=getattr(agent, "read_terminal_callback", None), + ) + function_result, function_args = _run_agent_tool_execution_middleware( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + execute=_execute, + ) + tool_duration = time.time() - tool_start_time + if agent._should_emit_quiet_tool_messages(): + agent._vprint(f" {_get_cute_tool_message_impl('read_terminal', function_args, tool_duration, result=function_result)}") elif function_name == "delegate_task": tasks_arg = function_args.get("tasks") if tasks_arg and isinstance(tasks_arg, list): @@ -1365,7 +1384,7 @@ def _execute(next_args: dict) -> Any: # entire batch. The model sees it on the next API iteration. agent._apply_pending_steer_to_tool_results(messages, 1) - if not agent.quiet_mode: + if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off": if agent.verbose_logging: print(f" ✅ Tool {i} completed in {tool_duration:.2f}s") print(agent._wrap_verbose("Result: ", function_result)) diff --git a/agent/transports/anthropic.py b/agent/transports/anthropic.py index d77ae63ef327..3a209f2d753b 100644 --- a/agent/transports/anthropic.py +++ b/agent/transports/anthropic.py @@ -84,7 +84,7 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse: to OpenAI finish_reason, and collects reasoning_details in provider_data. """ import json - from agent.anthropic_adapter import _to_plain_data + from agent.anthropic_adapter import _to_plain_data, _sanitize_replay_block from agent.transports.types import ToolCall strip_tool_prefix = kwargs.get("strip_tool_prefix", False) @@ -94,14 +94,40 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse: reasoning_parts = [] reasoning_details = [] tool_calls = [] + # Verbatim, order-preserving copy of every content block in the turn. + # Anthropic signs each thinking block against the turn content that + # PRECEDES it at its position; when a turn interleaves thinking and + # tool_use (adaptive/interleaved thinking, Claude 4.6+), the parallel + # reasoning_details + tool_calls lists below lose that cross-type + # ordering. Replaying the latest assistant message in the wrong order + # invalidates the signatures -> HTTP 400 "thinking ... blocks in the + # latest assistant message cannot be modified". Preserve the exact + # block sequence here so the adapter can replay it unchanged. See + # tests/agent/test_anthropic_thinking_block_order.py. + ordered_blocks = [] for block in response.content: + block_dict = _to_plain_data(block) + clean_block = None + if isinstance(block_dict, dict): + # Sanitize at capture so output-only SDK fields (parsed_output, + # caller, citations=None, …) never persist to state.db and leak + # back as request input on replay → HTTP 400 "Extra inputs are + # not permitted". Defence-in-depth with the replay-side sanitize. + clean_block = _sanitize_replay_block(block_dict) + if clean_block is not None: + ordered_blocks.append(clean_block) if block.type == "text": text_parts.append(block.text) - elif block.type == "thinking": - reasoning_parts.append(block.thinking) - block_dict = _to_plain_data(block) - if isinstance(block_dict, dict): + elif block.type in ("thinking", "redacted_thinking"): + if block.type == "thinking": + reasoning_parts.append(block.thinking) + # Use the sanitized block (clean_block) for reasoning_details too, + # since _extract_preserved_thinking_blocks replays these on the + # non-ordered path. Falls back to raw only if sanitize dropped it. + if isinstance(clean_block, dict): + reasoning_details.append(clean_block) + elif isinstance(block_dict, dict): reasoning_details.append(block_dict) elif block.type == "tool_use": name = block.name @@ -130,6 +156,23 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse: provider_data = {} if reasoning_details: provider_data["reasoning_details"] = reasoning_details + # Only worth carrying the ordered-blocks channel when the turn + # actually interleaves signed thinking with tool_use — that's the + # only shape the parallel lists reconstruct incorrectly. A turn that + # is purely text, or thinking-then-tools with a single leading + # thinking block, replays correctly without it. + _has_signed_thinking = any( + isinstance(b, dict) + and b.get("type") in ("thinking", "redacted_thinking") + and (b.get("signature") or b.get("data")) + for b in ordered_blocks + ) + _has_tool_use = any( + isinstance(b, dict) and b.get("type") == "tool_use" + for b in ordered_blocks + ) + if _has_signed_thinking and _has_tool_use: + provider_data["anthropic_content_blocks"] = ordered_blocks return NormalizedResponse( content="\n".join(text_parts) if text_parts else None, diff --git a/agent/transports/types.py b/agent/transports/types.py index 2deb157535b4..6ad20f2376d1 100644 --- a/agent/transports/types.py +++ b/agent/transports/types.py @@ -121,6 +121,18 @@ def reasoning_details(self): pd = self.provider_data or {} return pd.get("reasoning_details") + @property + def anthropic_content_blocks(self): + """Verbatim, order-preserving Anthropic content blocks for a turn. + + Present only when an Anthropic turn interleaves signed thinking with + tool_use — the one shape the parallel reasoning_details + tool_calls + lists reconstruct in the wrong order, invalidating thinking-block + signatures on replay. See agent/transports/anthropic.py. + """ + pd = self.provider_data or {} + return pd.get("anthropic_content_blocks") + @property def codex_reasoning_items(self): pd = self.provider_data or {} diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index 8d6b85cd0b86..95bb11df521e 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -13,6 +13,7 @@ _ZERO = Decimal("0") _ONE_MILLION = Decimal("1000000") +_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1" CostStatus = Literal["actual", "estimated", "included", "unknown"] CostSource = Literal[ @@ -570,6 +571,8 @@ def resolve_billing_route( return BillingRoute(provider="openai-codex", model=model, base_url=base_url or "", billing_mode="subscription_included") if provider_name == "openrouter" or base_url_host_matches(base_url or "", "openrouter.ai"): return BillingRoute(provider="openrouter", model=model, base_url=base_url or "", billing_mode="official_models_api") + if provider_name == "nous" or base_url_host_matches(base_url or "", "inference-api.nousresearch.com"): + 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") if provider_name == "openai": diff --git a/apps/bootstrap-installer/package.json b/apps/bootstrap-installer/package.json index 6b7991eafd1d..9b3dc46a4a06 100644 --- a/apps/bootstrap-installer/package.json +++ b/apps/bootstrap-installer/package.json @@ -11,7 +11,8 @@ "tauri": "tauri", "tauri:dev": "tauri dev", "tauri:build": "tauri build", - "tauri:build:debug": "tauri build --debug" + "tauri:build:debug": "tauri build --debug", + "typecheck": "tsc -p . --noEmit" }, "dependencies": { "@nous-research/ui": "0.16.0", @@ -40,7 +41,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", - "typescript": "~5.9.3", + "typescript": "^6.0.3", "vite": "^7.3.1" } } diff --git a/apps/bootstrap-installer/tsconfig.json b/apps/bootstrap-installer/tsconfig.json index e2a5f6bbeb61..9227970f066a 100644 --- a/apps/bootstrap-installer/tsconfig.json +++ b/apps/bootstrap-installer/tsconfig.json @@ -16,9 +16,8 @@ "noUnusedParameters": true, "esModuleInterop": true, "noFallthroughCasesInSwitch": true, - "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": ["./src/*"] } }, "include": ["src"], diff --git a/apps/desktop/README.md b/apps/desktop/README.md index f3084a9b6478..301b094592f4 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -93,7 +93,7 @@ Run before opening a PR (lint may surface pre-existing warnings but must exit cl ```bash npm run fix -npm run type-check +npm run typecheck npm run lint npm run test:desktop:all ``` diff --git a/apps/desktop/assets/icon.icns b/apps/desktop/assets/icon.icns index e173b26ee23a..002b639e6dd7 100644 Binary files a/apps/desktop/assets/icon.icns and b/apps/desktop/assets/icon.icns differ diff --git a/apps/desktop/assets/icon.ico b/apps/desktop/assets/icon.ico index eaa48ff2dd67..1dcf20d8c381 100644 Binary files a/apps/desktop/assets/icon.ico and b/apps/desktop/assets/icon.ico differ diff --git a/apps/desktop/assets/icon.png b/apps/desktop/assets/icon.png index e0f04fe72554..539fdf9b7e59 100644 Binary files a/apps/desktop/assets/icon.png and b/apps/desktop/assets/icon.png differ diff --git a/apps/desktop/electron/backend-env.cjs b/apps/desktop/electron/backend-env.cjs new file mode 100644 index 000000000000..d3b65f4f781c --- /dev/null +++ b/apps/desktop/electron/backend-env.cjs @@ -0,0 +1,101 @@ +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, +// which misses Apple Silicon Homebrew and user-installed CLI tools such as codex. +const POSIX_SANE_PATH_ENTRIES = Object.freeze([ + '/opt/homebrew/bin', + '/opt/homebrew/sbin', + '/usr/local/sbin', + '/usr/local/bin', + '/usr/sbin', + '/usr/bin', + '/sbin', + '/bin' +]) + +function delimiterForPlatform(platform = process.platform) { + return platform === 'win32' ? ';' : ':' +} + +function pathModuleForPlatform(platform = process.platform) { + return platform === 'win32' ? path.win32 : path.posix +} + +function pathEnvKey(env = process.env, platform = process.platform) { + 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] || '' +} + +function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) { + const seen = new Set() + const ordered = [] + + for (const entry of entries) { + if (!entry) continue + const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter) + for (const part of parts) { + if (!part || seen.has(part)) continue + seen.add(part) + ordered.push(part) + } + } + + return ordered.join(delimiter) +} + +function buildDesktopBackendPath({ + hermesHome, + venvRoot, + currentPath = '', + platform = process.platform, + pathModule = pathModuleForPlatform(platform) +} = {}) { + const delimiter = delimiterForPlatform(platform) + const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null + const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null + const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES + + return appendUniquePathEntries( + [hermesNodeBin, venvBin, currentPath, saneEntries], + { delimiter } + ) +} + +function buildDesktopBackendEnv({ + hermesHome, + pythonPathEntries = [], + venvRoot, + currentEnv = process.env, + platform = process.platform, + pathModule = pathModuleForPlatform(platform) +} = {}) { + const delimiter = delimiterForPlatform(platform) + const currentPythonPath = currentEnv?.PYTHONPATH || '' + const key = pathEnvKey(currentEnv, platform) + + return { + PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }), + [key]: buildDesktopBackendPath({ + hermesHome, + venvRoot, + currentPath: currentPathValue(currentEnv, platform), + platform, + pathModule + }) + } +} + +module.exports = { + POSIX_SANE_PATH_ENTRIES, + appendUniquePathEntries, + buildDesktopBackendEnv, + buildDesktopBackendPath, + delimiterForPlatform, + pathEnvKey +} diff --git a/apps/desktop/electron/backend-env.test.cjs b/apps/desktop/electron/backend-env.test.cjs new file mode 100644 index 000000000000..1011161917aa --- /dev/null +++ b/apps/desktop/electron/backend-env.test.cjs @@ -0,0 +1,95 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const path = require('node:path') + +const { + POSIX_SANE_PATH_ENTRIES, + appendUniquePathEntries, + buildDesktopBackendEnv, + buildDesktopBackendPath, + pathEnvKey +} = require('./backend-env.cjs') + +test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => { + const result = buildDesktopBackendPath({ + hermesHome: '/Users/test/.hermes', + venvRoot: '/Users/test/.hermes/hermes-agent/venv', + currentPath: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin', + platform: 'darwin', + pathModule: path.posix + }) + + const entries = result.split(':') + assert.equal(entries[0], '/Users/test/.hermes/node/bin') + assert.equal(entries[1], '/Users/test/.hermes/hermes-agent/venv/bin') + assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added') + assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added') + assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added') + + for (const expected of POSIX_SANE_PATH_ENTRIES) { + assert.ok(entries.includes(expected), `${expected} should be present`) + } +}) + +test('desktop backend PATH preserves first occurrence and avoids duplicates', () => { + const result = buildDesktopBackendPath({ + hermesHome: '/Users/test/.hermes', + venvRoot: '/Users/test/.hermes/hermes-agent/venv', + currentPath: '/opt/homebrew/bin:/usr/bin:/opt/homebrew/bin:/bin', + platform: 'darwin', + pathModule: path.posix + }) + + const entries = result.split(':') + assert.equal(entries.filter(entry => entry === '/opt/homebrew/bin').length, 1) + assert.ok( + entries.indexOf('/opt/homebrew/bin') < entries.indexOf('/opt/homebrew/sbin'), + 'existing Homebrew bin keeps its precedence over appended missing sane entries' + ) +}) + +test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () => { + const env = buildDesktopBackendEnv({ + hermesHome: '/Users/test/.hermes', + pythonPathEntries: ['/repo/hermes-agent'], + venvRoot: '/Users/test/.hermes/hermes-agent/venv', + currentEnv: { + PATH: '/usr/bin:/bin', + PYTHONPATH: '/existing/pythonpath' + }, + platform: 'darwin', + pathModule: path.posix + }) + + assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath') + assert.ok(env.PATH.startsWith('/Users/test/.hermes/node/bin:/Users/test/.hermes/hermes-agent/venv/bin:')) + assert.ok(env.PATH.includes('/opt/homebrew/bin')) +}) + +test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => { + const env = buildDesktopBackendEnv({ + hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes', + pythonPathEntries: ['C:\\repo\\hermes-agent'], + venvRoot: 'C:\\Users\\test\\AppData\\Local\\hermes\\hermes-agent\\venv', + currentEnv: { + Path: 'C:\\Windows\\System32;C:\\Windows', + PYTHONPATH: 'C:\\existing\\pythonpath' + }, + platform: 'win32', + pathModule: path.win32 + }) + + assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path') + assert.equal(env.PATH, undefined) + assert.ok(env.Path.startsWith('C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;')) + assert.ok(env.Path.includes('\\venv\\Scripts;')) + assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows')) + assert.equal(env.Path.includes('/opt/homebrew/bin'), false) +}) + +test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => { + assert.equal( + appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }), + '/a:/b:/c' + ) +}) diff --git a/apps/desktop/electron/bootstrap-runner.cjs b/apps/desktop/electron/bootstrap-runner.cjs index 95c43c955219..644f9405056e 100644 --- a/apps/desktop/electron/bootstrap-runner.cjs +++ b/apps/desktop/electron/bootstrap-runner.cjs @@ -40,6 +40,15 @@ const path = require('node:path') const https = require('node:https') const { spawn } = require('node:child_process') +const IS_WINDOWS = process.platform === 'win32' + +function hiddenWindowsChildOptions(options = {}) { + if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) { + return options + } + return { ...options, windowsHide: true } +} + const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i // Stages flagged needs_user_input=true in the manifest are skipped by the @@ -284,7 +293,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh' const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args] - const child = spawn(ps, fullArgs, { + const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, @@ -292,7 +301,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme // choice rather than re-computing the default. HERMES_HOME: hermesHome || process.env.HERMES_HOME || '' } - }) + })) let stdout = '' let stderr = '' diff --git a/apps/desktop/electron/dashboard-token.cjs b/apps/desktop/electron/dashboard-token.cjs new file mode 100644 index 000000000000..1a9ca50ad9c2 --- /dev/null +++ b/apps/desktop/electron/dashboard-token.cjs @@ -0,0 +1,99 @@ +/** + * Helpers for local dashboard session-token discovery. + * + * The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it + * spawns the local dashboard, but the dashboard is the source of truth for the + * token it actually serves to the renderer. If those drift, HTTP readiness + * probes still pass while /api/ws rejects the renderer's token. + */ + +const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000 + +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}`) + + return text +} + +function extractInjectedDashboardToken(html) { + const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || '')) + if (!match) return null + try { + return JSON.parse(match[1]) + } catch { + return null + } +} + +function dashboardIndexUrl(baseUrl) { + return `${String(baseUrl || '').replace(/\/+$/, '')}/` +} + +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') { + options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth') + } + + return servedToken || fallbackToken +} + +/** + * A served token that differs from our spawn token while our child is DEAD + * came from a process we did not spawn (orphan/port squatter that satisfied + * the public /api/status readiness probe). With a live child the mismatch is + * benign: our own backend regenerated the token because the env pin did not + * survive the spawn. + */ +function isForeignBackendToken({ servedToken, spawnToken, childAlive }) { + return Boolean(servedToken) && servedToken !== spawnToken && !childAlive +} + +/** + * Resolve the token the backend actually serves, adopting benign drift and + * failing loudly on a foreign backend. `childAlive` is a thunk so liveness is + * sampled after the fetch, not before. + */ +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 + }) + + if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) { + throw new Error( + `${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.` + ) + } + + return servedToken +} + +module.exports = { + DEFAULT_TOKEN_FETCH_TIMEOUT_MS, + adoptServedDashboardToken, + dashboardIndexUrl, + extractInjectedDashboardToken, + fetchPublicText, + isForeignBackendToken, + resolveServedDashboardToken +} diff --git a/apps/desktop/electron/dashboard-token.test.cjs b/apps/desktop/electron/dashboard-token.test.cjs new file mode 100644 index 000000000000..d598ffc2bc1d --- /dev/null +++ b/apps/desktop/electron/dashboard-token.test.cjs @@ -0,0 +1,142 @@ +/** + * Tests for electron/dashboard-token.cjs. + * + * Run with: node --test electron/dashboard-token.test.cjs + * (Wired into npm test:desktop:platforms in package.json.) + */ + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { + adoptServedDashboardToken, + dashboardIndexUrl, + extractInjectedDashboardToken, + fetchPublicText, + isForeignBackendToken, + resolveServedDashboardToken +} = require('./dashboard-token.cjs') + +test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => { + const html = '' + assert.equal(extractInjectedDashboardToken(html), 'served-token') +}) + +test('extractInjectedDashboardToken handles escaped token strings', () => { + const html = '' + assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted') +}) + +test('extractInjectedDashboardToken returns null for missing or malformed values', () => { + assert.equal(extractInjectedDashboardToken(''), null) + assert.equal(extractInjectedDashboardToken(''), null) +}) + +test('dashboardIndexUrl preserves dashboard path prefixes', () => { + assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/') + assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/') +}) + +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 '' + }, + rememberLog: line => logs.push(line) + }) + + assert.equal(token, 'served-token') + assert.equal(logs.length, 1) + assert.match(logs[0], /served a different session token/) +}) + +test('resolveServedDashboardToken falls back when the served HTML has no token', async () => { + const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { + fetchText: async () => '', + rememberLog: () => { + throw new Error('should not log when no served token is present') + } + }) + + assert.equal(token, 'spawn-token') +}) + +test('resolveServedDashboardToken does not log when served token matches fallback', async () => { + const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', { + fetchText: async () => '', + rememberLog: () => { + throw new Error('should not log when token already matches') + } + }) + + assert.equal(token, 'same-token') +}) + +test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => { + await assert.rejects( + () => + resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { + fetchText: async () => { + throw new Error('boom') + } + }), + /boom/ + ) +}) + +test('fetchPublicText rejects unsupported protocols', async () => { + await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/) +}) + +test('isForeignBackendToken only flags a mismatched token from a dead child', () => { + const cases = [ + [{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true], + // Live child + drift = our backend regenerated the token (env pin lost). + [{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false], + [{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false], + [{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false], + [{ servedToken: null, spawnToken: 'mine', childAlive: false }, false], + [{ servedToken: '', spawnToken: 'mine', childAlive: false }, false] + ] + for (const [input, expected] of cases) { + assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input)) + } +}) + +test('adoptServedDashboardToken adopts drift from a live child', async () => { + const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { + childAlive: () => true, + fetchText: async () => '' + }) + + assert.equal(token, 'served-token') +}) + +test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => { + await assert.rejects( + () => + adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', { + childAlive: () => false, + fetchText: async () => '', + label: 'Hermes backend for profile "work"' + }), + /profile "work".*process we did not spawn/ + ) +}) + +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 () => { + throw new Error('boom') + }, + rememberLog: line => logs.push(line) + }) + + assert.equal(token, 'spawn-token') + assert.equal(logs.length, 1) + assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/) +}) diff --git a/apps/desktop/electron/fs-read-dir.cjs b/apps/desktop/electron/fs-read-dir.cjs new file mode 100644 index 000000000000..52d182ad5670 --- /dev/null +++ b/apps/desktop/electron/fs-read-dir.cjs @@ -0,0 +1,109 @@ +'use strict' + +const fs = require('node:fs') +const path = require('node:path') +const { resolveDirectoryForIpc } = require('./hardening.cjs') + +const FS_READDIR_STAT_CONCURRENCY = 16 + +// Always-hidden noise (covers non-git projects too; gitignore catches many of +// these, but the project tree should keep the same hygiene without one). +const FS_READDIR_HIDDEN = new Set([ + '.git', + '.hg', + '.svn', + '.cache', + '.next', + '.turbo', + '.venv', + '__pycache__', + 'build', + 'dist', + 'node_modules', + 'target', + 'venv' +]) + +function direntIsDirectory(dirent) { + return typeof dirent.isDirectory === 'function' && dirent.isDirectory() +} + +function direntIsFile(dirent) { + return typeof dirent.isFile === 'function' && dirent.isFile() +} + +function direntIsSymbolicLink(dirent) { + return typeof dirent.isSymbolicLink === 'function' && dirent.isSymbolicLink() +} + +function shouldStatDirent(dirent) { + if (direntIsDirectory(dirent)) return false + + return direntIsSymbolicLink(dirent) || !direntIsFile(dirent) +} + +async function entryForDirent(dirent, resolved, fsImpl) { + const fullPath = path.join(resolved, dirent.name) + let isDirectory = direntIsDirectory(dirent) + + if (!isDirectory && shouldStatDirent(dirent)) { + try { + isDirectory = (await fsImpl.promises.stat(fullPath)).isDirectory() + } catch { + isDirectory = false + } + } + + return { name: dirent.name, path: fullPath, isDirectory } +} + +async function mapWithStatConcurrency(items, mapper) { + const results = new Array(items.length) + let nextIndex = 0 + + async function runWorker() { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await mapper(items[index]) + } + } + + const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length) + const workers = Array.from({ length: workerCount }, () => runWorker()) + await Promise.all(workers) + + return results +} + +async function readDirForIpc(dirPath, options = {}) { + const fsImpl = options.fs || fs + let resolved + + try { + ;({ resolvedPath: resolved } = await resolveDirectoryForIpc(dirPath, { + fs: fsImpl, + purpose: 'Directory read' + })) + } catch (error) { + return { entries: [], error: error?.code || 'read-error' } + } + + try { + const dirents = await fsImpl.promises.readdir(resolved, { withFileTypes: true }) + const visibleDirents = dirents.filter(dirent => !FS_READDIR_HIDDEN.has(dirent.name)) + const entries = await mapWithStatConcurrency(visibleDirents, dirent => + entryForDirent(dirent, resolved, fsImpl) + ) + + entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name)) + + return { entries } + } catch (error) { + return { entries: [], error: error?.code || 'read-error' } + } +} + +module.exports = { + readDirForIpc +} diff --git a/apps/desktop/electron/fs-read-dir.test.cjs b/apps/desktop/electron/fs-read-dir.test.cjs new file mode 100644 index 000000000000..42e80af3489d --- /dev/null +++ b/apps/desktop/electron/fs-read-dir.test.cjs @@ -0,0 +1,364 @@ +'use strict' + +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 = {}) { + return { + name, + isDirectory: () => Boolean(flags.directory), + isFile: () => Boolean(flags.file), + isSymbolicLink: () => Boolean(flags.symlink) + } +} + +test('readDirForIpc hides noisy directories and files from the project tree', async () => { + const root = mkTmpDir() + + try { + fs.mkdirSync(path.join(root, 'node_modules')) + fs.mkdirSync(path.join(root, 'src')) + fs.writeFileSync(path.join(root, 'target'), 'hidden file') + fs.writeFileSync(path.join(root, 'README.md'), 'visible file') + + const result = await readDirForIpc(root) + + assert.equal(result.error, undefined) + assert.deepEqual( + result.entries.map(entry => entry.name), + ['src', 'README.md'] + ) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('readDirForIpc filters a hidden basename whether it is a file or directory', async () => { + const dirRoot = mkTmpDir() + const fileRoot = mkTmpDir() + + try { + fs.mkdirSync(path.join(dirRoot, 'node_modules')) + fs.writeFileSync(path.join(dirRoot, 'visible.txt'), 'visible') + fs.writeFileSync(path.join(fileRoot, 'node_modules'), 'hidden file') + fs.writeFileSync(path.join(fileRoot, 'visible.txt'), 'visible') + + assert.deepEqual( + (await readDirForIpc(dirRoot)).entries.map(entry => entry.name), + ['visible.txt'] + ) + assert.deepEqual( + (await readDirForIpc(fileRoot)).entries.map(entry => entry.name), + ['visible.txt'] + ) + } finally { + fs.rmSync(dirRoot, { recursive: true, force: true }) + fs.rmSync(fileRoot, { recursive: true, force: true }) + } +}) + +test('readDirForIpc returns directories before files and sorts by name within groups', async () => { + const root = mkTmpDir() + + try { + fs.writeFileSync(path.join(root, 'z.txt'), 'z') + fs.mkdirSync(path.join(root, 'src')) + fs.writeFileSync(path.join(root, 'a.txt'), 'a') + fs.mkdirSync(path.join(root, 'lib')) + + const result = await readDirForIpc(root) + + assert.equal(result.error, undefined) + assert.deepEqual( + result.entries.map(entry => entry.name), + ['lib', 'src', 'a.txt', 'z.txt'] + ) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('readDirForIpc accepts file URLs for directories', async () => { + const root = mkTmpDir() + + try { + fs.mkdirSync(path.join(root, 'src')) + fs.writeFileSync(path.join(root, 'README.md'), 'visible file') + + const result = await readDirForIpc(pathToFileURL(root).toString()) + + assert.equal(result.error, undefined) + assert.deepEqual( + result.entries.map(entry => entry.name), + ['src', 'README.md'] + ) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('readDirForIpc returns invalid-path for blank or non-string input', async () => { + let readdirCalls = 0 + const fsImpl = { + promises: { + readdir: async () => { + readdirCalls += 1 + return [] + } + } + } + + assert.deepEqual(await readDirForIpc('', { fs: fsImpl }), { entries: [], error: 'invalid-path' }) + assert.deepEqual(await readDirForIpc(' ', { fs: fsImpl }), { entries: [], error: 'invalid-path' }) + assert.deepEqual(await readDirForIpc(null, { fs: fsImpl }), { entries: [], error: 'invalid-path' }) + assert.equal(readdirCalls, 0) +}) + +test('readDirForIpc rejects Windows device paths before readdir', async () => { + let readdirCalls = 0 + const fsImpl = { + promises: { + readdir: async () => { + readdirCalls += 1 + return [] + } + } + } + + assert.deepEqual(await readDirForIpc('\\\\?\\C:\\secret', { fs: fsImpl }), { + entries: [], + error: 'device-path' + }) + assert.equal(readdirCalls, 0) +}) + +test('readDirForIpc returns filesystem error codes instead of throwing', async () => { + const root = mkTmpDir() + + try { + const result = await readDirForIpc(path.join(root, 'missing')) + + assert.deepEqual(result, { entries: [], error: 'ENOENT' }) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('readDirForIpc marks a symlink to a directory as a directory', async t => { + const root = mkTmpDir() + + try { + fs.mkdirSync(path.join(root, 'actual-dir')) + + try { + fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'linked-dir'), 'dir') + } catch (error) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + t.skip(`symlink creation is not permitted on this platform (${error.code})`) + + return + } + + throw error + } + + const result = await readDirForIpc(root) + const linked = result.entries.find(entry => entry.name === 'linked-dir') + + assert.equal(result.error, undefined) + assert.equal(linked?.isDirectory, true) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('readDirForIpc marks a Windows junction to a directory as a directory', async t => { + if (process.platform !== 'win32') { + t.skip('junctions are a Windows-specific symlink type') + + return + } + + const root = mkTmpDir() + + try { + fs.mkdirSync(path.join(root, 'actual-dir')) + + try { + fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'junction-dir'), 'junction') + } catch (error) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + t.skip(`junction creation is not permitted on this platform (${error.code})`) + + return + } + + throw error + } + + const result = await readDirForIpc(root) + const junction = result.entries.find(entry => entry.name === 'junction-dir') + + assert.equal(result.error, undefined) + assert.equal(junction?.isDirectory, true) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('readDirForIpc allows expanding symlink or junction directories outside the project root', async t => { + const root = mkTmpDir() + const outside = mkTmpDir() + + try { + 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) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + t.skip(`directory symlink creation is not permitted on this platform (${error.code})`) + + return + } + + throw error + } + + const result = await readDirForIpc(linkPath) + + assert.equal(result.error, undefined) + assert.deepEqual(result.entries, [ + { name: 'outside.txt', path: path.join(linkPath, 'outside.txt'), isDirectory: false } + ]) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + fs.rmSync(outside, { recursive: true, force: true }) + } +}) + +test('readDirForIpc stats symbolic links and unknown entries without dropping the whole listing', async () => { + const input = path.join('virtual-root') + const resolved = path.resolve(input) + const statCalls = [] + const fsImpl = { + promises: { + readdir: async () => [ + fakeDirent('unknown-entry'), + fakeDirent('linked-dir', { symlink: true }), + fakeDirent('broken-link', { symlink: true }), + fakeDirent('plain.txt', { file: true }) + ], + stat: async fullPath => { + if (fullPath === resolved) { + return { isDirectory: () => true } + } + + statCalls.push(fullPath) + if (fullPath.endsWith(`${path.sep}linked-dir`)) { + return { isDirectory: () => true } + } + throw Object.assign(new Error('gone'), { code: 'ENOENT' }) + } + } + } + + const result = await readDirForIpc(input, { fs: fsImpl }) + + assert.equal(result.error, undefined) + assert.deepEqual( + statCalls.sort(), + [path.join(resolved, 'broken-link'), path.join(resolved, 'linked-dir'), path.join(resolved, 'unknown-entry')].sort() + ) + assert.deepEqual(result.entries, [ + { name: 'linked-dir', path: path.join(resolved, 'linked-dir'), isDirectory: true }, + { name: 'broken-link', path: path.join(resolved, 'broken-link'), isDirectory: false }, + { name: 'plain.txt', path: path.join(resolved, 'plain.txt'), isDirectory: false }, + { name: 'unknown-entry', path: path.join(resolved, 'unknown-entry'), isDirectory: false } + ]) +}) + +test('readDirForIpc bounds concurrent stats while preserving complete sorted output', async () => { + const input = path.join('virtual-root') + const resolved = path.resolve(input) + const names = Array.from({ length: 105 }, (_, index) => `entry-${String(104 - index).padStart(3, '0')}`) + const failedName = 'entry-100' + const directoryNames = new Set(names.filter((_, index) => index % 10 === 4)) + const successfulDirectoryNames = new Set([...directoryNames].filter(name => name !== failedName)) + const statCalls = [] + let active = 0 + 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 () => [ + fakeDirent('node_modules', { symlink: true }), + ...names.map((name, index) => fakeDirent(name, { symlink: index % 2 === 0 })) + ], + stat: async fullPath => { + if (fullPath === resolved) { + return { isDirectory: () => true } + } + + statCalls.push(fullPath) + active += 1 + peak = Math.max(peak, active) + markFirstStatStarted() + await statsReleased + active -= 1 + + const name = path.basename(fullPath) + if (name === failedName) { + throw Object.assign(new Error('gone'), { code: 'ENOENT' }) + } + + return { isDirectory: () => successfulDirectoryNames.has(name) } + } + } + } + + const resultPromise = readDirForIpc(input, { fs: fsImpl }) + await firstStatStarted + await new Promise(resolve => setImmediate(resolve)) + releaseStats() + const result = await resultPromise + + const expectedNames = [ + ...names.filter(name => successfulDirectoryNames.has(name)).sort(), + ...names.filter(name => !successfulDirectoryNames.has(name)).sort() + ] + + assert.equal(result.error, undefined) + assert.equal(result.entries.length, names.length) + assert.equal(statCalls.length, names.length) + assert.equal(statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)), false) + assert.ok(peak > 1, `expected concurrent stats, observed peak ${peak}`) + assert.ok(peak <= 16, `expected at most 16 concurrent stats, observed peak ${peak}`) + assert.deepEqual( + result.entries.map(entry => entry.name), + expectedNames + ) + assert.equal(result.entries.find(entry => entry.name === failedName)?.isDirectory, false) + assert.equal( + result.entries.filter(entry => entry.isDirectory).length, + successfulDirectoryNames.size + ) +}) diff --git a/apps/desktop/electron/git-root.cjs b/apps/desktop/electron/git-root.cjs new file mode 100644 index 000000000000..593d3531ebce --- /dev/null +++ b/apps/desktop/electron/git-root.cjs @@ -0,0 +1,54 @@ +'use strict' + +const fs = require('node:fs') +const path = require('node:path') +const { resolveRequestedPathForIpc } = require('./hardening.cjs') + +function findGitRoot(start, fsImpl = fs) { + let dir = start + + for (let i = 0; i < 50; i += 1) { + try { + if (fsImpl.existsSync(path.join(dir, '.git'))) { + return dir + } + } catch { + return null + } + + const parent = path.dirname(dir) + + if (parent === dir) { + return null + } + + dir = parent + } + + return null +} + +async function gitRootForIpc(startPath, options = {}) { + const fsImpl = options.fs || fs + let resolved + + try { + resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Git root' }) + } catch { + return null + } + + try { + const stat = await fsImpl.promises.stat(resolved) + const start = stat.isDirectory() ? resolved : path.dirname(resolved) + + return findGitRoot(start, fsImpl) + } catch { + return findGitRoot(resolved, fsImpl) + } +} + +module.exports = { + findGitRoot, + gitRootForIpc +} diff --git a/apps/desktop/electron/git-root.test.cjs b/apps/desktop/electron/git-root.test.cjs new file mode 100644 index 000000000000..ba649b259f3c --- /dev/null +++ b/apps/desktop/electron/git-root.test.cjs @@ -0,0 +1,40 @@ +'use strict' + +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-')) +} + +test('gitRootForIpc returns null for invalid and device paths', async () => { + assert.equal(await gitRootForIpc(''), null) + assert.equal(await gitRootForIpc(' '), null) + assert.equal(await gitRootForIpc(null), null) + assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null) + assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null) +}) + +test('gitRootForIpc resolves directories files missing descendants and file URLs', async t => { + const root = mkTmpDir() + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + + const gitDir = path.join(root, '.git') + const srcDir = path.join(root, 'src') + const filePath = path.join(srcDir, 'index.ts') + fs.mkdirSync(gitDir) + fs.mkdirSync(srcDir) + fs.writeFileSync(filePath, 'export {}\n', 'utf8') + + assert.equal(await gitRootForIpc(root), root) + assert.equal(await gitRootForIpc(srcDir), root) + assert.equal(await gitRootForIpc(filePath), root) + assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root) + assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root) +}) diff --git a/apps/desktop/electron/hardening.cjs b/apps/desktop/electron/hardening.cjs index 4ffdea051b51..812dc3f77c7c 100644 --- a/apps/desktop/electron/hardening.cjs +++ b/apps/desktop/electron/hardening.cjs @@ -106,71 +106,155 @@ function sensitiveFileBlockReason(filePath) { return null } -function resolveRequestedFilePath(filePath, baseDir = process.cwd(), purpose = 'File read') { - const raw = String(filePath || '').trim() +function ipcPathError(code, message) { + const error = new Error(message) + error.code = code + return error +} + +function rejectUnsafePathSyntax(filePath, purpose = 'File read') { + if (typeof filePath !== 'string') { + throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`) + } + + const raw = filePath.trim() if (!raw) { - throw new Error(`${purpose} failed: file path is required.`) + throw ipcPathError('invalid-path', `${purpose} failed: file path is required.`) } if (raw.includes('\0')) { - throw new Error(`${purpose} failed: file path is invalid.`) + throw ipcPathError('invalid-path', `${purpose} failed: file path is invalid.`) } - if (/^file:/i.test(raw)) { - try { - return fileURLToPath(raw) - } catch { - throw new Error(`${purpose} failed: file URL is invalid.`) - } + const normalized = raw.replace(/\\/g, '/').toLowerCase() + if ( + normalized.startsWith('//?/') || + normalized.startsWith('//./') || + normalized.startsWith('globalroot/device/') || + normalized.includes('/globalroot/device/') + ) { + throw ipcPathError('device-path', `${purpose} blocked: Windows device paths are not allowed.`) } - const resolvedBase = path.resolve(String(baseDir || process.cwd())) - return path.resolve(resolvedBase, raw) + return raw } -async function resolveReadableFileForIpc(filePath, options = {}) { +function resolveRequestedPathForIpc(filePath, options = {}) { const purpose = String(options.purpose || 'File read') - const resolvedPath = resolveRequestedFilePath(filePath, options.baseDir, purpose) + const raw = rejectUnsafePathSyntax(filePath, purpose) - if (options.blockSensitive !== false) { - const blockReason = sensitiveFileBlockReason(resolvedPath) - if (blockReason) { - throw new Error(`${purpose} blocked for sensitive file: ${blockReason}`) + 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) } - let stat + const baseInput = typeof options.baseDir === 'string' && options.baseDir.trim() ? options.baseDir : process.cwd() + const safeBaseInput = rejectUnsafePathSyntax(baseInput, purpose) + const resolvedBase = path.resolve(safeBaseInput) + rejectUnsafePathSyntax(resolvedBase, purpose) + const resolvedPath = path.resolve(resolvedBase, raw) + rejectUnsafePathSyntax(resolvedPath, purpose) + + return resolvedPath +} + +async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) { try { - stat = await fs.promises.stat(resolvedPath) + return await fsImpl.promises.stat(resolvedPath) } catch (error) { const code = error && typeof error === 'object' ? error.code : '' if (code === 'ENOENT' || code === 'ENOTDIR') { - throw new Error(`${purpose} failed: file does not exist.`) + throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`) } - throw new Error(`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`) + throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`) } +} + +async function realpathForIpc(fsImpl, resolvedPath, purpose) { + if (typeof fsImpl.promises.realpath !== 'function') { + return resolvedPath + } + + try { + const realPath = await fsImpl.promises.realpath(resolvedPath) + rejectUnsafePathSyntax(realPath, purpose) + return realPath + } catch (error) { + const code = error && typeof error === 'object' ? error.code : '' + throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`) + } +} + +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 = {}) { + const purpose = String(options.purpose || 'Directory read') + const fsImpl = options.fs || fs + const resolvedPath = resolveRequestedPathForIpc(dirPath, { baseDir: options.baseDir, purpose }) + const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'directory') + + if (!stat.isDirectory()) { + throw ipcPathError('ENOTDIR', `${purpose} failed: path is not a directory.`) + } + + const realPath = await realpathForIpc(fsImpl, resolvedPath, purpose) + + return { realPath, resolvedPath, stat } +} + +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 }) + + if (options.blockSensitive !== false) { + rejectSensitiveFilePath(resolvedPath, purpose) + } + + const stat = await statForIpc(fsImpl, resolvedPath, purpose, 'file') if (stat.isDirectory()) { - throw new Error(`${purpose} failed: path points to a directory.`) + throw ipcPathError('EISDIR', `${purpose} failed: path points to a directory.`) } if (!stat.isFile()) { - throw new Error(`${purpose} failed: only regular files can be read.`) + throw ipcPathError('EINVAL', `${purpose} failed: only regular files can be read.`) + } + + 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 new Error(`${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`) + throw ipcPathError('EFBIG', `${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`) } try { - await fs.promises.access(resolvedPath, fs.constants.R_OK) + await fsImpl.promises.access(resolvedPath, fs.constants.R_OK) } catch { - throw new Error(`${purpose} failed: file is not readable.`) + throw ipcPathError('EACCES', `${purpose} failed: file is not readable.`) } - return { resolvedPath, stat } + return { realPath, resolvedPath, stat } } module.exports = { @@ -178,7 +262,10 @@ module.exports = { DEFAULT_FETCH_TIMEOUT_MS, TEXT_PREVIEW_SOURCE_MAX_BYTES, encryptDesktopSecret, + rejectUnsafePathSyntax, + resolveDirectoryForIpc, resolveReadableFileForIpc, + resolveRequestedPathForIpc, resolveTimeoutMs, sensitiveFileBlockReason } diff --git a/apps/desktop/electron/hardening.test.cjs b/apps/desktop/electron/hardening.test.cjs index 865da8fe797e..a52ee27c830a 100644 --- a/apps/desktop/electron/hardening.test.cjs +++ b/apps/desktop/electron/hardening.test.cjs @@ -8,11 +8,20 @@ const { pathToFileURL } = require('node:url') const { DEFAULT_FETCH_TIMEOUT_MS, encryptDesktopSecret, + resolveDirectoryForIpc, resolveReadableFileForIpc, + resolveRequestedPathForIpc, resolveTimeoutMs, sensitiveFileBlockReason } = require('./hardening.cjs') +async function rejectsWithCode(promise, code) { + await assert.rejects(promise, error => { + assert.equal(error?.code, code) + return true + }) +} + test('resolveTimeoutMs falls back to defaults and accepts overrides', () => { assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS) assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS) @@ -51,6 +60,52 @@ test('sensitiveFileBlockReason blocks obvious secret file patterns', () => { assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/) }) +test('path helpers reject blank non-string NUL and Windows device syntax', async () => { + await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path') + await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path') + await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path') + await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path') + + const devicePaths = [ + '\\\\?\\C:\\secret.txt', + '\\\\.\\C:\\secret.txt', + '\\\\?\\UNC\\server\\share\\secret.txt', + 'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt' + ] + + for (const devicePath of devicePaths) { + assert.throws( + () => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }), + error => { + assert.equal(error?.code, 'device-path') + return true + } + ) + await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path') + } + + assert.throws( + () => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), + error => { + assert.equal(error?.code, 'invalid-path') + return true + } + ) + await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path') +}) + +test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => { + const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base') + + assert.equal( + resolveRequestedPathForIpc('notes.txt', { + baseDir: ` ${baseDir} `, + purpose: 'File preview' + }), + path.resolve(baseDir, 'notes.txt') + ) +}) + test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-')) t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) @@ -71,6 +126,13 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity', }) 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( resolveReadableFileForIpc('missing.txt', { baseDir: tempDir, @@ -114,3 +176,91 @@ test('resolveReadableFileForIpc validates existence type size and sensitivity', }) assert.equal(envTemplate.resolvedPath, envTemplatePath) }) + +test('resolveReadableFileForIpc blocks common sensitive files', async t => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-')) + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const sshDir = path.join(tempDir, '.ssh') + fs.mkdirSync(sshDir) + + const blockedFiles = [ + path.join(tempDir, '.env'), + path.join(tempDir, '.npmrc'), + path.join(sshDir, 'id_ed25519'), + path.join(tempDir, 'cert.pem'), + path.join(tempDir, 'cert.p12'), + path.join(tempDir, 'cert.pfx') + ] + + for (const filePath of blockedFiles) { + fs.writeFileSync(filePath, 'secret', 'utf8') + await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file') + } + + const allowed = path.join(tempDir, '.env.example') + fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8') + assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed) +}) + +test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async t => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-')) + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const envPath = path.join(tempDir, '.env') + const linkPath = path.join(tempDir, 'safe-name.txt') + fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8') + + try { + fs.symlinkSync(envPath, linkPath, 'file') + } catch (error) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + t.skip(`symlink creation is not permitted on this platform (${error.code})`) + return + } + throw error + } + + await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file') +}) + +test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async t => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-')) + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const directory = path.join(tempDir, 'project') + const filePath = path.join(tempDir, 'file.txt') + fs.mkdirSync(directory) + fs.writeFileSync(filePath, 'not a directory', 'utf8') + + const resolved = await resolveDirectoryForIpc(directory) + assert.equal(resolved.resolvedPath, directory) + assert.equal(resolved.stat.isDirectory(), true) + + await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR') + await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT') + await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path') +}) + +test('resolveDirectoryForIpc accepts directory symlinks or junctions', async t => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-')) + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const directory = path.join(tempDir, 'actual-project') + const linkPath = path.join(tempDir, 'linked-project') + fs.mkdirSync(directory) + + try { + fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir') + } 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 + } + + const resolved = await resolveDirectoryForIpc(linkPath) + assert.equal(resolved.resolvedPath, linkPath) + assert.equal(resolved.stat.isDirectory(), true) +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 333e2b136a60..2dd0a68d0d2d 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -22,13 +22,24 @@ const http = require('node:http') const https = require('node:https') const net = require('node:net') const path = require('node:path') -const { fileURLToPath, pathToFileURL } = require('node:url') +const { pathToFileURL } = require('node:url') const { execFileSync, spawn } = require('node:child_process') const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs') const { runBootstrap } = require('./bootstrap-runner.cjs') +const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs') const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') +const { adoptServedDashboardToken } = require('./dashboard-token.cjs') +const { PortPool } = require('./port-pool.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') +const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') +const { buildDesktopBackendEnv } = require('./backend-env.cjs') +const { readDirForIpc } = require('./fs-read-dir.cjs') +const { gitRootForIpc } = require('./git-root.cjs') +const { + OFFICIAL_REPO_HTTPS_URL, + isOfficialSshRemote +} = require('./update-remote.cjs') const { buildPosixCleanupScript, buildWindowsCleanupScript, @@ -38,6 +49,7 @@ const { shouldRemoveAppBundle, uninstallArgsForMode } = require('./desktop-uninstall.cjs') +const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs') const { authModeFromStatus, buildGatewayWsUrl, @@ -58,13 +70,16 @@ const { TEXT_PREVIEW_SOURCE_MAX_BYTES, encryptDesktopSecret: encryptDesktopSecretStrict, resolveReadableFileForIpc, + resolveRequestedPathForIpc, resolveTimeoutMs } = require('./hardening.cjs') let nodePty = null +let nodePtyDir = null try { nodePty = require('node-pty') + nodePtyDir = path.dirname(require.resolve('node-pty/package.json')) } catch { // Packaged builds set `files:` in package.json, which excludes node_modules // from the asar. Workspace dedup also hoists this native dep to the repo @@ -77,10 +92,13 @@ try { const path = require('node:path') const resourcesPath = process.resourcesPath if (resourcesPath) { - nodePty = require(path.join(resourcesPath, 'native-deps', 'node-pty')) + nodePtyDir = path.join(resourcesPath, 'native-deps', 'node-pty') + nodePty = require(nodePtyDir) } } catch { + console.log(`[terminal] failed to load node-pty from path ${nodePtyDir}`) nodePty = null + nodePtyDir = null } } @@ -93,6 +111,10 @@ if (USER_DATA_OVERRIDE) { const PORT_FLOOR = 9120 const PORT_CEILING = 9199 +// In-process port reservations that close the pickPort() TOCTOU window where +// two concurrent backend spawns could be handed the same port. See +// port-pool.cjs for the full rationale. +const portPool = new PortPool(PORT_FLOOR, PORT_CEILING) const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER const IS_PACKAGED = app.isPackaged const IS_MAC = process.platform === 'darwin' @@ -100,6 +122,13 @@ const IS_WINDOWS = process.platform === 'win32' const IS_WSL = isWslEnvironment() const APP_ROOT = app.getAppPath() +function hiddenWindowsChildOptions(options = {}) { + if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) { + return options + } + return { ...options, windowsHide: true } +} + // Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU // compositor flicker — accelerated layers can't be presented cleanly over the // wire, so the window flashes during scroll/streaming/animation. Local @@ -712,7 +741,7 @@ function openExternalUrl(rawUrl) { if (parsed.protocol === 'file:') { let localPath try { - localPath = fileURLToPath(parsed.toString()) + localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open external file' }) } catch { return false } @@ -1099,7 +1128,7 @@ function findSystemPython() { const out = execFileSync( 'reg', ['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'], - { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } + hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) ) // Output format: " (Default) REG_SZ C:\Path\To\Python\" const match = out.match(/REG_SZ\s+(.+?)\s*$/m) @@ -1135,10 +1164,10 @@ function findSystemPython() { if (pyExe) { for (const version of SUPPORTED_VERSIONS) { try { - const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], { + const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] - }) + })) const candidate = out.trim() if (candidate && fileExists(candidate)) return candidate } catch { @@ -1273,11 +1302,11 @@ function resolveUpdateRoot() { function runGit(args, options = {}) { return new Promise((resolve, reject) => { - const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, { + const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({ cwd: options.cwd, env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] - }) + })) let stdout = '' let stderr = '' @@ -1298,6 +1327,11 @@ function runGit(args, options = {}) { const firstLine = text => (text || '').split('\n').find(Boolean) || '' +async function getOriginUrl(updateRoot) { + const origin = await runGit(['remote', 'get-url', 'origin'], { cwd: updateRoot }) + return origin.code === 0 ? origin.stdout.trim() : '' +} + function emitUpdateProgress(payload) { const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() } rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`) @@ -1317,7 +1351,9 @@ async function resolveHealedBranch(updateRoot, branch) { return branch || 'main' } - const probe = await runGit(['ls-remote', '--exit-code', '--heads', 'origin', branch], { cwd: updateRoot }) + const originUrl = await getOriginUrl(updateRoot) + const remote = isOfficialSshRemote(originUrl) ? OFFICIAL_REPO_HTTPS_URL : 'origin' + const probe = await runGit(['ls-remote', '--exit-code', '--heads', remote, branch], { cwd: updateRoot }) if (probe.code !== 2) { return branch } @@ -1345,6 +1381,40 @@ async function checkUpdates() { } branch = await resolveHealedBranch(updateRoot, branch) + const originUrl = await getOriginUrl(updateRoot) + if (isOfficialSshRemote(originUrl)) { + const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) + const [currentSha, target, dirtyStr, currentBranch] = await Promise.all([ + git(['rev-parse', 'HEAD']), + runGit(['ls-remote', OFFICIAL_REPO_HTTPS_URL, `refs/heads/${branch}`], { cwd: updateRoot }), + git(['status', '--porcelain']), + git(['rev-parse', '--abbrev-ref', 'HEAD']) + ]) + const targetSha = firstLine(target.stdout).split(/\s+/)[0] || '' + if (target.code !== 0 || !targetSha) { + return { + supported: true, + branch, + error: 'fetch-failed', + message: firstLine(target.stderr) || 'git ls-remote failed.', + hermesRoot: updateRoot, + fetchedAt: Date.now() + } + } + return { + supported: true, + branch, + currentBranch, + behind: currentSha && currentSha === targetSha ? 0 : 1, + currentSha, + targetSha, + commits: [], + dirty: dirtyStr.length > 0, + hermesRoot: updateRoot, + fetchedAt: Date.now() + } + } + const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot }) if (fetched.code !== 0) { return { @@ -1487,7 +1557,7 @@ function forceKillProcessTree(pid) { if (!IS_WINDOWS) return if (!Number.isInteger(pid) || pid <= 0) return try { - execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) + execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' })) } catch { // Already gone, or no permission — best effort; the unlock wait below is // the real gate. @@ -1673,11 +1743,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) { return new Promise(resolve => { let child try { - child = spawn(command, args, { + child = spawn(command, args, hiddenWindowsChildOptions({ cwd, env: { ...process.env, ...(env || {}) }, stdio: ['ignore', 'pipe', 'pipe'] - }) + })) } catch (err) { resolve({ code: 1, error: err.message }) return @@ -1948,6 +2018,21 @@ function resolveRendererIndex() { return candidates[0] } +// True when `dir` lives inside the packaged app bundle / install tree. +// Packaged Electron's process.cwd() (and npm's INIT_CWD when dev tooling +// leaked into a release build) often resolve here — e.g. win-unpacked on +// Windows — which is exactly where PR #37536 item 16 said we must NOT run. +function isPackagedInstallPath(dir) { + return isPackagedInstallPathUnderRoots(dir, { + isPackaged: IS_PACKAGED, + installRoots: [ + APP_ROOT, + path.dirname(process.execPath), + resolveRemovableAppPath(process.execPath, process.platform, process.env) + ] + }) +} + function resolveHermesCwd() { // In a packaged build, `process.cwd()` resolves to the install root (e.g. // `…/win-unpacked` on Windows or `/Applications/Hermes.app/Contents/...` @@ -1959,7 +2044,7 @@ function resolveHermesCwd() { const candidates = [ readDefaultProjectDir(), process.env.HERMES_DESKTOP_CWD, - process.env.INIT_CWD, + IS_PACKAGED ? null : process.env.INIT_CWD, IS_PACKAGED ? null : process.cwd(), !IS_PACKAGED ? SOURCE_REPO_ROOT : null, app.getPath('home') @@ -1968,12 +2053,37 @@ function resolveHermesCwd() { for (const candidate of candidates) { if (!candidate) continue const resolved = path.resolve(String(candidate)) + + if (isPackagedInstallPath(resolved)) { + continue + } + if (directoryExists(resolved)) return resolved } return app.getPath('home') } +function sanitizeWorkspaceCwd(cwd) { + const trimmed = typeof cwd === 'string' ? cwd.trim() : '' + + if (!trimmed || isPackagedInstallPath(trimmed)) { + return { cwd: resolveHermesCwd(), sanitized: Boolean(trimmed) } + } + + try { + const resolved = path.resolve(trimmed) + + if (directoryExists(resolved)) { + return { cwd: resolved, sanitized: false } + } + } catch { + // Fall through to the resolved default. + } + + return { cwd: resolveHermesCwd(), sanitized: Boolean(trimmed) } +} + // Persisted "Default project directory" — surfaced as a setting in the // renderer (see app/settings/sessions-settings.tsx). Stored as JSON in // userData so it survives self-updates without bleeding into the new @@ -2025,9 +2135,11 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) { label, command: python, args: ['-m', 'hermes_cli.main', ...dashboardArgs], - env: { - PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) - }, + env: buildDesktopBackendEnv({ + hermesHome: HERMES_HOME, + pythonPathEntries: [root], + venvRoot: path.join(root, 'venv') + }), root, bootstrap: Boolean(options.bootstrap), shell: false @@ -2046,9 +2158,11 @@ function createActiveBackend(dashboardArgs) { label: `Hermes at ${ACTIVE_HERMES_ROOT}`, command: fileExists(venvPython) ? venvPython : findSystemPython(), args: ['-m', 'hermes_cli.main', ...dashboardArgs], - env: { - PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) - }, + env: buildDesktopBackendEnv({ + hermesHome: HERMES_HOME, + pythonPathEntries: [ACTIVE_HERMES_ROOT], + venvRoot: VENV_ROOT + }), root: ACTIVE_HERMES_ROOT, bootstrap: true, shell: false @@ -2350,10 +2464,11 @@ function isPortAvailable(port) { } async function pickPort() { - for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) { - if (await isPortAvailable(port)) return port + const port = await portPool.reserve(isPortAvailable) + if (port === null) { + throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`) } - throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`) + return port } function fetchJson(url, token, options = {}) { @@ -2624,7 +2739,7 @@ function fetchHtmlTitleWithCurl(rawUrl) { '--raw', url ] - const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] }) + const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] })) const chunks = [] let bytes = 0 @@ -2779,10 +2894,10 @@ async function resourceBufferFromUrl(rawUrl) { const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8') return { buffer, mimeType } } - if (rawUrl.startsWith('file:')) { - const filePath = fileURLToPath(rawUrl) - const buffer = await fs.promises.readFile(filePath) - return { buffer, mimeType: mimeTypeForPath(filePath) } + if (/^file:/i.test(rawUrl)) { + const { resolvedPath } = await resolveReadableFileForIpc(rawUrl, { purpose: 'Image file' }) + const buffer = await fs.promises.readFile(resolvedPath) + return { buffer, mimeType: mimeTypeForPath(resolvedPath) } } const parsed = new URL(rawUrl) @@ -2860,11 +2975,13 @@ function expandUserPath(filePath) { return value } -function previewFileTarget(rawTarget, baseDir) { +async function previewFileTarget(rawTarget, baseDir) { const raw = String(rawTarget || '').trim() const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd() - const filePath = raw.startsWith('file:') ? fileURLToPath(raw) : path.resolve(base, expandUserPath(raw)) - let resolved = filePath + let resolved = resolveRequestedPathForIpc(/^file:/i.test(raw) ? raw : expandUserPath(raw), { + baseDir: base, + purpose: 'Preview target' + }) if (directoryExists(resolved)) { resolved = path.join(resolved, 'index.html') @@ -2875,6 +2992,8 @@ function previewFileTarget(rawTarget, baseDir) { return null } + ;({ resolvedPath: resolved } = await resolveReadableFileForIpc(resolved, { purpose: 'Preview target' })) + const mimeType = mimeTypeForPath(resolved) const metadata = previewFileMetadata(resolved, mimeType) const isHtml = PREVIEW_HTML_EXTENSIONS.has(ext) @@ -2920,7 +3039,7 @@ function previewUrlTarget(rawTarget) { } } -function normalizePreviewTarget(rawTarget, baseDir) { +async function normalizePreviewTarget(rawTarget, baseDir) { const raw = String(rawTarget || '').trim() if (!raw) { @@ -2932,20 +3051,15 @@ function normalizePreviewTarget(rawTarget, baseDir) { return previewUrlTarget(raw) } - return previewFileTarget(raw, baseDir) + return await previewFileTarget(raw, baseDir) } catch { return null } } -function filePathFromPreviewUrl(rawUrl) { - const filePath = fileURLToPath(String(rawUrl || '')) - - if (!fileExists(filePath)) { - throw new Error('Preview file is not readable') - } - - return filePath +async function filePathFromPreviewUrl(rawUrl) { + const { resolvedPath } = await resolveReadableFileForIpc(String(rawUrl || ''), { purpose: 'Preview file' }) + return resolvedPath } function sendPreviewFileChanged(payload) { @@ -2955,8 +3069,8 @@ function sendPreviewFileChanged(payload) { webContents.send('hermes:preview-file-changed', payload) } -function watchPreviewFile(rawUrl) { - const filePath = filePathFromPreviewUrl(rawUrl) +async function watchPreviewFile(rawUrl) { + const filePath = await filePathFromPreviewUrl(rawUrl) const watchDir = path.dirname(filePath) const targetName = path.basename(filePath) const id = crypto.randomBytes(12).toString('base64url') @@ -3270,14 +3384,18 @@ function setAndPersistZoomLevel(window, zoomLevel) { const next = clampZoomLevel(zoomLevel) window.webContents.setZoomLevel(next) window.webContents - .executeJavaScript(`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`) + .executeJavaScript( + `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}` + ) .catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`)) } function restorePersistedZoomLevel(window) { if (!window || window.isDestroyed()) return window.webContents - .executeJavaScript(`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`) + .executeJavaScript( + `(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()` + ) .then(stored => { if (stored == null || !window || window.isDestroyed()) return const level = clampZoomLevel(Number(stored)) @@ -4136,9 +4254,7 @@ async function requestJsonForProfile(profile, path, method, body) { const conn = await ensureBackend(profile) const url = `${conn.baseUrl}${path}` const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS } - return conn.authMode === 'oauth' - ? fetchJsonViaOauthSession(url, opts) - : fetchJson(url, conn.token, opts) + return conn.authMode === 'oauth' ? fetchJsonViaOauthSession(url, opts) : fetchJson(url, conn.token, opts) } async function probeRemoteAuthMode(rawUrl) { @@ -4212,7 +4328,8 @@ async function testDesktopConnectionConfig(input = {}) { // The block under test: a per-profile entry or the global remote. Coerce has // already normalized the URL and resolved token inheritance for the scope. const block = key ? config.profiles?.[key] || null : config.remote - const wantRemote = block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block) + const wantRemote = + block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block) // ``/api/status`` is public on every gateway (no creds needed), so a // reachability test works for local, token, and oauth modes alike — we only // need a base URL. For a remote config we normalize the URL from the input; @@ -4295,20 +4412,31 @@ async function teardownPrimaryBackendAndWait() { const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null resetHermesConnection() - if (!dying) { + await waitForBackendExit(dying) +} + +async function waitForBackendExit(child, timeoutMs = 5000) { + if (!child) { + return + } + if (child.exitCode !== null || child.signalCode !== null) { return } await new Promise(resolve => { const timer = setTimeout(() => { try { - dying.kill('SIGKILL') + if (IS_WINDOWS && Number.isInteger(child.pid)) { + forceKillProcessTree(child.pid) + } else { + child.kill('SIGKILL') + } } catch { // Already gone. } resolve() - }, 5000) - dying.once('exit', () => { + }, timeoutMs) + child.once('exit', () => { clearTimeout(timer) resolve() }) @@ -4424,18 +4552,33 @@ async function spawnPoolBackend(profile, entry) { // --profile wins over the inherited HERMES_HOME env (see _apply_profile_override // step 3 in hermes_cli/main.py), so the child re-homes to this profile. const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)] - const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) - const hermesCwd = resolveHermesCwd() - const webDist = resolveWebDist() + let backend + let hermesCwd + let webDist + try { + backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) + hermesCwd = resolveHermesCwd() + webDist = resolveWebDist() + } catch (error) { + // These run before the child exists / its exit handler is attached, so a + // throw here would otherwise leak the reservation and slowly exhaust the + // 9120-9199 range across switch cycles in one app session. + portPool.release(port) + throw error + } rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`) - const child = spawn(backend.command, backend.args, { + const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({ cwd: hermesCwd, env: { ...process.env, HERMES_HOME, ...backend.env, + // Pin the gateway's tool/terminal cwd to the same directory we chose for + // the child process. Inherited TERMINAL_CWD (or a stale config bridge) + // can still point at the install dir even when spawn cwd is home. + TERMINAL_CWD: hermesCwd, HERMES_DASHBOARD_SESSION_TOKEN: token, // Marks this dashboard backend as desktop-spawned so it runs the cron // scheduler tick loop (the gateway isn't running under the app). @@ -4444,7 +4587,7 @@ async function spawnPoolBackend(profile, entry) { }, shell: backend.shell, stdio: ['ignore', 'pipe', 'pipe'] - }) + })) entry.process = child entry.port = port entry.token = token @@ -4460,28 +4603,38 @@ async function spawnPoolBackend(profile, entry) { child.once('error', error => { rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`) backendPool.delete(profile) + portPool.release(port) rejectStart?.(error) }) child.once('exit', (code, signal) => { rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`) backendPool.delete(profile) + portPool.release(port) if (!ready) { - rejectStart?.(new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)) + rejectStart?.( + new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`) + ) } }) const baseUrl = `http://127.0.0.1:${port}` await Promise.race([waitForHermes(baseUrl, token), startFailed]) ready = true + const authToken = await adoptServedDashboardToken(baseUrl, token, { + childAlive: () => child.exitCode === null && !child.killed, + label: `Hermes backend for profile "${profile}"`, + rememberLog + }) + entry.token = authToken return { baseUrl, mode: 'local', source: 'local', authMode: 'token', - token, + token: authToken, profile, - wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`, + wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`, logs: hermesLog.slice(-80), ...getWindowState() } @@ -4491,6 +4644,7 @@ function stopPoolBackend(profile) { const entry = backendPool.get(profile) if (!entry) return backendPool.delete(profile) + if (entry.port) portPool.release(entry.port) if (entry.process && !entry.process.killed) { try { entry.process.kill('SIGTERM') @@ -4500,12 +4654,70 @@ function stopPoolBackend(profile) { } } +async function teardownPoolBackendAndWait(profile) { + const entry = backendPool.get(profile) + if (!entry) return + backendPool.delete(profile) + + if (entry.process && !entry.process.killed) { + try { + entry.process.kill('SIGTERM') + } catch { + // Already gone. + } + } + + await waitForBackendExit(entry.process) +} + function stopAllPoolBackends() { for (const profile of [...backendPool.keys()]) { stopPoolBackend(profile) } } +function profileNameFromDeleteRequest(request) { + if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') { + return null + } + + const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/) + if (!match) { + return null + } + + let raw = '' + try { + raw = decodeURIComponent(match[1]) + } catch { + return null + } + + const name = raw.trim() + if (!name) { + return null + } + if (name.toLowerCase() === 'default') { + return 'default' + } + return name.toLowerCase() +} + +async function prepareProfileDeleteRequest(request) { + const profile = profileNameFromDeleteRequest(request) + if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) { + return + } + + if (profile === primaryProfileKey()) { + writeActiveDesktopProfile('default') + await teardownPrimaryBackendAndWait() + return + } + + await teardownPoolBackendAndWait(profile) +} + async function startHermes() { // Latched-failure short-circuit: once bootstrap has failed in this // process, every subsequent startHermes() call re-throws the same error @@ -4518,6 +4730,11 @@ async function startHermes() { } if (connectionPromise) return connectionPromise + // Hoisted so the outer .catch can release a port reserved by pickPort() when + // a throw (e.g. ensureRuntime failing) happens before the child's exit + // handler is attached. Stays null on the remote path (no port picked). + let reservedPort = null + connectionPromise = (async () => { await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8) // Resolve for the desktop's primary profile so a per-profile remote @@ -4547,6 +4764,7 @@ async function startHermes() { await advanceBootProgress('backend.port', 'Finding an open local port', 16) const port = await pickPort() + reservedPort = port const token = crypto.randomBytes(32).toString('base64url') const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)] // Pin the desktop's chosen profile via the global --profile flag. This is @@ -4566,7 +4784,7 @@ async function startHermes() { await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84) rememberLog(`Starting Hermes backend via ${backend.label}`) - hermesProcess = spawn(backend.command, backend.args, { + hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({ cwd: hermesCwd, env: { ...process.env, @@ -4580,6 +4798,7 @@ async function startHermes() { // can't reliably do that, so we set it inline for every spawn. HERMES_HOME, ...backend.env, + TERMINAL_CWD: hermesCwd, HERMES_DASHBOARD_SESSION_TOKEN: token, // Marks this dashboard backend as desktop-spawned so it runs the cron // scheduler tick loop (the gateway isn't running under the app). @@ -4588,7 +4807,7 @@ async function startHermes() { }, shell: backend.shell, stdio: ['ignore', 'pipe', 'pipe'] - }) + })) hermesProcess.stdout.on('data', rememberLog) hermesProcess.stderr.on('data', rememberLog) @@ -4610,6 +4829,7 @@ async function startHermes() { ) hermesProcess = null connectionPromise = null + portPool.release(port) sendBackendExit({ code: null, signal: null, error: error.message }) rejectBackendStart?.(error) }) @@ -4617,6 +4837,7 @@ async function startHermes() { rememberLog(`Hermes backend exited (${signal || code})`) hermesProcess = null connectionPromise = null + portPool.release(port) sendBackendExit({ code, signal }) if (!backendReady) { const message = `Hermes backend exited before it became ready (${signal || code}).` @@ -4641,6 +4862,11 @@ async function startHermes() { await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90) await Promise.race([waitForHermes(baseUrl, token), backendStartFailed]) backendReady = true + const authToken = await adoptServedDashboardToken(baseUrl, token, { + // The exit/error handlers null hermesProcess when the child dies. + childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed, + rememberLog + }) updateBootProgress({ phase: 'backend.ready', message: 'Hermes backend is ready. Finalizing desktop startup', @@ -4654,8 +4880,8 @@ async function startHermes() { mode: 'local', source: 'local', authMode: 'token', - token, - wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`, + token: authToken, + wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`, logs: hermesLog.slice(-80), ...getWindowState() } @@ -4671,12 +4897,101 @@ async function startHermes() { { allowDecrease: true } ) connectionPromise = null + portPool.release(reservedPort) throw error }) return connectionPromise } +// Shared navigation guards + window chrome wiring applied to every window +// (the primary plus any secondary session windows). Factored out of +// createWindow() so secondary windows can't drift from the main window's +// security posture: external links open in the OS browser, in-app navigation +// stays confined to the dev server / packaged file URL, and the preview / +// devtools / zoom / context-menu affordances behave identically everywhere. +function wireCommonWindowHandlers(win) { + installPreviewShortcut(win) + installDevToolsShortcut(win) + installZoomShortcuts(win) + installContextMenu(win) + win.webContents.setWindowOpenHandler(details => { + openExternalUrl(details.url) + + return { action: 'deny' } + }) + win.webContents.on('will-navigate', (event, url) => { + if ((DEV_SERVER && url.startsWith(DEV_SERVER)) || (!DEV_SERVER && url.startsWith('file:'))) { + return + } + + event.preventDefault() + openExternalUrl(url) + }) +} + +// Secondary "session windows" — one extra OS window per chat so a user can +// work with multiple chats side by side. The registry guarantees one window +// per sessionId (re-opening focuses the existing window) and self-cleans on +// close. The primary mainWindow is never tracked here. Pure logic + the URL +// builder live in session-windows.cjs so they stay unit-testable. +const sessionWindows = createSessionWindowRegistry() + +function focusWindow(win) { + if (!win || win.isDestroyed()) return + if (win.isMinimized()) win.restore() + if (!win.isVisible()) win.show() + win.focus() +} + +// Open (or focus) a standalone window for a single chat session. +function createSessionWindow(sessionId) { + return sessionWindows.openOrFocus(sessionId, () => { + const icon = getAppIconPath() + const win = new BrowserWindow({ + width: 480, + height: 800, + minWidth: 420, + minHeight: 620, + title: 'Hermes', + titleBarStyle: 'hidden', + titleBarOverlay: getTitleBarOverlayOptions(), + trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, + vibrancy: IS_MAC ? 'sidebar' : undefined, + icon, + backgroundColor: '#f7f7f7', + webPreferences: { + preload: path.join(__dirname, 'preload.cjs'), + contextIsolation: true, + webviewTag: true, + sandbox: true, + nodeIntegration: false, + devTools: true + } + }) + + if (IS_MAC) { + win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) + } + + win.on('will-enter-full-screen', () => sendWindowStateChanged(true)) + win.on('enter-full-screen', () => sendWindowStateChanged(true)) + win.on('will-leave-full-screen', () => sendWindowStateChanged(false)) + win.on('leave-full-screen', () => sendWindowStateChanged(false)) + + wireCommonWindowHandlers(win) + + win.loadURL( + buildSessionWindowUrl(sessionId, { + devServer: DEV_SERVER, + rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex() + }) + ) + + return win + }) +} + function createWindow() { const icon = getAppIconPath() mainWindow = new BrowserWindow({ @@ -4737,23 +5052,7 @@ function createWindow() { mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false)) mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false)) - installPreviewShortcut(mainWindow) - installDevToolsShortcut(mainWindow) - installZoomShortcuts(mainWindow) - installContextMenu(mainWindow) - mainWindow.webContents.setWindowOpenHandler(details => { - openExternalUrl(details.url) - - return { action: 'deny' } - }) - mainWindow.webContents.on('will-navigate', (event, url) => { - if ((DEV_SERVER && url.startsWith(DEV_SERVER)) || (!DEV_SERVER && url.startsWith('file:'))) { - return - } - - event.preventDefault() - openExternalUrl(url) - }) + wireCommonWindowHandlers(mainWindow) mainWindow.webContents.on('render-process-gone', (_event, details) => { rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`) @@ -4859,13 +5158,22 @@ ipcMain.handle('hermes:backend:touch', async (_event, profile) => { return { ok: true } }) ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile)) +ipcMain.handle('hermes:window:openSession', async (_event, sessionId) => { + if (typeof sessionId !== 'string' || !sessionId.trim()) { + return { ok: false, error: 'invalid-session-id' } + } + + createSessionWindow(sessionId.trim()) + + return { ok: true } +}) ipcMain.handle('hermes:bootstrap:reset', async () => { // Renderer's "Reload and retry" path. Clear the latched failure and // reset connection state so the next startHermes() call restarts the // full backend flow (including a fresh runBootstrap pass). rememberLog('[bootstrap] reset requested by renderer; clearing latched failure') + await teardownPrimaryBackendAndWait() bootstrapFailure = null - connectionPromise = null bootstrapState = { active: false, manifest: null, @@ -5097,17 +5405,19 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { let total = (Number(base.total) || 0) - remoteProfiles.reduce((n, p) => n + (profileTotals[p] || 0), 0) // Swap each remote profile's stale local rows/total for the remote's real ones. - await Promise.all(remoteProfiles.map(async name => { - const list = await remoteSessionList(name, remoteParams).catch(() => null) - if (!list) { - delete profileTotals[name] // dead remote → drop its stale local total too - return - } - const rows = rowsOf(list) - merged.push(...rows) - profileTotals[name] = Number(list.total) || rows.length - total += profileTotals[name] - })) + await Promise.all( + remoteProfiles.map(async name => { + const list = await remoteSessionList(name, remoteParams).catch(() => null) + if (!list) { + delete profileTotals[name] // dead remote → drop its stale local total too + return + } + const rows = rowsOf(list) + merged.push(...rows) + profileTotals[name] = Number(list.total) || rows.length + total += profileTotals[name] + }) + ) const recency = s => s?.[order] ?? s?.started_at ?? 0 merged.sort((a, b) => recency(b) - recency(a)) @@ -5124,6 +5434,8 @@ ipcMain.handle('hermes:api', async (_event, request) => { return rerouted } + await prepareProfileDeleteRequest(request) + const connection = await ensureBackend(request?.profile) const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) const url = `${connection.baseUrl}${request.path}` @@ -5271,9 +5583,12 @@ ipcMain.handle('hermes:openExternal', (_event, url) => { // session spawn (no app restart needed). ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({ dir: readDefaultProjectDir(), - defaultLabel: path.join(app.getPath('home'), 'hermes-projects') + defaultLabel: app.getPath('home'), + resolvedCwd: resolveHermesCwd() })) +ipcMain.handle('hermes:workspace:sanitize', async (_event, cwd) => sanitizeWorkspaceCwd(cwd)) + ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => { const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null @@ -5321,62 +5636,119 @@ ipcMain.handle('hermes:logs:reveal', async () => { ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) })) -// Always-hidden noise (covers non-git projects too — gitignore would catch -// these anyway when present, but we want the same hygiene without one). -const FS_READDIR_HIDDEN = new Set([ - '.git', - '.hg', - '.svn', - '.cache', - '.next', - '.turbo', - '.venv', - '__pycache__', - 'build', - 'dist', - 'node_modules', - 'target', - 'venv' -]) +function isExecutableFile(filePath) { + if (!filePath || !path.isAbsolute(filePath)) { + return false + } -function findGitRoot(start) { - let dir = start + try { + fs.accessSync(filePath, fs.constants.X_OK) + return true + } catch { + return false + } +} + +function posixShellSpec(shellPath) { + const shellName = path.basename(shellPath) + const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i'] + + return { args: interactiveArgs, command: shellPath, name: shellName } +} + +let spawnHelperChecked = false + +// node-pty execs a `spawn-helper` binary on macOS/Linux to launch the shell in a +// fresh session. The prebuilt that ships in node-pty's `prebuilds/` (and the +// staged copy under resources/native-deps) loses its execute bit through npm +// pack / electron-builder file collection, so every nodePty.spawn() dies with +// "posix_spawnp failed". Restore +x once, lazily, before the first spawn. +function ensureSpawnHelperExecutable() { + if (spawnHelperChecked || IS_WINDOWS || !nodePtyDir) { + return + } - for (let i = 0; i < 50; i += 1) { + spawnHelperChecked = true + + const arch = process.arch + const candidates = [ + path.join(nodePtyDir, 'build', 'Release', 'spawn-helper'), + path.join(nodePtyDir, 'prebuilds', `${process.platform}-${arch}`, 'spawn-helper') + ] + + for (const helper of candidates) { try { - if (fs.existsSync(path.join(dir, '.git'))) { - return dir + const mode = fs.statSync(helper).mode + + if ((mode & 0o111) !== 0o111) { + fs.chmodSync(helper, mode | 0o755) } } catch { - return null + // Not present in this layout (e.g. compiled build vs prebuild); skip. } + } +} - const parent = path.dirname(dir) +// Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box; +// prefer it only after PowerShell 7+ (`pwsh`). +function windowsPowerShellPath() { + const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows' + const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') - if (parent === dir) { - return null - } + return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe') +} + +// Map a resolved shell path to its spawn spec, picking interactive flags by +// family: PowerShell drops its logo banner (so the prompt sits flush like the +// POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…) +// gets POSIX interactive-login flags. +function shellSpecFor(shellPath) { + const name = path.basename(shellPath).toLowerCase() - dir = parent + if (name.startsWith('pwsh') || name.startsWith('powershell')) { + return { args: ['-NoLogo'], command: shellPath, name } } - return null + if (name.startsWith('cmd')) { + return { args: [], command: shellPath, name } + } + + return posixShellSpec(shellPath) +} + +// Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell +// 5.1, then comspec/cmd.exe as the universal fallback. +function windowsShellSpec() { + const command = + findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe' + + return shellSpecFor(command) } +// Resolve the interactive shell for the embedded terminal: an explicit user +// override wins, otherwise auto-detect the best one installed for the platform. function terminalShellCommand() { + // HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare + // name on PATH); $SHELL is honored on POSIX, where it's the user's canonical + // choice, but ignored on Windows, where it's usually a stray MSYS/Git path + // node-pty can't spawn natively. + const override = (process.env.HERMES_DESKTOP_SHELL || (IS_WINDOWS ? '' : process.env.SHELL) || '').trim() + + if (override) { + const resolved = isExecutableFile(override) ? override : findOnPath(override) + + if (resolved) { + return shellSpecFor(resolved) + } + } + if (IS_WINDOWS) { - return { args: [], command: process.env.COMSPEC || 'cmd.exe' } + return windowsShellSpec() } - const configuredShell = process.env.SHELL || '' - const shellPath = - (path.isAbsolute(configuredShell) && fs.existsSync(configuredShell) && configuredShell) || - ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => fs.existsSync(candidate)) || - '/bin/sh' - const shellName = path.basename(shellPath) - const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i'] + const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate)) - return { args: interactiveArgs, command: shellPath, name: shellName } + return posixShellSpec(shellPath || '/bin/sh') } function safeTerminalCwd(cwd) { @@ -5416,6 +5788,11 @@ function terminalShellEnv() { env.TERM_PROGRAM = 'Hermes' env.TERM_PROGRAM_VERSION = app.getVersion() + // Let a hermes/--tui launched in this pane know it's embedded in the desktop + // GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP, + // which marks the agent *backend* and gates cron/gateway behavior. + env.HERMES_DESKTOP_TERMINAL = '1' + return env } @@ -5441,52 +5818,17 @@ function disposeTerminalSession(id) { return true } -ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => { - const resolved = path.resolve(String(dirPath || '')) - - if (!resolved) { - return { entries: [], error: 'invalid-path' } - } - - try { - const dirents = await fs.promises.readdir(resolved, { withFileTypes: true }) - - const entries = dirents - .filter(d => { - if (FS_READDIR_HIDDEN.has(d.name)) { - return false - } +ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath)) - return true - }) - .map(d => ({ name: d.name, path: path.join(resolved, d.name), isDirectory: d.isDirectory() })) - .sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name)) - - return { entries } - } catch (error) { - return { entries: [], error: error?.code || 'read-error' } - } -}) - -ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => { - const input = String(startPath || '') - const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input) - - try { - const stat = await fs.promises.stat(resolved) - const start = stat.isDirectory() ? resolved : path.dirname(resolved) - - return findGitRoot(start) - } catch { - return findGitRoot(resolved) - } -}) +ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath)) ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { if (!nodePty) { throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.') } + ensureSpawnHelperExecutable() + const id = crypto.randomUUID() const { args, command, name } = terminalShellCommand() const cwd = safeTerminalCwd(payload?.cwd) @@ -5666,11 +6008,11 @@ async function getUninstallSummary() { resolve(value) } try { - const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], { + const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({ cwd: agentRoot, env: { ...process.env, HERMES_HOME, NO_COLOR: '1' }, stdio: ['ignore', 'pipe', 'ignore'] - }) + })) child.stdout.on('data', chunk => { stdout += chunk.toString() }) @@ -5809,6 +6151,117 @@ ipcMain.handle('hermes:uninstall:run', async (_event, payload) => { return runDesktopUninstall(String(mode || '')) }) +// Download a VS Code Marketplace extension and return the raw color-theme JSON +// it contributes. No theme code is executed — we only read JSON from the .vsix. +ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketplaceThemes(String(id || ''))) + +// Search the Marketplace for color-theme extensions (empty query = top installs). +ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20)) + +// --------------------------------------------------------------------------- +// hermes:// deep links (e.g. hermes://blueprint/morning-brief?time=08:00). +// A docs/dashboard "Send to App" button opens this URL; we route it into the +// running app's chat composer. Three delivery paths: macOS 'open-url', +// Win/Linux running-app 'second-instance' (argv), Win/Linux cold-start argv. +// --------------------------------------------------------------------------- +const HERMES_PROTOCOL = 'hermes' +let _pendingDeepLink = null +let _rendererReadyForDeepLink = false + +function _extractDeepLink(argv) { + if (!Array.isArray(argv)) return null + return argv.find((a) => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null +} + +function handleDeepLink(url) { + if (!url || typeof url !== 'string') return + let parsed + try { + parsed = new URL(url) + } catch { + rememberLog(`[deeplink] ignoring malformed url: ${url}`) + return + } + // hermes://blueprint/?slot=val -> host="blueprint", path="/" + const kind = parsed.hostname || '' + const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, '')) + const params = {} + parsed.searchParams.forEach((v, k) => { + params[k] = v + }) + const payload = { kind, name, params } + + if (!_rendererReadyForDeepLink || !mainWindow || mainWindow.isDestroyed()) { + _pendingDeepLink = payload + return + } + try { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.focus() + mainWindow.webContents.send('hermes:deep-link', payload) + rememberLog(`[deeplink] delivered ${kind}/${name}`) + } catch (err) { + rememberLog(`[deeplink] delivery failed: ${err.message}`) + } +} + +// Renderer calls this (via IPC) once it has mounted its deep-link listener, so +// a link that arrived during boot/install is flushed exactly once. +ipcMain.handle('hermes:deep-link-ready', () => { + _rendererReadyForDeepLink = true + if (_pendingDeepLink) { + const queued = _pendingDeepLink + _pendingDeepLink = null + handleDeepLink( + `${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` + + (Object.keys(queued.params).length + ? '?' + new URLSearchParams(queued.params).toString() + : ''), + ) + } + return { ok: true } +}) + +function registerDeepLinkProtocol() { + try { + if (process.defaultApp && process.argv.length >= 2) { + // Dev: register with the electron exec path + entry script so the OS can + // relaunch us with the URL. + app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [ + path.resolve(process.argv[1]), + ]) + } else { + app.setAsDefaultProtocolClient(HERMES_PROTOCOL) + } + } catch (err) { + rememberLog(`[deeplink] protocol registration failed: ${err.message}`) + } +} + +// Single-instance lock: deep links on a running app (Win/Linux) arrive as a +// second-instance argv. Without the lock a second `hermes://` launch spawns a +// whole new app instead of routing into the running one. +const _gotSingleInstanceLock = app.requestSingleInstanceLock() +if (!_gotSingleInstanceLock) { + app.quit() +} else { + app.on('second-instance', (_event, argv) => { + const url = _extractDeepLink(argv) + if (url) handleDeepLink(url) + else if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.focus() + } + }) +} + +// macOS delivers deep links via 'open-url' — register early (can fire before +// whenReady; handleDeepLink queues until the renderer is ready). +app.on('open-url', (event, url) => { + event.preventDefault() + handleDeepLink(url) +}) + app.whenReady().then(() => { if (IS_MAC) { @@ -5818,13 +6271,25 @@ app.whenReady().then(() => { } installMediaPermissions() registerMediaProtocol() + registerDeepLinkProtocol() ensureWslWindowsFonts() configureSpellChecker() registerPowerResumeListeners() createWindow() + // Win/Linux cold start: the launching hermes:// URL is in our own argv. + const _coldStartLink = _extractDeepLink(process.argv) + if (_coldStartLink) handleDeepLink(_coldStartLink) + app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow() + // Recreate the primary window if it's gone. Guard on mainWindow directly + // (not just total window count) so a dock click still restores the main + // window when only secondary session windows remain open. + if (!mainWindow || mainWindow.isDestroyed()) { + createWindow() + } else { + focusWindow(mainWindow) + } }) }) diff --git a/apps/desktop/electron/port-pool.cjs b/apps/desktop/electron/port-pool.cjs new file mode 100644 index 000000000000..351310908148 --- /dev/null +++ b/apps/desktop/electron/port-pool.cjs @@ -0,0 +1,73 @@ +'use strict' + +/** + * In-process port reservation pool for the desktop backend launcher. + * + * pickPort() probes a localhost port with a throwaway server and closes it + * before the real bind happens in a separate Python child. Between that probe + * and the child's bind there is a TOCTOU window: a second concurrent spawn + * (the primary backend racing a pool backend) can be handed the SAME port, and + * one then dies with EADDRINUSE ("address already in use" -> "Object has been + * destroyed" boot loop). Reserving the chosen port in THIS process until the + * child exits closes that window. + * + * The OS bind remains the source of truth; this only deconflicts racers inside + * this process — it can't stop a foreign squatter, which the probe + the + * EADDRINUSE self-heal still cover. + * + * The pool is dependency-injected (the availability probe is passed in) and + * free of Electron/Node socket I/O, so it is unit-tested without real sockets + * (see port-pool.test.cjs). + */ +class PortPool { + /** + * @param {number} floor inclusive lowest port to hand out + * @param {number} ceiling inclusive highest port to hand out + */ + constructor(floor, ceiling) { + this.floor = floor + this.ceiling = ceiling + this._reserved = new Set() + } + + /** @returns {boolean} whether `port` is currently reserved in-process. */ + has(port) { + return this._reserved.has(port) + } + + /** Release a previously reserved port. No-op if it was not reserved. */ + release(port) { + this._reserved.delete(port) + } + + /** Drop all reservations. */ + clear() { + this._reserved.clear() + } + + /** @returns {number} count of currently reserved ports. */ + get size() { + return this._reserved.size + } + + /** + * Reserve and return the lowest port in [floor, ceiling] that is neither + * already reserved in-process nor rejected by `isAvailable(port)`, or null + * if every port is taken. `isAvailable` may be sync (boolean) or async + * (Promise); it is awaited either way. + * + * @param {(port: number) => boolean | Promise} isAvailable + * @returns {Promise} + */ + async reserve(isAvailable) { + for (let port = this.floor; port <= this.ceiling; port += 1) { + if (this._reserved.has(port)) continue + if (!(await isAvailable(port))) continue + this._reserved.add(port) + return port + } + return null + } +} + +module.exports = { PortPool } diff --git a/apps/desktop/electron/port-pool.test.cjs b/apps/desktop/electron/port-pool.test.cjs new file mode 100644 index 000000000000..f2600ce7d5f9 --- /dev/null +++ b/apps/desktop/electron/port-pool.test.cjs @@ -0,0 +1,77 @@ +/** + * Tests for electron/port-pool.cjs. + * + * Run with: node --test electron/port-pool.test.cjs + * + * PortPool is the in-process reservation that closes the pickPort() TOCTOU + * window. These cover selection order, skipping reserved/unavailable ports, + * release/reuse, exhaustion, and async probes — without real sockets. + */ + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { PortPool } = require('./port-pool.cjs') + +const allFree = () => true + +test('reserve returns the lowest free port and reserves it', async () => { + const pool = new PortPool(9120, 9199) + const port = await pool.reserve(allFree) + assert.equal(port, 9120) + assert.ok(pool.has(9120)) + assert.equal(pool.size, 1) +}) + +test('reserve skips ports already reserved in-process', async () => { + const pool = new PortPool(9120, 9199) + const first = await pool.reserve(allFree) + const second = await pool.reserve(allFree) + assert.equal(first, 9120) + assert.equal(second, 9121) +}) + +test('reserve skips ports the probe rejects', async () => { + const pool = new PortPool(9120, 9199) + const busy = new Set([9120, 9121]) + const port = await pool.reserve(p => !busy.has(p)) + assert.equal(port, 9122) +}) + +test('reserve returns null when every port is taken', async () => { + const pool = new PortPool(9120, 9121) + await pool.reserve(allFree) + await pool.reserve(allFree) + assert.equal(await pool.reserve(allFree), null) +}) + +test('release frees a reserved port for reuse', async () => { + const pool = new PortPool(9120, 9120) + assert.equal(await pool.reserve(allFree), 9120) + assert.equal(await pool.reserve(allFree), null) // exhausted + pool.release(9120) + assert.ok(!pool.has(9120)) + assert.equal(await pool.reserve(allFree), 9120) // reusable +}) + +test('release is a no-op for an unreserved port', () => { + const pool = new PortPool(9120, 9199) + pool.release(9120) + assert.equal(pool.size, 0) +}) + +test('reserve awaits an async probe', async () => { + const pool = new PortPool(9120, 9199) + const busy = new Set([9120]) + const port = await pool.reserve(p => Promise.resolve(!busy.has(p))) + assert.equal(port, 9121) +}) + +test('clear drops all reservations', async () => { + const pool = new PortPool(9120, 9199) + await pool.reserve(allFree) + await pool.reserve(allFree) + assert.equal(pool.size, 2) + pool.clear() + assert.equal(pool.size, 0) +}) diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index cf094e751c3a..9880d4bcf585 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -5,6 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'), touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile), getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), + openSessionWindow: sessionId => ipcRenderer.invoke('hermes:window:openSession', sessionId), getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'), getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile), saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), @@ -41,6 +42,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)), openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url), + sanitizeWorkspaceCwd: cwd => ipcRenderer.invoke('hermes:workspace:sanitize', cwd), settings: { getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'), setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir), @@ -78,6 +80,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', { 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) @@ -132,5 +140,9 @@ contextBridge.exposeInMainWorld('hermesDesktop', { ipcRenderer.on('hermes:updates:progress', listener) return () => ipcRenderer.removeListener('hermes:updates:progress', listener) } + }, + themes: { + fetchMarketplace: id => ipcRenderer.invoke('hermes:vscode-theme:fetch', id), + searchMarketplace: query => ipcRenderer.invoke('hermes:vscode-theme:search', query) } }) diff --git a/apps/desktop/electron/session-windows.cjs b/apps/desktop/electron/session-windows.cjs new file mode 100644 index 000000000000..8775feb1bcea --- /dev/null +++ b/apps/desktop/electron/session-windows.cjs @@ -0,0 +1,86 @@ +// 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/*.cjs splits testable logic out of the main.cjs monolith). + +const { pathToFileURL } = require('node:url') + +// Build the renderer URL for a secondary window. The renderer uses a +// HashRouter, so the session route lives after the '#'. The `?win=secondary` +// flag MUST sit in the query string BEFORE the '#': anything after the '#' is +// treated as the route by HashRouter and would break routeSessionId(). The +// renderer reads the flag from window.location.search to suppress the install / +// onboarding overlays and the global session sidebar. +function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath } = {}) { + const route = `#/${encodeURIComponent(sessionId)}` + + if (devServer) { + const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer + + return `${base}/?win=secondary${route}` + } + + return `${pathToFileURL(rendererIndexPath).toString()}?win=secondary${route}` +} + +// A small registry keyed by sessionId that guarantees one window per chat: +// opening a session that already has a live window focuses it instead of +// spawning a duplicate, and a window removes itself from the registry when it +// closes. The actual BrowserWindow construction is injected (the `factory`) so +// this module stays free of Electron and is unit-testable. +function createSessionWindowRegistry() { + const windows = new Map() + + function openOrFocus(sessionId, factory) { + const key = typeof sessionId === 'string' ? sessionId.trim() : '' + + if (!key) { + return null + } + + const existing = windows.get(key) + + if (existing && !existing.isDestroyed()) { + // Focus-or-create: never duplicate a window for the same chat. + if (typeof existing.isMinimized === 'function' && existing.isMinimized()) { + existing.restore?.() + } + + if (typeof existing.isVisible === 'function' && !existing.isVisible()) { + existing.show?.() + } + + existing.focus?.() + + return existing + } + + const win = factory(key) + + if (!win) { + return null + } + + windows.set(key, win) + + // Self-cleanup on close so the registry never holds a destroyed window. + win.on?.('closed', () => { + if (windows.get(key) === win) { + windows.delete(key) + } + }) + + return win + } + + return { + openOrFocus, + get: key => windows.get(key), + has: key => windows.has(key), + get size() { + return windows.size + } + } +} + +module.exports = { buildSessionWindowUrl, createSessionWindowRegistry } diff --git a/apps/desktop/electron/session-windows.test.cjs b/apps/desktop/electron/session-windows.test.cjs new file mode 100644 index 000000000000..3453971eb517 --- /dev/null +++ b/apps/desktop/electron/session-windows.test.cjs @@ -0,0 +1,165 @@ +const assert = require('node:assert/strict') +const test = require('node:test') + +const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs') + +// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a +// test fire the 'closed' event, mirroring the slice of the Electron API the +// registry actually touches. +function makeFakeWindow() { + const listeners = {} + const calls = { focus: 0, show: 0, restore: 0 } + let destroyed = false + let minimized = false + let visible = true + + return { + on(event, handler) { + listeners[event] = handler + + return this + }, + emit(event) { + listeners[event]?.() + }, + isDestroyed: () => destroyed, + destroy() { + destroyed = true + }, + isMinimized: () => minimized, + setMinimized(value) { + minimized = value + }, + isVisible: () => visible, + setVisible(value) { + visible = value + }, + restore() { + calls.restore += 1 + minimized = false + }, + show() { + calls.show += 1 + visible = true + }, + focus() { + calls.focus += 1 + }, + calls + } +} + +test('buildSessionWindowUrl puts the secondary flag before the hash route (dev server)', () => { + const url = buildSessionWindowUrl('abc123', { devServer: 'http://localhost:5173' }) + + assert.equal(url, 'http://localhost:5173/?win=secondary#/abc123') +}) + +test('buildSessionWindowUrl avoids a double slash when the dev server has a trailing slash', () => { + const url = buildSessionWindowUrl('abc123', { devServer: 'http://localhost:5173/' }) + + assert.equal(url, 'http://localhost:5173/?win=secondary#/abc123') +}) + +test('buildSessionWindowUrl encodes the session id in the hash route', () => { + const url = buildSessionWindowUrl('a b/c', { devServer: 'http://localhost:5173' }) + + // The query flag must precede the '#' or HashRouter would swallow it as the + // route; the id is URL-encoded so slashes/spaces survive routeSessionId(). + assert.equal(url, 'http://localhost:5173/?win=secondary#/a%20b%2Fc') + assert.ok(url.indexOf('?win=secondary') < url.indexOf('#')) +}) + +test('buildSessionWindowUrl builds a packaged file URL with the flag before the hash', () => { + const url = buildSessionWindowUrl('abc', { rendererIndexPath: '/opt/app/index.html' }) + + assert.match(url, /^file:\/\/.*index\.html\?win=secondary#\/abc$/) +}) + +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 + + return win + } + + const first = registry.openOrFocus('s1', factory) + const second = registry.openOrFocus('s1', factory) + + assert.equal(built, 1, 'factory runs once for the same session') + assert.equal(first, second) + assert.equal(registry.size, 1) + assert.equal(win.calls.focus, 1, 'second open focuses the existing window') +}) + +test('registry restores + shows a minimized/hidden window on re-open', () => { + const registry = createSessionWindowRegistry() + const win = makeFakeWindow() + registry.openOrFocus('s1', () => win) + + win.setMinimized(true) + win.setVisible(false) + registry.openOrFocus('s1', () => win) + + assert.equal(win.calls.restore, 1) + assert.equal(win.calls.show, 1) + assert.equal(win.calls.focus, 1) +}) + +test('registry drops the entry when the window closes', () => { + const registry = createSessionWindowRegistry() + const win = makeFakeWindow() + registry.openOrFocus('s1', () => win) + assert.equal(registry.size, 1) + + win.emit('closed') + + assert.equal(registry.size, 0) + assert.equal(registry.has('s1'), false) +}) + +test('registry rebuilds a fresh window after the previous one was destroyed', () => { + const registry = createSessionWindowRegistry() + const first = makeFakeWindow() + registry.openOrFocus('s1', () => first) + first.destroy() + + let built = 0 + const second = makeFakeWindow() + const result = registry.openOrFocus('s1', () => { + built += 1 + + return second + }) + + assert.equal(built, 1, 'a destroyed window is replaced, not focused') + assert.equal(result, second) +}) + +test('registry ignores empty / non-string session ids', () => { + const registry = createSessionWindowRegistry() + let built = 0 + const factory = () => { + built += 1 + + return makeFakeWindow() + } + + assert.equal(registry.openOrFocus('', factory), null) + assert.equal(registry.openOrFocus(' ', factory), null) + assert.equal(registry.openOrFocus(null, factory), null) + assert.equal(registry.openOrFocus(42, factory), null) + assert.equal(built, 0) + assert.equal(registry.size, 0) +}) + +test('registry trims the session id before keying', () => { + const registry = createSessionWindowRegistry() + const win = makeFakeWindow() + registry.openOrFocus(' s1 ', () => win) + + assert.equal(registry.has('s1'), true) +}) diff --git a/apps/desktop/electron/update-remote.cjs b/apps/desktop/electron/update-remote.cjs new file mode 100644 index 000000000000..3cb432d1b1e9 --- /dev/null +++ b/apps/desktop/electron/update-remote.cjs @@ -0,0 +1,56 @@ +/** + * Pure helpers for choosing a remote URL during passive update checks. + * + * A public install can end up with `origin=git@github.com:NousResearch/hermes-agent.git`. + * If the user's GitHub SSH key is FIDO2/passkey-backed, a background `git fetch + * origin` triggers an unexplained hardware-touch prompt. For passive checks + * against the official repo we substitute the public HTTPS `ls-remote` path, + * which needs no auth and cannot prompt. Active update/apply flows are left + * unchanged. + * + * Extracted from main.cjs so the security-critical remote detection is unit + * testable without booting Electron (main.cjs requires('electron') at load). + */ + +const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git' +const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent' + +// Normalize common GitHub remote URL forms to `host/owner/repo` (lowercased, +// no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo +// compare equal. +function canonicalGitHubRemote(url) { + if (!url) return '' + let value = String(url).trim() + if (value.startsWith('git@github.com:')) { + value = `github.com/${value.slice('git@github.com:'.length)}` + } else if (value.startsWith('ssh://git@github.com/')) { + value = `github.com/${value.slice('ssh://git@github.com/'.length)}` + } else { + try { + const parsed = new URL(value) + if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}` + } catch { + // Leave non-URL forms unchanged. + } + } + value = value.trim().replace(/\/+$/, '') + if (value.endsWith('.git')) value = value.slice(0, -4) + return value.toLowerCase() +} + +function isSshRemote(url) { + const value = String(url || '').trim().toLowerCase() + return value.startsWith('git@') || value.startsWith('ssh://') +} + +function isOfficialSshRemote(url) { + return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL +} + +module.exports = { + OFFICIAL_REPO_HTTPS_URL, + OFFICIAL_REPO_CANONICAL, + canonicalGitHubRemote, + isSshRemote, + isOfficialSshRemote +} diff --git a/apps/desktop/electron/update-remote.test.cjs b/apps/desktop/electron/update-remote.test.cjs new file mode 100644 index 000000000000..0dfba970138b --- /dev/null +++ b/apps/desktop/electron/update-remote.test.cjs @@ -0,0 +1,78 @@ +/** + * Tests for electron/update-remote.cjs — the remote-detection helpers that + * keep passive update checks off the SSH origin for official installs. + * + * Run with: node --test electron/update-remote.test.cjs + * (Wired into npm test:desktop:platforms in package.json.) + * + * Why this matters: a public install can carry + * origin=git@github.com:NousResearch/hermes-agent.git. A background + * `git fetch origin` then authenticates over SSH and, with a FIDO2/passkey + * key, triggers an unexplained hardware-touch prompt. isOfficialSshRemote + * must reliably recognize the official SSH remote (in every URL form, + * case-insensitively) so the caller can swap in the anonymous HTTPS path — + * while NOT misclassifying forks, other hosts, or the HTTPS remote (which + * never prompts and should keep the normal fetch path). + */ + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { + OFFICIAL_REPO_HTTPS_URL, + OFFICIAL_REPO_CANONICAL, + canonicalGitHubRemote, + isSshRemote, + isOfficialSshRemote +} = require('./update-remote.cjs') + +test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => { + assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL) + assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent'), OFFICIAL_REPO_CANONICAL) + assert.equal(canonicalGitHubRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL) + assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL) + // Case-insensitive: an uppercased owner still canonicalizes to the same repo. + assert.equal(canonicalGitHubRemote('git@github.com:nousresearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL) + // Trailing slashes are stripped. + assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent/'), OFFICIAL_REPO_CANONICAL) +}) + +test('canonicalGitHubRemote is empty for falsy input', () => { + assert.equal(canonicalGitHubRemote(''), '') + assert.equal(canonicalGitHubRemote(null), '') + assert.equal(canonicalGitHubRemote(undefined), '') +}) + +test('isSshRemote detects scp-like and ssh:// forms only', () => { + assert.equal(isSshRemote('git@github.com:NousResearch/hermes-agent.git'), true) + assert.equal(isSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true) + assert.equal(isSshRemote('https://github.com/NousResearch/hermes-agent.git'), false) + assert.equal(isSshRemote(''), false) + assert.equal(isSshRemote(null), false) +}) + +test('isOfficialSshRemote is true only for the official repo over SSH', () => { + assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent.git'), true) + assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent'), true) + assert.equal(isOfficialSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true) + // Case-insensitive owner/repo match. + assert.equal(isOfficialSshRemote('git@github.com:nousresearch/hermes-agent.git'), true) +}) + +test('isOfficialSshRemote does NOT match forks, other hosts, or HTTPS', () => { + // A fork over SSH belongs to the user — fetching it is their own remote, + // not the official upstream, so the SSH-avoidance swap must not apply. + assert.equal(isOfficialSshRemote('git@github.com:someuser/hermes-agent.git'), false) + // Same repo name on a different host is not the official repo. + assert.equal(isOfficialSshRemote('git@gitlab.com:NousResearch/hermes-agent.git'), false) + // HTTPS to the official repo never prompts for SSH/FIDO2, so it keeps the + // normal fetch path — must not be flagged as an official SSH remote. + assert.equal(isOfficialSshRemote('https://github.com/NousResearch/hermes-agent.git'), false) + assert.equal(isOfficialSshRemote(''), false) + assert.equal(isOfficialSshRemote(null), false) +}) + +test('OFFICIAL_REPO_HTTPS_URL canonicalizes to OFFICIAL_REPO_CANONICAL', () => { + // Invariant: the URL we substitute in must be the same repo we detect. + assert.equal(canonicalGitHubRemote(OFFICIAL_REPO_HTTPS_URL), OFFICIAL_REPO_CANONICAL) +}) diff --git a/apps/desktop/electron/vscode-marketplace.cjs b/apps/desktop/electron/vscode-marketplace.cjs new file mode 100644 index 000000000000..829182a1f0f0 --- /dev/null +++ b/apps/desktop/electron/vscode-marketplace.cjs @@ -0,0 +1,331 @@ +'use strict' + +/** + * VS Code Marketplace color-theme fetcher (main process). + * + * Resolves an extension's latest version via the (undocumented but stable) + * gallery ExtensionQuery API, downloads the `.vsix` (a zip), and extracts the + * color-theme JSON files it contributes. No theme code is ever executed — we + * only read `package.json` + the referenced `*.json` theme files out of the + * archive and hand their text back to the renderer to convert. + * + * Dependency-free on purpose: a `.vsix` is a plain zip, so we parse the central + * directory and inflate just the entries we need with `zlib`. Avoids pulling a + * zip library into the desktop bundle for a feature this small. + */ + +const https = require('node:https') +const zlib = require('node:zlib') + +const GALLERY_QUERY_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery' +const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage' +const MAX_VSIX_BYTES = 40 * 1024 * 1024 // 40 MB — themes are tiny; this is paranoia. +const MAX_REDIRECTS = 5 +const REQUEST_TIMEOUT_MS = 20_000 + +const ID_RE = /^[\w-]+\.[\w-]+$/ + +/** Minimal HTTPS helper with redirect-following, timeout, and a size cap. */ +function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS) { + return new Promise((resolve, reject) => { + const req = https.request(url, { method, headers }, res => { + const status = res.statusCode ?? 0 + + if (status >= 300 && status < 400 && res.headers.location) { + if (redirectsLeft <= 0) { + res.resume() + reject(new Error('Too many redirects.')) + + return + } + + const next = new URL(res.headers.location, url).toString() + res.resume() + // Redirects to the CDN are plain GETs (drop the POST body). + resolve(request(next, { method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes }, redirectsLeft - 1)) + + return + } + + if (status < 200 || status >= 300) { + res.resume() + reject(new Error(`Request failed (${status}) for ${url}`)) + + return + } + + const chunks = [] + let total = 0 + + res.on('data', chunk => { + total += chunk.length + + if (total > maxBytes) { + req.destroy() + reject(new Error('Response exceeded the size limit.')) + + return + } + + chunks.push(chunk) + }) + res.on('end', () => resolve(Buffer.concat(chunks))) + }) + + req.on('error', reject) + req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('Request timed out.'))) + + if (body) { + req.write(body) + } + + req.end() + }) +} + +/** Resolve `{ displayName, vsixUrl }` for the latest version of `id`. */ +async function resolveExtension(id) { + const json = await queryGallery({ + // FilterType 7 = ExtensionName (the full publisher.extension id). + filters: [{ criteria: [{ filterType: 7, value: id }], pageNumber: 1, pageSize: 1 }], + // Flags: IncludeFiles | IncludeVersionProperties | IncludeAssetUri | + // IncludeCategoryAndTags | IncludeLatestVersionOnly = 914. + flags: 914 + }) + const extension = json?.results?.[0]?.extensions?.[0] + + if (!extension) { + throw new Error(`Extension "${id}" was not found on the Marketplace.`) + } + + const version = extension.versions?.[0] + + if (!version) { + throw new Error(`Extension "${id}" has no published versions.`) + } + + const asset = (version.files ?? []).find(file => file.assetType === VSIX_ASSET_TYPE) + const vsixUrl = asset?.source + + if (!vsixUrl) { + throw new Error(`Could not find a downloadable package for "${id}".`) + } + + return { displayName: extension.displayName || id, vsixUrl } +} + +/** POST an ExtensionQuery payload and return the parsed gallery response. */ +async function queryGallery(payload, { maxBytes = 4 * 1024 * 1024 } = {}) { + const body = JSON.stringify(payload) + const raw = await request(GALLERY_QUERY_URL, { + method: 'POST', + headers: { + Accept: 'application/json;api-version=3.0-preview.1', + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + 'User-Agent': 'Hermes-Desktop' + }, + body, + maxBytes + }) + + return JSON.parse(raw.toString('utf8')) +} + +/** + * Search the Marketplace for color-theme extensions. With an empty query this + * returns the most-installed themes; with a query it's a full-text search + * scoped to the Themes category. Returns lightweight cards (no download). + */ +/** + * The "Themes" category also contains file-icon and product-icon themes (the + * gallery has no color-only category). We can't see an extension's actual + * contributions without downloading it, so filter the obvious icon packs out by + * tag + name/description. Color themes that also ship icons are rare; worst case + * a user installs them by exact id from settings. + */ +function looksLikeIconTheme(extension) { + const tags = (extension.tags ?? []).map(tag => String(tag).toLowerCase()) + + if (tags.includes('icon-theme') || tags.includes('product-icon-theme')) { + return true + } + + const text = `${extension.displayName ?? ''} ${extension.shortDescription ?? ''}`.toLowerCase() + + return /\b(icon theme|file icons?|product icons?|icon pack|fileicons)\b/.test(text) +} + +async function searchMarketplaceThemes(query, limit = 20) { + const text = String(query || '').trim() + const pageSize = Math.min(Math.max(Number(limit) || 20, 1), 50) + + // FilterType: 8=Target, 5=Category, 10=SearchText, 12=ExcludeWithFlags. + const criteria = [ + { filterType: 8, value: 'Microsoft.VisualStudio.Code' }, + { filterType: 5, value: 'Themes' }, + { filterType: 12, value: '4096' } // Exclude unpublished (Unpublished = 0x1000). + ] + + if (text) { + criteria.push({ filterType: 10, value: text }) + } + + const json = await queryGallery({ + // Over-fetch so the icon-theme filter below still leaves a full page. + filters: [{ criteria, pageNumber: 1, pageSize: Math.min(pageSize * 2, 50), sortBy: 4, sortOrder: 0 }], + // IncludeStatistics (0x100) | IncludeLatestVersionOnly (0x200) | IncludeCategoryAndTags (0x4). + flags: 772 + }) + + const extensions = json?.results?.[0]?.extensions ?? [] + + return extensions + .filter(extension => !looksLikeIconTheme(extension)) + .slice(0, pageSize) + .map(extension => { + const publisherName = extension.publisher?.publisherName ?? '' + const installStat = (extension.statistics ?? []).find(stat => stat.statisticName === 'install') + + return { + extensionId: `${publisherName}.${extension.extensionName}`, + displayName: extension.displayName || extension.extensionName, + publisher: extension.publisher?.displayName || publisherName, + description: extension.shortDescription || '', + installs: Math.round(installStat?.value ?? 0) + } + }) +} + +// ─── Minimal zip reader ───────────────────────────────────────────────────── + +function findEndOfCentralDirectory(buf) { + // EOCD signature 0x06054b50, scanning back from the end (comment is rare). + for (let i = buf.length - 22; i >= 0; i--) { + if (buf.readUInt32LE(i) === 0x06054b50) { + return i + } + } + + throw new Error('Not a valid zip archive (no end-of-central-directory).') +} + +/** Parse the central directory into a name → record map. */ +function readCentralDirectory(buf) { + const eocd = findEndOfCentralDirectory(buf) + const count = buf.readUInt16LE(eocd + 10) + let offset = buf.readUInt32LE(eocd + 16) + const records = new Map() + + for (let i = 0; i < count; i++) { + if (buf.readUInt32LE(offset) !== 0x02014b50) { + break + } + + const method = buf.readUInt16LE(offset + 10) + const compressedSize = buf.readUInt32LE(offset + 20) + const nameLen = buf.readUInt16LE(offset + 28) + const extraLen = buf.readUInt16LE(offset + 30) + const commentLen = buf.readUInt16LE(offset + 32) + const localOffset = buf.readUInt32LE(offset + 42) + const name = buf.toString('utf8', offset + 46, offset + 46 + nameLen) + + records.set(name, { method, compressedSize, localOffset }) + offset += 46 + nameLen + extraLen + commentLen + } + + return records +} + +/** Inflate a single entry to a string. */ +function extractEntry(buf, record) { + // The local header's name/extra lengths can differ from the central record, + // so re-read them here to locate the compressed payload. + if (buf.readUInt32LE(record.localOffset) !== 0x04034b50) { + throw new Error('Corrupt zip: bad local file header.') + } + + const nameLen = buf.readUInt16LE(record.localOffset + 26) + const extraLen = buf.readUInt16LE(record.localOffset + 28) + const dataStart = record.localOffset + 30 + nameLen + extraLen + const data = buf.subarray(dataStart, dataStart + record.compressedSize) + + // 0 = stored, 8 = deflate. Theme files are one or the other. + return record.method === 0 ? data.toString('utf8') : zlib.inflateRawSync(data).toString('utf8') +} + +/** Normalize a package.json theme path to its zip entry name. */ +function themeEntryName(themePath) { + const clean = String(themePath).replace(/^\.\//, '').replace(/^\//, '') + + return `extension/${clean}` +} + +/** Extract every contributed color theme from a `.vsix` buffer. */ +function extractThemes(vsixBuffer) { + const records = readCentralDirectory(vsixBuffer) + const pkgRecord = records.get('extension/package.json') + + if (!pkgRecord) { + throw new Error('Package manifest missing from the extension.') + } + + const pkg = JSON.parse(extractEntry(vsixBuffer, pkgRecord)) + const contributed = pkg?.contributes?.themes + + if (!Array.isArray(contributed) || contributed.length === 0) { + return [] + } + + const themes = [] + + for (const entry of contributed) { + if (!entry?.path) { + continue + } + + const record = records.get(themeEntryName(entry.path)) + + if (!record) { + continue + } + + try { + themes.push({ + label: entry.label || entry.id || pkg.displayName || pkg.name || 'VS Code Theme', + uiTheme: entry.uiTheme, + contents: extractEntry(vsixBuffer, record) + }) + } catch { + // Skip an entry we can't inflate rather than failing the whole install. + } + } + + return themes +} + +/** + * Public entry: resolve, download, and extract color themes for `id` + * (`publisher.extension`). Returns `{ extensionId, displayName, themes }`. + */ +async function fetchMarketplaceThemes(id) { + const trimmed = String(id || '').trim() + + if (!ID_RE.test(trimmed)) { + throw new Error('Expected a Marketplace id like "publisher.extension".') + } + + const { displayName, vsixUrl } = await resolveExtension(trimmed) + const vsix = await request(vsixUrl, { headers: { 'User-Agent': 'Hermes-Desktop' } }) + const themes = extractThemes(vsix) + + return { extensionId: trimmed, displayName, themes } +} + +module.exports = { + fetchMarketplaceThemes, + searchMarketplaceThemes, + extractThemes, + readCentralDirectory, + __testing: { themeEntryName, looksLikeIconTheme } +} diff --git a/apps/desktop/electron/vscode-marketplace.test.cjs b/apps/desktop/electron/vscode-marketplace.test.cjs new file mode 100644 index 000000000000..45169044bfa3 --- /dev/null +++ b/apps/desktop/electron/vscode-marketplace.test.cjs @@ -0,0 +1,113 @@ +'use strict' + +const assert = require('node:assert') +const test = require('node:test') + +const { __testing, extractThemes, readCentralDirectory } = require('./vscode-marketplace.cjs') + +// Build a minimal zip with stored (uncompressed) entries so the test controls +// the bytes exactly — exercises the central-directory reader + theme extraction +// without a deflate dependency. +function makeZip(entries) { + const locals = [] + const centrals = [] + let offset = 0 + + for (const { name, data } of entries) { + const nameBuf = Buffer.from(name, 'utf8') + const body = Buffer.from(data, 'utf8') + + const local = Buffer.alloc(30 + nameBuf.length) + local.writeUInt32LE(0x04034b50, 0) + local.writeUInt16LE(0, 8) // method: stored + local.writeUInt32LE(body.length, 18) // compressed size + local.writeUInt32LE(body.length, 22) // uncompressed size + local.writeUInt16LE(nameBuf.length, 26) + nameBuf.copy(local, 30) + + locals.push(local, body) + + const central = Buffer.alloc(46 + nameBuf.length) + central.writeUInt32LE(0x02014b50, 0) + central.writeUInt16LE(0, 10) // method: stored + central.writeUInt32LE(body.length, 20) + central.writeUInt32LE(body.length, 24) + central.writeUInt16LE(nameBuf.length, 28) + central.writeUInt32LE(offset, 42) // local header offset + nameBuf.copy(central, 46) + + centrals.push(central) + offset += local.length + body.length + } + + const centralStart = offset + const centralBuf = Buffer.concat(centrals) + + const eocd = Buffer.alloc(22) + eocd.writeUInt32LE(0x06054b50, 0) + eocd.writeUInt16LE(entries.length, 8) + eocd.writeUInt16LE(entries.length, 10) + eocd.writeUInt32LE(centralBuf.length, 12) + eocd.writeUInt32LE(centralStart, 16) + + return Buffer.concat([...locals, centralBuf, eocd]) +} + +test('readCentralDirectory finds every entry', () => { + const zip = makeZip([ + { name: 'extension/package.json', data: '{}' }, + { name: 'extension/themes/x.json', data: '{}' } + ]) + + const records = readCentralDirectory(zip) + assert.ok(records.has('extension/package.json')) + assert.ok(records.has('extension/themes/x.json')) +}) + +test('extractThemes reads contributed color themes (resolving ./ paths)', () => { + const pkg = JSON.stringify({ + name: 'theme-dracula', + displayName: 'Dracula', + contributes: { + themes: [{ label: 'Dracula', uiTheme: 'vs-dark', path: './themes/dracula.json' }] + } + }) + const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } }) + + const zip = makeZip([ + { name: 'extension/package.json', data: pkg }, + { name: 'extension/themes/dracula.json', data: themeJson } + ]) + + const themes = extractThemes(zip) + assert.strictEqual(themes.length, 1) + assert.strictEqual(themes[0].label, 'Dracula') + assert.strictEqual(themes[0].uiTheme, 'vs-dark') + assert.match(themes[0].contents, /editor\.background/) +}) + +test('extractThemes returns empty when the extension contributes no themes', () => { + const zip = makeZip([{ name: 'extension/package.json', data: JSON.stringify({ name: 'x', contributes: {} }) }]) + assert.deepStrictEqual(extractThemes(zip), []) +}) + +test('extractThemes throws when the manifest is missing', () => { + const zip = makeZip([{ name: 'extension/other.txt', data: 'hi' }]) + assert.throws(() => extractThemes(zip), /manifest missing/i) +}) + +test('looksLikeIconTheme filters icon/product-icon packs out of theme search', () => { + const { looksLikeIconTheme } = __testing + + // Tagged contribution points are the strongest signal. + assert.strictEqual(looksLikeIconTheme({ tags: ['theme', 'icon-theme'] }), true) + assert.strictEqual(looksLikeIconTheme({ tags: ['product-icon-theme'] }), true) + + // Name/description fallback for packs that don't tag themselves. + assert.strictEqual(looksLikeIconTheme({ displayName: 'Material Icon Theme' }), true) + assert.strictEqual(looksLikeIconTheme({ shortDescription: 'A pack of file icons.' }), true) + + // Real color themes survive. + assert.strictEqual(looksLikeIconTheme({ displayName: 'Dracula Official', tags: ['theme', 'color-theme'] }), false) + assert.strictEqual(looksLikeIconTheme({ displayName: 'One Dark Pro' }), false) +}) diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs new file mode 100644 index 000000000000..92989c978bb8 --- /dev/null +++ b/apps/desktop/electron/windows-child-process.test.cjs @@ -0,0 +1,54 @@ +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const ELECTRON_DIR = __dirname + +function readElectronFile(name) { + return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n') +} + +function requireHiddenChildOptions(source, needle) { + const index = source.indexOf(needle) + assert.notEqual(index, -1, `missing call site: ${needle}`) + const snippet = source.slice(index, index + 700) + assert.match( + snippet, + /hiddenWindowsChildOptions\(/, + `expected ${needle} to wrap child-process options with hiddenWindowsChildOptions` + ) +} + +test('desktop background child processes opt into hidden Windows consoles', () => { + const source = readElectronFile('main.cjs') + + assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) + + requireHiddenChildOptions(source, "execFileSync(\n 'reg'") + requireHiddenChildOptions(source, 'execFileSync(pyExe') + requireHiddenChildOptions(source, 'spawn(resolveGitBinary()') + requireHiddenChildOptions(source, "execFileSync('taskkill'") + requireHiddenChildOptions(source, 'spawn(command, args') + requireHiddenChildOptions(source, "spawn('curl'") + requireHiddenChildOptions(source, 'spawn(backend.command, backend.args') + requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args') + requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']") +}) + +test('intentional or interactive desktop child processes stay documented', () => { + const source = readElectronFile('main.cjs') + + assert.match(source, /windowsHide: false/) + assert.match(source, /nodePty\.spawn\(command, args/) + assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/) +}) + +test('bootstrap PowerShell runner hides Windows console children', () => { + const source = readElectronFile('bootstrap-runner.cjs') + + assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) + requireHiddenChildOptions(source, 'spawn(ps, fullArgs') +}) diff --git a/apps/desktop/electron/workspace-cwd.cjs b/apps/desktop/electron/workspace-cwd.cjs new file mode 100644 index 000000000000..2955975b0b06 --- /dev/null +++ b/apps/desktop/electron/workspace-cwd.cjs @@ -0,0 +1,38 @@ +const path = require('node:path') + +/** True when `dir` lives inside a packaged app bundle / install tree. */ +function isPackagedInstallPath(dir, { installRoots, isPackaged }) { + if (!isPackaged || !dir) { + return false + } + + let resolved + + try { + resolved = path.resolve(String(dir)) + } catch { + return false + } + + const roots = new Set( + (installRoots ?? []) + .filter(Boolean) + .map(candidate => path.resolve(String(candidate))) + ) + + for (const root of roots) { + if (resolved === root) { + return true + } + + const rel = path.relative(root, resolved) + + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { + return true + } + } + + return false +} + +module.exports = { isPackagedInstallPath } diff --git a/apps/desktop/electron/workspace-cwd.test.cjs b/apps/desktop/electron/workspace-cwd.test.cjs new file mode 100644 index 000000000000..760fb9d08ef6 --- /dev/null +++ b/apps/desktop/electron/workspace-cwd.test.cjs @@ -0,0 +1,45 @@ +/** + * Tests for electron/workspace-cwd.cjs. + * + * Run with: node --test electron/workspace-cwd.test.cjs + */ + +const test = require('node:test') +const assert = require('node:assert/strict') +const path = require('node:path') + +const { isPackagedInstallPath } = require('./workspace-cwd.cjs') + +const installRoot = path.resolve('/opt/Hermes') + +test('isPackagedInstallPath returns false when not packaged', () => { + assert.equal( + isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }), + false + ) +}) + +test('isPackagedInstallPath flags the install root itself', () => { + assert.equal( + isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }), + true + ) +}) + +test('isPackagedInstallPath flags paths nested under the install root', () => { + const nested = path.join(installRoot, 'resources', 'app.asar') + + assert.equal( + isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }), + true + ) +}) + +test('isPackagedInstallPath ignores paths outside the install root', () => { + const homeProject = path.resolve('/home/user/projects/demo') + + assert.equal( + isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }), + false + ) +}) diff --git a/apps/desktop/eslint.config.mjs b/apps/desktop/eslint.config.mjs index 7650c747dbe1..069a0056bbb9 100644 --- a/apps/desktop/eslint.config.mjs +++ b/apps/desktop/eslint.config.mjs @@ -3,7 +3,6 @@ import typescriptEslint from '@typescript-eslint/eslint-plugin' import typescriptParser from '@typescript-eslint/parser' import perfectionist from 'eslint-plugin-perfectionist' import reactPlugin from 'eslint-plugin-react' -import reactCompiler from 'eslint-plugin-react-compiler' import hooksPlugin from 'eslint-plugin-react-hooks' import unusedImports from 'eslint-plugin-unused-imports' import globals from 'globals' @@ -47,7 +46,6 @@ export default [ 'custom-rules': customRules, perfectionist, react: reactPlugin, - 'react-compiler': reactCompiler, 'react-hooks': hooksPlugin, 'unused-imports': unusedImports }, @@ -98,7 +96,6 @@ export default [ 'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }], 'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }], 'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }], - 'react-compiler/react-compiler': 'warn', 'react-hooks/exhaustive-deps': 'warn', 'react-hooks/rules-of-hooks': 'error', 'unused-imports/no-unused-imports': 'error' diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 22f7a9dd4b6e..d78416589f62 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,7 +18,8 @@ "profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "start": "npm run build && electron .", - "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/assert-dist-built.cjs", + "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild", + "postbuild": "node scripts/assert-dist-built.cjs", "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder", "pack": "npm run build && npm run builder -- --dir", "dist": "npm run build && npm run builder", @@ -35,8 +36,8 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs", - "type-check": "tsc -b", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/port-pool.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs", + "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'", @@ -72,6 +73,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "dnd-core": "^14.0.1", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.2", "ignore": "^7.0.5", @@ -83,6 +85,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.5", "react-arborist": "^3.5.0", + "react-dnd-html5-backend": "^14.0.3", "react-dom": "^19.2.5", "react-router-dom": "^7.17.0", "react-shiki": "^0.9.3", @@ -103,20 +106,19 @@ "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.2", "@types/hast": "^3.0.4", - "@types/node": "^24.12.2", + "@types/node": "^24.13.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.59.1", "@typescript-eslint/parser": "^8.59.1", "@vitejs/plugin-react": "^6.0.1", - "concurrently": "^9.2.1", + "concurrently": "^10.0.3", "cross-env": "^10.1.0", "electron": "^40.9.3", "electron-builder": "^26.8.1", "eslint": "^9.39.4", "eslint-plugin-perfectionist": "^5.9.0", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-compiler": "^19.1.0-rc.2", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-unused-imports": "^4.4.1", "globals": "^16.5.0", @@ -133,6 +135,14 @@ "appId": "com.nousresearch.hermes", "productName": "Hermes", "executableName": "Hermes", + "protocols": [ + { + "name": "Hermes Protocol", + "schemes": [ + "hermes" + ] + } + ], "artifactName": "Hermes-${version}-${os}-${arch}.${ext}", "icon": "assets/icon", "directories": { diff --git a/apps/desktop/public/apple-touch-icon.png b/apps/desktop/public/apple-touch-icon.png index ed92319bb0f1..1910487428d2 100644 Binary files a/apps/desktop/public/apple-touch-icon.png and b/apps/desktop/public/apple-touch-icon.png differ diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index fd1569d7caf4..8e98dd9d40d3 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -18,7 +18,7 @@ import { } from '@/components/ui/pagination' import { TextTab, TextTabMeta } from '@/components/ui/text-tab' import { Tip } from '@/components/ui/tooltip' -import { getSessionMessages, listSessions } from '@/hermes' +import { getSessionMessages, listAllProfileSessions } from '@/hermes' import { type Translations, useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link' @@ -388,8 +388,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, . setRefreshing(true) try { - const sessions = (await listSessions(30, 1)).sessions - const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id))) + const sessions = (await listAllProfileSessions(30, 1)).sessions + const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile))) const nextArtifacts: ArtifactRecord[] = [] results.forEach((result, index) => { diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx index 0c154a8a4b15..6229c9da8bd7 100644 --- a/apps/desktop/src/app/chat/composer/attachments.tsx +++ b/apps/desktop/src/app/chat/composer/attachments.tsx @@ -3,8 +3,9 @@ import { useStore } from '@nanostores/react' import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' -import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons' +import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, Terminal } from '@/lib/icons' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' +import { cn } from '@/lib/utils' import type { ComposerAttachment } from '@/store/composer' import { notifyError } from '@/store/notifications' import { setCurrentSessionPreviewTarget } from '@/store/preview' @@ -31,7 +32,9 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme const c = t.composer const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind] const cwd = useStore($currentCwd) - const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' + const isUploading = attachment.uploadState === 'uploading' + const hasUploadError = attachment.uploadState === 'error' + const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined async function openPreview() { @@ -59,7 +62,15 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme throw new Error(c.couldNotPreview(attachment.label)) } - setCurrentSessionPreviewTarget(preview, 'manual', target) + // We already hold the image bytes (the card thumbnail) — render those + // directly so a screenshot/clipboard image previews even when its only + // on-disk copy is a transient path the renderer can't re-read. + const withBytes = + attachment.kind === 'image' && attachment.previewUrl + ? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const } + : preview + + setCurrentSessionPreviewTarget(withBytes, 'manual', target) } catch (error) { notifyError(error, c.previewUnavailable) } @@ -69,30 +80,51 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme