Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44ef0150ab | ||
|
|
f019a9c491 | ||
|
|
46ea0a184d | ||
|
|
49f1b9e4b4 | ||
|
|
c77c470d27 | ||
|
|
e114b31eda | ||
|
|
fd1ec8033d | ||
|
|
28f1590b7a | ||
|
|
ada04573a9 | ||
|
|
a23728dfcc | ||
|
|
9b43ab8de5 | ||
|
|
188e52db91 | ||
|
|
5005b79bc3 | ||
|
|
d0ea4caf7f | ||
|
|
6a2909fe5a | ||
|
|
9272e4019a | ||
|
|
feb50eee70 | ||
|
|
e0a999aa8a | ||
|
|
55a76ec669 | ||
|
|
d9f7e7ac81 | ||
|
|
e618cbee44 | ||
|
|
2f0ee66467 | ||
|
|
cbc1d901ba | ||
|
|
84eb5f1f89 | ||
|
|
e5472da584 | ||
|
|
3ab783a7bb | ||
|
|
06aa140fa1 | ||
|
|
dd28f2ac9c | ||
|
|
9bdf01852a | ||
|
|
a92cbcac45 | ||
|
|
e67ab2e042 | ||
|
|
b6da66c5be | ||
|
|
dfba3f3e51 | ||
|
|
b28dd3417d | ||
|
|
918aef267b | ||
|
|
205ed71ba0 | ||
|
|
d6b0c23f87 | ||
|
|
7d0246ab57 | ||
|
|
ae5b2de2fa | ||
|
|
1e047677a5 | ||
|
|
6ed9a2de8f | ||
|
|
54343bcade | ||
|
|
b6945ce772 | ||
|
|
591c329f15 | ||
|
|
afec339e96 | ||
|
|
d704df2d6e | ||
|
|
39933f758b | ||
|
|
21e172b94a | ||
|
|
46e513ef51 | ||
|
|
1daecfa4b0 | ||
|
|
4a626ed187 | ||
|
|
4df280d511 | ||
|
|
a51a7b9b92 | ||
|
|
115671ae6b | ||
|
|
01eaba7061 | ||
|
|
7982560845 | ||
|
|
4b06c98fe4 | ||
|
|
ab2472e692 | ||
|
|
7466182179 | ||
|
|
ea4fe15631 | ||
|
|
bb1c8b6f1a | ||
|
|
082025abcd | ||
|
|
30a7a94120 | ||
|
|
123b945731 | ||
|
|
cbc82511ea | ||
|
|
a13db76eaa | ||
|
|
33807e2b14 | ||
|
|
a429a2a0bf | ||
|
|
d963ad56c1 | ||
|
|
3be9fb7317 | ||
|
|
63e824831c | ||
|
|
dd5e97bd7f |
@@ -1,5 +1,5 @@
|
||||
watch_file pyproject.toml uv.lock
|
||||
watch_file ui-tui/package-lock.json ui-tui/package.json
|
||||
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
|
||||
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix
|
||||
|
||||
use flake
|
||||
|
||||
@@ -4,10 +4,10 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'ui-tui/package-lock.json'
|
||||
- 'package-lock.json'
|
||||
- 'package.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'apps/dashboard/package-lock.json'
|
||||
- 'apps/dashboard/package.json'
|
||||
- 'apps/desktop/package.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
@@ -27,9 +27,9 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
# ── Auto-fix on main ───────────────────────────────────────────────
|
||||
# Fires when a push to main touches package.json or package-lock.json
|
||||
# in ui-tui/ or apps/dashboard/. Runs fix-lockfiles and pushes the hash
|
||||
# update commit directly to main so Nix builds never stay broken.
|
||||
# Fires when a push to main touches package.json or package-lock.json.
|
||||
# Runs fix-lockfiles and pushes the hash update commit directly to main
|
||||
# so Nix builds never stay broken.
|
||||
#
|
||||
# Safety invariants:
|
||||
# 1. The fix commit only touches nix/*.nix files, which are NOT in
|
||||
@@ -109,8 +109,8 @@ jobs:
|
||||
# our computed hashes are stale. Abort and let the next triggered
|
||||
# run recompute from the correct package-lock state.
|
||||
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
|
||||
'ui-tui/package-lock.json' 'ui-tui/package.json' \
|
||||
'apps/dashboard/package-lock.json' 'apps/dashboard/package.json' || true)"
|
||||
'package-lock.json' 'package.json' \
|
||||
'ui-tui/package.json' 'apps/desktop/package.json' || true)"
|
||||
if [ -n "$pkg_changed" ]; then
|
||||
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
|
||||
exit 0
|
||||
|
||||
@@ -37,23 +37,16 @@ jobs:
|
||||
|
||||
- name: Check flake
|
||||
id: flake
|
||||
if: runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
run: nix flake check --print-build-logs
|
||||
|
||||
- name: Build package
|
||||
id: build
|
||||
if: runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
run: nix build --print-build-logs
|
||||
|
||||
# When the real Nix build fails, run a targeted diagnostic to see if
|
||||
# When the flake check fails, run a targeted diagnostic to see if
|
||||
# the failure is specifically a stale npm lockfile hash in one of the
|
||||
# known npm subpackages (tui / web). This avoids surfacing a generic
|
||||
# "build failed" message when the fix is a single known command.
|
||||
- name: Diagnose npm lockfile hashes
|
||||
id: hash_check
|
||||
if: (steps.flake.outcome == 'failure' || steps.build.outcome == 'failure') && runner.os == 'Linux'
|
||||
if: steps.flake.outcome == 'failure' && runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
env:
|
||||
LINK_SHA: ${{ steps.sha.outputs.full }}
|
||||
@@ -88,30 +81,25 @@ jobs:
|
||||
- Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`)
|
||||
- Or locally: `nix run .#fix-lockfiles` and commit the diff
|
||||
|
||||
# Clear the sticky comment when either the build passed outright (no
|
||||
# Clear the sticky comment when either the flake check passed outright (no
|
||||
# hash check needed) or the hash check explicitly returned stale=false
|
||||
# (build failed for a non-hash reason).
|
||||
# (check failed for a non-hash reason).
|
||||
- name: Clear sticky PR comment (resolved)
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
runner.os == 'Linux' &&
|
||||
(steps.hash_check.outputs.stale == 'false' ||
|
||||
(steps.flake.outcome == 'success' && steps.build.outcome == 'success'))
|
||||
steps.flake.outcome == 'success')
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
delete: true
|
||||
|
||||
- name: Final fail if build or flake failed
|
||||
if: steps.flake.outcome == 'failure' || steps.build.outcome == 'failure'
|
||||
- name: Final fail if flake check failed
|
||||
if: steps.flake.outcome == 'failure'
|
||||
run: |
|
||||
if [ "${{ steps.hash_check.outputs.stale }}" == "true" ]; then
|
||||
echo "::error::Nix build failed due to stale npm lockfile hash. Run: nix run .#fix-lockfiles"
|
||||
else
|
||||
echo "::error::Nix build/flake check failed. See logs above."
|
||||
echo "::error::Nix flake check failed. See logs above."
|
||||
fi
|
||||
exit 1
|
||||
|
||||
- name: Evaluate flake (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: nix flake show --json > /dev/null
|
||||
|
||||
@@ -28,7 +28,6 @@ on:
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'ui-tui/package-lock.json'
|
||||
- 'website/package.json'
|
||||
- 'website/package-lock.json'
|
||||
- '.github/workflows/osv-scanner.yml'
|
||||
@@ -39,7 +38,6 @@ on:
|
||||
- 'pyproject.toml'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'ui-tui/package-lock.json'
|
||||
- 'website/package-lock.json'
|
||||
schedule:
|
||||
# Weekly scan against main — catches CVEs published after merge for
|
||||
@@ -62,6 +60,6 @@ jobs:
|
||||
# the three sources of truth and skip vendored / test / worktree dirs.
|
||||
scan-args: |-
|
||||
--lockfile=uv.lock
|
||||
--lockfile=ui-tui/package-lock.json
|
||||
--lockfile=package-lock.json
|
||||
--lockfile=website/package-lock.json
|
||||
fail-on-vuln: false
|
||||
|
||||
+19
-4
@@ -113,8 +113,8 @@ WORKDIR /opt/hermes
|
||||
# ui-tui/package.json. Copying the tree up front lets npm resolve the
|
||||
# workspace to real content instead of stopping at a bare package.json.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY web/package.json web/package-lock.json web/
|
||||
COPY ui-tui/package.json ui-tui/package-lock.json ui-tui/
|
||||
COPY web/package.json web/
|
||||
COPY ui-tui/package.json ui-tui/
|
||||
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
||||
|
||||
# `npm_config_install_links=false` forces npm to install `file:` deps as
|
||||
@@ -131,8 +131,6 @@ ENV npm_config_install_links=false
|
||||
|
||||
RUN npm install --prefer-offline --no-audit && \
|
||||
npx playwright install --with-deps chromium --only-shell && \
|
||||
(cd web && npm install --prefer-offline --no-audit) && \
|
||||
(cd ui-tui && npm install --prefer-offline --no-audit) && \
|
||||
npm cache clean --force
|
||||
|
||||
# ---------- Layer-cached Python dependency install ----------
|
||||
@@ -245,6 +243,23 @@ COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-r
|
||||
|
||||
# ---------- Runtime ----------
|
||||
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
|
||||
# Point the TUI launcher at the prebuilt bundle baked at build time (Layer 8:
|
||||
# `ui-tui && npm run build`). This makes _make_tui_argv take the prebuilt-bundle
|
||||
# fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`) and skip the
|
||||
# _tui_need_npm_install / runtime `npm install` branch entirely — exactly the
|
||||
# nix/packaged-release path the launcher was designed for.
|
||||
#
|
||||
# Why this is required (not just an optimization): the root package-lock.json
|
||||
# describes the WHOLE monorepo workspace set (root + web + ui-tui + apps/*),
|
||||
# but the image only installs root/web/ui-tui (apps/* — the desktop app — is
|
||||
# never `npm install`ed here). So the actualized node_modules permanently
|
||||
# disagrees with the canonical lock, _tui_need_npm_install() returns True on
|
||||
# every launch, and the runtime `npm install` it triggers (a) can never
|
||||
# converge against the partial monorepo and (b) races itself across concurrent
|
||||
# embedded-chat (/api/pty) connections → ENOTEMPTY → the chat tab dies with a
|
||||
# 502 / "[session ended]". Pointing at the prebuilt bundle sidesteps the whole
|
||||
# check. (A separate launcher hardening is tracked independently.)
|
||||
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
|
||||
ENV HERMES_HOME=/opt/data
|
||||
|
||||
# `docker exec` privilege-drop shim. When operators run
|
||||
|
||||
@@ -1621,6 +1621,47 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
)
|
||||
|
||||
|
||||
def _refresh_nous_recommended_model(
|
||||
*, vision: bool, stale_model: Optional[str]
|
||||
) -> Optional[str]:
|
||||
"""Re-fetch the Nous Portal's recommended model after a stale-model 404.
|
||||
|
||||
Long-lived processes (gateway, watchers) cache the Portal's
|
||||
``recommended-models`` payload for 10 minutes and, in practice, can pin a
|
||||
model for the whole process lifetime. When that model is later dropped from
|
||||
the Nous → OpenRouter catalog, every auxiliary call 404s with
|
||||
"model does not exist". This forces a fresh Portal fetch and returns a
|
||||
model name to retry with:
|
||||
|
||||
* the Portal's current recommendation for the task, if it differs from
|
||||
the model that just failed; otherwise
|
||||
* ``_NOUS_MODEL`` (google/gemini-3-flash-preview), the known-good default,
|
||||
if it too differs from the failed model.
|
||||
|
||||
Returns ``None`` when no usable alternative is available (e.g. the Portal
|
||||
still recommends the exact model that just 404'd and the default also
|
||||
matches it) — callers should then let the original error propagate.
|
||||
"""
|
||||
stale = (stale_model or "").strip().lower()
|
||||
fresh: Optional[str] = None
|
||||
try:
|
||||
from hermes_cli.models import get_nous_recommended_aux_model
|
||||
|
||||
fresh = get_nous_recommended_aux_model(vision=vision, force_refresh=True)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Nous recommended-model refresh failed (%s); using default %s",
|
||||
exc, _NOUS_MODEL,
|
||||
)
|
||||
if fresh and fresh.strip().lower() != stale:
|
||||
return fresh
|
||||
# Portal recommendation unchanged or unavailable — fall back to the
|
||||
# hardcoded known-good default, but only if it's actually different.
|
||||
if _NOUS_MODEL.strip().lower() != stale:
|
||||
return _NOUS_MODEL
|
||||
return None
|
||||
|
||||
|
||||
def _read_main_model() -> str:
|
||||
"""Read the user's configured main model from config.yaml.
|
||||
|
||||
@@ -2451,6 +2492,46 @@ def _is_unsupported_temperature_error(exc: Exception) -> bool:
|
||||
return _is_unsupported_parameter_error(exc, "temperature")
|
||||
|
||||
|
||||
def _is_model_not_found_error(exc: Exception) -> bool:
|
||||
"""Detect "the requested model doesn't exist" errors (404 / invalid model).
|
||||
|
||||
This fires when a resolved model name is no longer served by the endpoint
|
||||
— most commonly when a long-lived process pinned a Portal-recommended model
|
||||
that has since been dropped from the Nous → OpenRouter catalog. The Nous
|
||||
proxy returns 404 with a body like::
|
||||
|
||||
Model 'gpt-5.4-mini' not found. The requested model does not exist
|
||||
in our configuration or OpenRouter catalog.
|
||||
|
||||
Distinct from :func:`_is_payment_error` (which also matches some 404s for
|
||||
free-tier/credit language) — this one keys on "does not exist / not found /
|
||||
not a valid model" phrasing, and explicitly excludes the billing keywords
|
||||
that the payment path already owns so the two predicates don't overlap.
|
||||
"""
|
||||
status = getattr(exc, "status_code", None)
|
||||
err_lower = str(exc).lower()
|
||||
# Billing/quota 404s belong to _is_payment_error — don't claim them here.
|
||||
if any(kw in err_lower for kw in (
|
||||
"credits", "insufficient funds", "billing", "out of funds",
|
||||
"balance_depleted", "no usable credits", "free tier", "free-tier",
|
||||
"not available on the free tier",
|
||||
)):
|
||||
return False
|
||||
if status not in {404, 400, None}:
|
||||
return False
|
||||
return any(kw in err_lower for kw in (
|
||||
"model does not exist",
|
||||
"does not exist in our configuration",
|
||||
"openrouter catalog",
|
||||
"is not a valid model",
|
||||
"no such model",
|
||||
"model not found",
|
||||
"the model `", # OpenAI-style: "The model `X` does not exist"
|
||||
"model_not_found",
|
||||
"unknown model",
|
||||
))
|
||||
|
||||
|
||||
def _evict_cached_clients(provider: str) -> None:
|
||||
"""Drop cached auxiliary clients for a provider so fresh creds are used."""
|
||||
normalized = _normalize_aux_provider(provider)
|
||||
@@ -5027,6 +5108,32 @@ def call_llm(
|
||||
raise
|
||||
first_err = retry_err
|
||||
|
||||
# ── Stale-model self-heal (Nous Portal recommendation drift) ───
|
||||
# A long-lived process can pin a Portal-recommended model that has
|
||||
# since been dropped from the Nous → OpenRouter catalog, so every
|
||||
# auxiliary call 404s with "model does not exist". Force a fresh
|
||||
# Portal fetch and retry once with the current recommendation (or the
|
||||
# known-good default). Only applies to Nous-routed calls.
|
||||
_heal_is_nous = (
|
||||
resolved_provider == "nous"
|
||||
or base_url_host_matches(_base_info, "inference-api.nousresearch.com")
|
||||
)
|
||||
if _is_model_not_found_error(first_err) and _heal_is_nous:
|
||||
healed_model = _refresh_nous_recommended_model(
|
||||
vision=(task == "vision"), stale_model=kwargs.get("model"))
|
||||
if healed_model and healed_model != kwargs.get("model"):
|
||||
logger.warning(
|
||||
"Auxiliary %s: model %r no longer in Nous catalog; "
|
||||
"retrying with refreshed recommendation %r",
|
||||
task or "call", kwargs.get("model"), healed_model,
|
||||
)
|
||||
kwargs["model"] = healed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
client.chat.completions.create(**kwargs), task)
|
||||
except Exception as retry_err:
|
||||
first_err = retry_err
|
||||
|
||||
# ── Nous auth refresh parity with main agent ──────────────────
|
||||
client_is_nous = (
|
||||
resolved_provider == "nous"
|
||||
@@ -5464,6 +5571,31 @@ async def async_call_llm(
|
||||
raise
|
||||
first_err = retry_err
|
||||
|
||||
# ── Stale-model self-heal (Nous Portal recommendation drift) ───
|
||||
# See the sync call_llm() path for the rationale: a long-lived process
|
||||
# can pin a Portal-recommended model that has since been dropped from
|
||||
# the Nous → OpenRouter catalog, 404'ing every auxiliary call. Force a
|
||||
# fresh Portal fetch and retry once with the current recommendation.
|
||||
_heal_is_nous = (
|
||||
resolved_provider == "nous"
|
||||
or base_url_host_matches(_client_base, "inference-api.nousresearch.com")
|
||||
)
|
||||
if _is_model_not_found_error(first_err) and _heal_is_nous:
|
||||
healed_model = _refresh_nous_recommended_model(
|
||||
vision=(task == "vision"), stale_model=kwargs.get("model"))
|
||||
if healed_model and healed_model != kwargs.get("model"):
|
||||
logger.warning(
|
||||
"Auxiliary %s (async): model %r no longer in Nous catalog; "
|
||||
"retrying with refreshed recommendation %r",
|
||||
task or "call", kwargs.get("model"), healed_model,
|
||||
)
|
||||
kwargs["model"] = healed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await client.chat.completions.create(**kwargs), task)
|
||||
except Exception as retry_err:
|
||||
first_err = retry_err
|
||||
|
||||
# ── Nous auth refresh parity with main agent ──────────────────
|
||||
client_is_nous = (
|
||||
resolved_provider == "nous"
|
||||
|
||||
@@ -32,8 +32,58 @@ function bundledRuntimeImportCheck(platform = process.platform) {
|
||||
return platform === 'win32' ? 'import fastapi, uvicorn, winpty' : 'import fastapi, uvicorn, ptyprocess'
|
||||
}
|
||||
|
||||
const GPU_OVERRIDE_ON = new Set(['1', 'true', 'yes', 'on'])
|
||||
const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off'])
|
||||
|
||||
/**
|
||||
* Decide whether the app is being shown over a remote/forwarded display, where
|
||||
* Chromium's GPU compositor produces an unstable, flickering surface (it can't
|
||||
* present accelerated layers cleanly over the wire). Native local Windows/macOS
|
||||
* sessions composite locally and never hit this, so we only fall back to
|
||||
* software rendering when a remote display is detected.
|
||||
*
|
||||
* Returns a short reason string when GPU acceleration should be disabled, or
|
||||
* null to keep it enabled. `HERMES_DESKTOP_DISABLE_GPU` overrides detection
|
||||
* both ways (1/true/yes/on → always disable, 0/false/no/off → never disable).
|
||||
*
|
||||
* Pure + dependency-free so it can be unit-tested and called before app ready.
|
||||
*/
|
||||
function detectRemoteDisplay(options = {}) {
|
||||
const env = options.env ?? process.env
|
||||
const platform = options.platform ?? process.platform
|
||||
|
||||
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '').trim().toLowerCase()
|
||||
if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)'
|
||||
if (GPU_OVERRIDE_OFF.has(override)) return null
|
||||
|
||||
// Launched from an SSH session → the display is X11-forwarded or otherwise
|
||||
// remote. Covers the common `ssh user@box` + GUI-forwarding case.
|
||||
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'ssh-session'
|
||||
|
||||
if (platform === 'linux') {
|
||||
// X11 forwarding sets DISPLAY to "<host>:N" (e.g. "localhost:10.0"); a
|
||||
// local X server is ":0"/":1" with no host part before the colon.
|
||||
// NB: WSLg deliberately isn't treated as remote — it reports
|
||||
// GPU-accelerated vGPU surfaces locally and doesn't show the flicker.
|
||||
const display = String(env.DISPLAY || '')
|
||||
if (display.includes(':') && display.split(':')[0]) {
|
||||
return `x11-forwarding (DISPLAY=${display})`
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
// RDP sessions report SESSIONNAME like "RDP-Tcp#7"; the local console is
|
||||
// "Console".
|
||||
const sessionName = String(env.SESSIONNAME || '')
|
||||
if (/^rdp-/i.test(sessionName)) return `rdp (SESSIONNAME=${sessionName})`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bundledRuntimeImportCheck,
|
||||
detectRemoteDisplay,
|
||||
isWindowsBinaryPathInWsl,
|
||||
isWslEnvironment
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const {
|
||||
bundledRuntimeImportCheck,
|
||||
detectRemoteDisplay,
|
||||
isWindowsBinaryPathInWsl,
|
||||
isWslEnvironment
|
||||
} = require('./bootstrap-platform.cjs')
|
||||
|
||||
test('isWslEnvironment detects WSL2 env vars on linux', () => {
|
||||
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
|
||||
@@ -28,6 +33,53 @@ test('bundledRuntimeImportCheck selects platform-specific import checks', () =>
|
||||
assert.equal(bundledRuntimeImportCheck('linux'), 'import fastapi, uvicorn, ptyprocess')
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay keeps GPU on for local sessions', () => {
|
||||
// Plain local X11, Wayland, native Windows, native macOS — no remote signal.
|
||||
assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':0' }, platform: 'linux' }), null)
|
||||
assert.equal(detectRemoteDisplay({ env: { WAYLAND_DISPLAY: 'wayland-0' }, platform: 'linux' }), null)
|
||||
assert.equal(detectRemoteDisplay({ env: { SESSIONNAME: 'Console' }, platform: 'win32' }), null)
|
||||
assert.equal(detectRemoteDisplay({ env: {}, platform: 'darwin' }), null)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay does not treat WSLg as remote', () => {
|
||||
// WSLg renders locally via vGPU and doesn't show the flicker, so a WSL
|
||||
// session with a local DISPLAY keeps hardware acceleration on.
|
||||
assert.equal(detectRemoteDisplay({ env: { WSL_DISTRO_NAME: 'Ubuntu', DISPLAY: ':0' }, platform: 'linux' }), null)
|
||||
assert.equal(detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }), null)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay flags SSH sessions on any platform', () => {
|
||||
assert.equal(detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }), 'ssh-session')
|
||||
assert.equal(detectRemoteDisplay({ env: { SSH_CLIENT: '1.2.3.4 5 22' }, platform: 'darwin' }), 'ssh-session')
|
||||
assert.equal(detectRemoteDisplay({ env: { SSH_TTY: '/dev/pts/0' }, platform: 'win32' }), 'ssh-session')
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay flags forwarded X11 displays but not local ones', () => {
|
||||
assert.match(String(detectRemoteDisplay({ env: { DISPLAY: 'localhost:10.0' }, platform: 'linux' })), /x11-forwarding/)
|
||||
assert.match(String(detectRemoteDisplay({ env: { DISPLAY: '192.168.1.5:0' }, platform: 'linux' })), /x11-forwarding/)
|
||||
assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':1' }, platform: 'linux' }), null)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay flags RDP sessions', () => {
|
||||
assert.match(String(detectRemoteDisplay({ env: { SESSIONNAME: 'RDP-Tcp#7' }, platform: 'win32' })), /^rdp/)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay honors the HERMES_DESKTOP_DISABLE_GPU override both ways', () => {
|
||||
// Force-on even on a local display.
|
||||
assert.match(
|
||||
String(detectRemoteDisplay({ env: { HERMES_DESKTOP_DISABLE_GPU: '1', DISPLAY: ':0' }, platform: 'linux' })),
|
||||
/override/
|
||||
)
|
||||
// Force-off even over SSH (escape hatch when a remote display has working accel).
|
||||
assert.equal(
|
||||
detectRemoteDisplay({
|
||||
env: { HERMES_DESKTOP_DISABLE_GPU: 'false', SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' },
|
||||
platform: 'linux'
|
||||
}),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('packaged electron entrypoints do not require unpackaged npm modules', () => {
|
||||
const electronDir = __dirname
|
||||
const entrypoints = ['main.cjs', 'preload.cjs', 'bootstrap-platform.cjs']
|
||||
|
||||
@@ -8,5 +8,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -23,7 +23,7 @@ const net = require('node:net')
|
||||
const path = require('node:path')
|
||||
const { fileURLToPath, pathToFileURL } = require('node:url')
|
||||
const { execFileSync, spawn } = require('node:child_process')
|
||||
const { isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const {
|
||||
@@ -73,6 +73,26 @@ const IS_MAC = process.platform === 'darwin'
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
const IS_WSL = isWslEnvironment()
|
||||
const APP_ROOT = app.getAppPath()
|
||||
|
||||
// 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
|
||||
// Windows/macOS (and WSLg, which renders locally via vGPU) composite on the
|
||||
// GPU and never see it. Fall back to software rendering when a remote display
|
||||
// is detected; it's rock-steady over the wire and the CPU cost is negligible
|
||||
// next to the connection's latency. Must run before app `ready` — these
|
||||
// switches only apply pre-launch. Override with HERMES_DESKTOP_DISABLE_GPU
|
||||
// (1/true → always disable, 0/false → keep GPU on).
|
||||
const REMOTE_DISPLAY_REASON = detectRemoteDisplay()
|
||||
if (REMOTE_DISPLAY_REASON) {
|
||||
app.disableHardwareAcceleration()
|
||||
// Belt-and-suspenders for X11/VNC, where the Viz compositor can still glitch
|
||||
// with only --disable-gpu: force compositing onto the CPU too.
|
||||
app.commandLine.appendSwitch('disable-gpu-compositing')
|
||||
console.log(
|
||||
`[hermes] remote display detected (${REMOTE_DISPLAY_REASON}); disabling GPU hardware acceleration to prevent flicker`
|
||||
)
|
||||
}
|
||||
const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
|
||||
|
||||
// Build-time install stamp -- the git ref this .exe was built against.
|
||||
@@ -445,6 +465,10 @@ let bootstrapFailure = null
|
||||
// Active first-launch install, so the renderer's Cancel button (and app quit)
|
||||
// can abort the in-flight install.sh/ps1 instead of leaving it running.
|
||||
let bootstrapAbortController = null
|
||||
// Set by the renderer's "Repair install" IPC. While true, resolution skips the
|
||||
// existing-install adopt branch (3b) so repair re-drives the installer instead
|
||||
// of re-adopting the install we're repairing. Cleared once a bootstrap runs.
|
||||
let forceBootstrapRepair = false
|
||||
let connectionConfigCache = null
|
||||
const hermesLog = []
|
||||
const previewWatchers = new Map()
|
||||
@@ -1506,8 +1530,12 @@ function readJson(filePath) {
|
||||
// Marker schema (version 1):
|
||||
// {
|
||||
// schemaVersion: 1,
|
||||
// pinnedCommit: "<40-char SHA>", // what install.ps1 was driven against
|
||||
// pinnedCommit: "<40-char SHA>" | null, // what install.ps1 was driven against;
|
||||
// // may be null for adopted installs
|
||||
// pinnedBranch: "<branch name>" | null,
|
||||
// adopted: <bool>, // true when we adopted a pre-existing
|
||||
// // install rather than bootstrapping it;
|
||||
// // treated as authoritative even sans commit
|
||||
// completedAt: "<ISO 8601>",
|
||||
// desktopVersion: "<app.getVersion()>" // for forensics
|
||||
// }
|
||||
@@ -1515,11 +1543,25 @@ function readBootstrapMarker() {
|
||||
return readJson(BOOTSTRAP_COMPLETE_MARKER)
|
||||
}
|
||||
|
||||
// Marker-independent: is the canonical install at ACTIVE_HERMES_ROOT actually
|
||||
// runnable right now? A complete CLI install (`install.sh --include-desktop`)
|
||||
// or a DMG launch over a prior CLI install satisfies this WITHOUT the desktop
|
||||
// ever having written the bootstrap marker -- so we must be able to recognise
|
||||
// "already installed" off the filesystem alone, not just the marker.
|
||||
function isActiveRuntimeUsable() {
|
||||
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
|
||||
}
|
||||
|
||||
function isBootstrapComplete() {
|
||||
const marker = readBootstrapMarker()
|
||||
if (!marker || typeof marker !== 'object') return false
|
||||
if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) return false
|
||||
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) return false
|
||||
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {
|
||||
// Adopted markers (an existing install we detected and took ownership of,
|
||||
// possibly without a resolvable commit) are still authoritative -- they
|
||||
// attest a runnable install we deliberately decided to forward to.
|
||||
if (marker.adopted !== true) return false
|
||||
}
|
||||
// We DELIBERATELY do NOT verify that the checkout is currently at the
|
||||
// pinned commit -- users update via the in-app update path or `hermes
|
||||
// update`, which moves HEAD legitimately. The marker just attests "we
|
||||
@@ -1527,7 +1569,22 @@ function isBootstrapComplete() {
|
||||
// a runnable venv: an interrupted or split-home install can leave the marker
|
||||
// + checkout without a venv, and trusting that spawns a dead backend
|
||||
// ("gateway offline") instead of re-running bootstrap to repair it.
|
||||
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
|
||||
return isActiveRuntimeUsable()
|
||||
}
|
||||
|
||||
// HEAD commit of ACTIVE_HERMES_ROOT so an adopted marker carries the same
|
||||
// provenance a freshly-bootstrapped one would. null when git is unavailable or
|
||||
// the root isn't a checkout -- the marker stays valid via its `adopted` flag.
|
||||
function readActiveHeadCommit() {
|
||||
try {
|
||||
const sha = execFileSync(resolveGitBinary(), ['-C', ACTIVE_HERMES_ROOT, 'rev-parse', 'HEAD'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}).trim()
|
||||
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeBootstrapMarker(payload) {
|
||||
@@ -1536,6 +1593,7 @@ function writeBootstrapMarker(payload) {
|
||||
schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION,
|
||||
pinnedCommit: payload.pinnedCommit || null,
|
||||
pinnedBranch: payload.pinnedBranch || null,
|
||||
adopted: Boolean(payload.adopted),
|
||||
completedAt: new Date().toISOString(),
|
||||
desktopVersion: app.getVersion()
|
||||
}
|
||||
@@ -1693,6 +1751,24 @@ function resolveHermesBackend(dashboardArgs) {
|
||||
return createActiveBackend(dashboardArgs)
|
||||
}
|
||||
|
||||
// 3b. Existing-but-unmarked install at ACTIVE_HERMES_ROOT. The marker is
|
||||
// written only by OUR bootstrap, so a runtime from `install.sh
|
||||
// --include-desktop` (or a DMG launch over a prior CLI install) is
|
||||
// runnable yet markerless -- without this we'd fall to step 6 and re-run
|
||||
// the WHOLE install on top of a working one. ACTIVE_HERMES_ROOT is our
|
||||
// canonical location (unlike a random `hermes` on PATH), so adopt it:
|
||||
// stamp the marker once and forward straight to the app. Repair skips
|
||||
// this so a broken-but-present venv still gets rebuilt.
|
||||
if (!forceBootstrapRepair && isActiveRuntimeUsable()) {
|
||||
rememberLog(`[bootstrap] adopting existing install at ${ACTIVE_HERMES_ROOT}; skipping first-launch setup`)
|
||||
try {
|
||||
writeBootstrapMarker({ pinnedCommit: readActiveHeadCommit(), pinnedBranch: null, adopted: true })
|
||||
} catch (err) {
|
||||
rememberLog(`[bootstrap] could not stamp adopted marker: ${err.message}`)
|
||||
}
|
||||
return createActiveBackend(dashboardArgs)
|
||||
}
|
||||
|
||||
// 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from
|
||||
// a previous tool-only setup, or pip-installed system-wide. Use it but
|
||||
// do NOT write a bootstrap marker; the user did this themselves and we
|
||||
@@ -1883,6 +1959,9 @@ async function ensureRuntime(backend) {
|
||||
}
|
||||
|
||||
rememberLog('[bootstrap] bootstrap complete; marker written. Re-resolving backend.')
|
||||
// A repair (if any) has now re-run, so clear the gate -- the re-resolution
|
||||
// below SHOULD land on the fresh marker fast-path rather than skip it.
|
||||
forceBootstrapRepair = false
|
||||
// Re-resolve now that the install exists. The new resolution lands in
|
||||
// step 3 (bootstrap-complete marker) and we recurse to wire venvPython.
|
||||
return ensureRuntime(resolveHermesBackend(backend.args))
|
||||
@@ -2672,9 +2751,31 @@ function buildApplicationMenu() {
|
||||
{ role: 'forceReload' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{
|
||||
label: 'Actual Size',
|
||||
accelerator: 'CommandOrControl+0',
|
||||
click: () => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.setZoomLevel(0) }
|
||||
},
|
||||
{
|
||||
label: 'Zoom In',
|
||||
accelerator: 'CommandOrControl+Plus',
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const next = Math.min(mainWindow.webContents.getZoomLevel() + 0.1, 9)
|
||||
mainWindow.webContents.setZoomLevel(next)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Zoom Out',
|
||||
accelerator: 'CommandOrControl+-',
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const next = Math.max(mainWindow.webContents.getZoomLevel() - 0.1, -9)
|
||||
mainWindow.webContents.setZoomLevel(next)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' }
|
||||
]
|
||||
@@ -2733,6 +2834,32 @@ function installPreviewShortcut(window) {
|
||||
})
|
||||
}
|
||||
|
||||
function installZoomShortcuts(window) {
|
||||
// Override Ctrl/Cmd + +/-/0 with half the default zoom step (0.1 vs 0.2).
|
||||
// The menu items handle this on macOS (where the menu is always present),
|
||||
// but on Linux/Windows the menu is null and Chromium's default handler
|
||||
// would use the full 0.2 step, so we intercept here for consistency.
|
||||
const ZOOM_STEP = 0.1
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
const mod = IS_MAC ? input.meta : input.control
|
||||
if (!mod || input.alt || input.shift) return
|
||||
|
||||
const key = input.key
|
||||
if (key === '0') {
|
||||
event.preventDefault()
|
||||
window.webContents.setZoomLevel(0)
|
||||
} else if (key === '=' || key === '+') {
|
||||
event.preventDefault()
|
||||
const next = Math.min(window.webContents.getZoomLevel() + ZOOM_STEP, 9)
|
||||
window.webContents.setZoomLevel(next)
|
||||
} else if (key === '-') {
|
||||
event.preventDefault()
|
||||
const next = Math.max(window.webContents.getZoomLevel() - ZOOM_STEP, -9)
|
||||
window.webContents.setZoomLevel(next)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function installContextMenu(window) {
|
||||
window.webContents.on('context-menu', (_event, params) => {
|
||||
const template = []
|
||||
@@ -3319,6 +3446,7 @@ function createWindow() {
|
||||
|
||||
installPreviewShortcut(mainWindow)
|
||||
installDevToolsShortcut(mainWindow)
|
||||
installZoomShortcuts(mainWindow)
|
||||
installContextMenu(mainWindow)
|
||||
mainWindow.webContents.setWindowOpenHandler(details => {
|
||||
openExternalUrl(details.url)
|
||||
@@ -3399,6 +3527,7 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
// full backend flow (including a fresh runBootstrap pass).
|
||||
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
|
||||
bootstrapFailure = null
|
||||
forceBootstrapRepair = false
|
||||
connectionPromise = null
|
||||
bootstrapState = {
|
||||
active: false,
|
||||
@@ -3426,6 +3555,9 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
|
||||
rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`)
|
||||
}
|
||||
bootstrapFailure = null
|
||||
// Force the next resolution past both the marker fast-path and the adopt
|
||||
// branch so the installer actually re-runs (the whole point of repair).
|
||||
forceBootstrapRepair = true
|
||||
resetHermesConnection()
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -3941,7 +4073,99 @@ ipcMain.handle('hermes:version', async () => ({
|
||||
hermesRoot: resolveUpdateRoot()
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// macOS first-launch placement: move into /Applications and pin to the Dock
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The DMG and CLI-built apps launch from wherever the user left them (a DMG
|
||||
// mount, ~/Downloads, ~/.hermes/...) -- which means Gatekeeper translocation,
|
||||
// no Dock tile, and "which icon do I click?" confusion. On first packaged
|
||||
// launch we relocate into /Applications (Electron relaunches from there) and,
|
||||
// once we're that canonical copy, pin to the Dock. Both macOS-only,
|
||||
// packaged-only, best-effort, run at most once.
|
||||
|
||||
// Move the bundle into /Applications and relaunch. Returns true when a relaunch
|
||||
// is underway (caller must stop init). No-op in dev, off macOS, or already in
|
||||
// /Applications. `existsAndRunning` -> another copy owns the slot; don't fight
|
||||
// it. `exists` -> stale copy; replace it so there's exactly one current app.
|
||||
function maybeRelocateToApplications() {
|
||||
if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_AUTO_MOVE === '1') return false
|
||||
try {
|
||||
if (app.isInApplicationsFolder()) return false
|
||||
const moved = app.moveToApplicationsFolder({ conflictHandler: type => type !== 'existsAndRunning' })
|
||||
if (moved) rememberLog('[install] relocated into /Applications; relaunching')
|
||||
return moved
|
||||
} catch (err) {
|
||||
rememberLog(`[install] move to /Applications skipped: ${err.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const DOCK_PINNED_MARKER = 'dock-pinned.json'
|
||||
|
||||
// Pin the /Applications copy to the Dock once. macOS has no Electron API for
|
||||
// this, so we append to com.apple.dock's persistent-apps and restart the Dock.
|
||||
// Guarded by a userData marker + membership check so we never duplicate the tile.
|
||||
function maybePinToDock() {
|
||||
if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_DOCK_PIN === '1') return
|
||||
const marker = path.join(app.getPath('userData'), DOCK_PINNED_MARKER)
|
||||
if (fileExists(marker)) return
|
||||
|
||||
let bundle
|
||||
try {
|
||||
if (!app.isInApplicationsFolder()) return // don't pin a soon-to-be-stale path
|
||||
bundle = runningAppBundle()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!bundle) return
|
||||
|
||||
// The Dock stores tiles as file-reference URLs (type 15), e.g.
|
||||
// file:///Applications/Hermes.app/ -- NOT a raw POSIX path. A type-0/raw-path
|
||||
// tile is silently dropped when the Dock rewrites persistent-apps on restart.
|
||||
const url = pathToFileURL(bundle.endsWith('/') ? bundle : `${bundle}/`).href
|
||||
|
||||
const done = (note = {}) => {
|
||||
try {
|
||||
fs.writeFileSync(marker, JSON.stringify({ bundle, pinnedAt: new Date().toISOString(), ...note }) + '\n')
|
||||
} catch {
|
||||
// best-effort; we re-check next launch (membership guard dedupes)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const apps = execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
if (apps.includes(url)) return done({ alreadyPresent: true })
|
||||
} catch {
|
||||
// persistent-apps may not exist yet; -array-add creates it
|
||||
}
|
||||
|
||||
const tile =
|
||||
'<dict><key>tile-data</key><dict><key>file-data</key><dict>' +
|
||||
`<key>_CFURLString</key><string>${url}</string><key>_CFURLStringType</key><integer>15</integer>` +
|
||||
'</dict></dict></dict>'
|
||||
try {
|
||||
execFileSync('defaults', ['write', 'com.apple.dock', 'persistent-apps', '-array-add', tile], { stdio: 'ignore' })
|
||||
// Flush the write through cfprefsd before restarting the Dock, otherwise the
|
||||
// Dock reloads stale prefs and our tile is lost in the race.
|
||||
execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], { stdio: 'ignore' })
|
||||
execFileSync('killall', ['Dock'], { stdio: 'ignore' })
|
||||
done()
|
||||
rememberLog(`[install] pinned to Dock: ${url}`)
|
||||
} catch (err) {
|
||||
rememberLog(`[install] Dock pin skipped: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// macOS: relocate into /Applications before anything else so setup + state
|
||||
// land in the final location; on success this relaunches, so bail here.
|
||||
if (maybeRelocateToApplications()) return
|
||||
maybePinToDock()
|
||||
|
||||
if (IS_MAC) {
|
||||
Menu.setApplicationMenu(buildApplicationMenu())
|
||||
} else {
|
||||
|
||||
Generated
-18363
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,229 @@
|
||||
// Reproduce + diagnose the "scroll wheel resets position while reading" bug.
|
||||
//
|
||||
// The complaint (Windows, mouse wheel): scrolling UP through a chat to re-read
|
||||
// older content randomly yanks the view to a different position, so you have to
|
||||
// fight the scrollbar. Mac users on trackpads don't see it.
|
||||
//
|
||||
// Hypothesis: the thread scroller has the browser default `overflow-anchor:
|
||||
// auto`, and the thread renders items in natural document flow (padding
|
||||
// spacers, NOT transforms). When an item above the viewport is measured by
|
||||
// @tanstack/react-virtual (its real height differs a lot from the 220px
|
||||
// estimate) — or when Shiki/images/fonts reflow it — TWO mechanisms both
|
||||
// adjust scrollTop for the same delta: TanStack's measurement compensation AND
|
||||
// the browser's native scroll anchoring. The double-correction lurches the
|
||||
// view. A mouse wheel's coarse, discrete notches mount/measure several
|
||||
// under-estimated turns per tick, so the over-correction is large and visible;
|
||||
// a trackpad's ~1-3px/frame keeps it sub-perceptual.
|
||||
//
|
||||
// This script drives synthetic mouse-wheel-UP scrolling on a long thread and
|
||||
// measures how much a tracked on-screen turn jumps, first with
|
||||
// `overflow-anchor: auto` (reproduce) then `overflow-anchor: none` (the fix).
|
||||
// If the fix run shows dramatically fewer/smaller jumps, the hypothesis holds.
|
||||
//
|
||||
// Prereq: a running desktop app with remote debugging on 9222, on a thread
|
||||
// with enough history to scroll (the longer / more code+tool blocks, the
|
||||
// better the repro). Then: node apps/desktop/scripts/diag-scroll-reset.mjs
|
||||
|
||||
const NOTCHES = 14 // wheel-up ticks per sweep
|
||||
const NOTCH_PX = 120 // Windows wheel notch ≈ 120px
|
||||
const NOTCH_GAP_MS = 130 // let each smooth-scroll animation settle
|
||||
const REVERSE_JUMP_PX = 6 // tracked turn moving UP while scrolling up = wrong way
|
||||
const LURCH_PX = 60 // single-frame on-screen jump that reads as a "reset"
|
||||
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
|
||||
if (!tgt) {
|
||||
console.error('No page target on :9222. Is the desktop app running with --remote-debugging-port=9222?')
|
||||
process.exit(1)
|
||||
}
|
||||
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.addEventListener('message', ev => {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (m.id != null && pending.has(m.id)) {
|
||||
pending.get(m.id)(m)
|
||||
pending.delete(m.id)
|
||||
}
|
||||
})
|
||||
await new Promise(r => ws.addEventListener('open', r))
|
||||
const send = (m, p = {}) =>
|
||||
new Promise(r => {
|
||||
const i = ++id
|
||||
pending.set(i, r)
|
||||
ws.send(JSON.stringify({ id: i, method: m, params: p }))
|
||||
})
|
||||
const evalP = async expr => {
|
||||
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true })
|
||||
if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text)
|
||||
return r.result.result.value
|
||||
}
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
// Install per-sweep instrumentation. `mode` is the overflow-anchor value to
|
||||
// force inline so we A/B the exact same thread regardless of any CSS fix.
|
||||
// Starts from ~45% down the thread so there's room to scroll up into
|
||||
// not-yet-measured turns, tags the turn nearest viewport-center as the anchor,
|
||||
// then records (per rAF) scrollTop + that turn's on-screen top, plus every
|
||||
// scrollTop *setter* write (TanStack compensation) and ResizeObserver hit.
|
||||
async function arm(mode) {
|
||||
await evalP(`(() => {
|
||||
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!v) throw new Error('thread viewport not found')
|
||||
|
||||
// Force the overflow-anchor behavior under test (inline beats CSS).
|
||||
v.style.overflowAnchor = ${JSON.stringify(mode)}
|
||||
|
||||
// Park ~45% down so a wheel-up sweep climbs into estimated-but-unmeasured
|
||||
// turns above the fold (where the measurement correction fires).
|
||||
v.scrollTop = Math.round(v.scrollHeight * 0.45)
|
||||
|
||||
// Tag the turn closest to viewport center; we track its on-screen top.
|
||||
const vr = v.getBoundingClientRect()
|
||||
const center = vr.top + v.clientHeight / 2
|
||||
let best = null, bestD = Infinity
|
||||
for (const el of v.querySelectorAll('[data-index]')) {
|
||||
const r = el.getBoundingClientRect()
|
||||
const d = Math.abs((r.top + r.height / 2) - center)
|
||||
if (d < bestD) { bestD = d; best = el }
|
||||
}
|
||||
document.querySelectorAll('[data-se-anchor]').forEach(e => e.removeAttribute('data-se-anchor'))
|
||||
if (best) best.setAttribute('data-se-anchor', '1')
|
||||
const anchorIndex = best ? best.getAttribute('data-index') : null
|
||||
|
||||
const samples = []
|
||||
const writes = []
|
||||
const ros = []
|
||||
const t0 = performance.now()
|
||||
|
||||
// Intercept scrollTop writes → these are JS (TanStack) corrections.
|
||||
// Native browser scroll anchoring does NOT go through this setter, so a
|
||||
// scrollTop change with no write in the same frame is a native adjust.
|
||||
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
|
||||
Object.defineProperty(v, 'scrollTop', {
|
||||
configurable: true,
|
||||
get() { return desc.get.call(this) },
|
||||
set(val) {
|
||||
writes.push({ t: performance.now() - t0, val, sh: this.scrollHeight })
|
||||
desc.set.call(this, val)
|
||||
}
|
||||
})
|
||||
window.__restoreScrollTop = () => Object.defineProperty(v, 'scrollTop', desc)
|
||||
|
||||
const ro = new ResizeObserver(entries => {
|
||||
for (const e of entries) {
|
||||
ros.push({ t: performance.now() - t0, slot: e.target.getAttribute?.('data-slot') || e.target.tagName, h: Math.round(e.contentRect.height) })
|
||||
}
|
||||
})
|
||||
ro.observe(v)
|
||||
if (v.firstElementChild) ro.observe(v.firstElementChild)
|
||||
|
||||
let running = true
|
||||
const tick = () => {
|
||||
if (!running) return
|
||||
const a = v.querySelector('[data-se-anchor]')
|
||||
const ar = a ? a.getBoundingClientRect() : null
|
||||
samples.push({
|
||||
t: performance.now() - t0,
|
||||
st: Math.round(v.scrollTop * 100) / 100,
|
||||
sh: v.scrollHeight,
|
||||
ch: v.clientHeight,
|
||||
atop: ar ? Math.round(ar.top * 100) / 100 : null,
|
||||
aconn: !!a
|
||||
})
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
|
||||
window.__se = { samples, writes, ros, anchorIndex, dpr: window.devicePixelRatio, stop() { running = false; ro.disconnect(); window.__restoreScrollTop?.() } }
|
||||
return true
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function wheelUpSweep() {
|
||||
const { x, y } = await evalP(`(() => {
|
||||
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
const r = v.getBoundingClientRect()
|
||||
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }
|
||||
})()`)
|
||||
|
||||
for (let i = 0; i < NOTCHES; i++) {
|
||||
await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX: 0, deltaY: -NOTCH_PX })
|
||||
await sleep(NOTCH_GAP_MS)
|
||||
}
|
||||
await sleep(400)
|
||||
}
|
||||
|
||||
async function collect() {
|
||||
const data = JSON.parse(await evalP(`(() => { window.__se.stop(); return JSON.stringify(window.__se) })()`))
|
||||
return data
|
||||
}
|
||||
|
||||
function analyze(label, data) {
|
||||
const { samples, writes, ros, anchorIndex, dpr } = data
|
||||
let reverseJumps = 0
|
||||
let reverseSum = 0
|
||||
let lurches = 0
|
||||
let maxJump = 0
|
||||
let nativeMoves = 0
|
||||
let prev = null
|
||||
for (const s of samples) {
|
||||
if (prev && prev.aconn && s.aconn && prev.atop != null && s.atop != null) {
|
||||
const dTop = s.atop - prev.atop // wheel-up should move content DOWN → dTop >= 0
|
||||
const dSt = s.st - prev.st
|
||||
// Native (browser-anchoring) move: scrollTop changed with no setter write in this frame window.
|
||||
const wroteThisFrame = writes.some(w => w.t > prev.t && w.t <= s.t)
|
||||
if (Math.abs(dSt) > 0.5 && !wroteThisFrame) nativeMoves++
|
||||
if (dTop < -REVERSE_JUMP_PX) {
|
||||
reverseJumps++
|
||||
reverseSum += -dTop
|
||||
}
|
||||
if (Math.abs(dTop) > LURCH_PX) lurches++
|
||||
if (Math.abs(dTop) > maxJump) maxJump = Math.abs(dTop)
|
||||
}
|
||||
prev = s
|
||||
}
|
||||
console.log(`\n── ${label} ──`)
|
||||
console.log(` devicePixelRatio: ${dpr}${Number.isInteger(dpr) ? '' : ' (fractional — Windows scaling, worsens rounding jitter)'}`)
|
||||
console.log(` tracked turn index: ${anchorIndex}`)
|
||||
console.log(` rAF frames: ${samples.length}`)
|
||||
console.log(` scrollTop writes: ${writes.length} (TanStack measurement corrections)`)
|
||||
console.log(` ResizeObserver hits: ${ros.length}`)
|
||||
console.log(` native scroll moves: ${nativeMoves} (scrollTop moved with NO JS write = browser anchoring)`)
|
||||
console.log(` reverse jumps: ${reverseJumps} (tracked turn yanked UP while scrolling up; total ${reverseSum.toFixed(0)}px)`)
|
||||
console.log(` big lurches (>${LURCH_PX}px): ${lurches}`)
|
||||
console.log(` max single-frame jump: ${maxJump.toFixed(0)}px`)
|
||||
return { reverseJumps, reverseSum, lurches, maxJump, nativeMoves }
|
||||
}
|
||||
|
||||
console.log(`Wheel-up repro: ${NOTCHES} notches × ${NOTCH_PX}px, anchored mid-thread.\n`)
|
||||
|
||||
await arm('auto')
|
||||
await sleep(150)
|
||||
await wheelUpSweep()
|
||||
const a = analyze('overflow-anchor: auto (current / repro)', await collect())
|
||||
|
||||
await sleep(300)
|
||||
|
||||
await arm('none')
|
||||
await sleep(150)
|
||||
await wheelUpSweep()
|
||||
const b = analyze('overflow-anchor: none (proposed fix)', await collect())
|
||||
|
||||
// Clean up our tag.
|
||||
await evalP(`document.querySelectorAll('[data-se-anchor]').forEach(e => e.removeAttribute('data-se-anchor'))`)
|
||||
|
||||
console.log('\n══ verdict ══')
|
||||
const drop = (x, y) => (x === 0 ? (y === 0 ? '0' : 'n/a') : `${Math.round((1 - y / x) * 100)}% fewer`)
|
||||
console.log(` reverse jumps: auto=${a.reverseJumps} none=${b.reverseJumps} (${drop(a.reverseJumps, b.reverseJumps)})`)
|
||||
console.log(` big lurches: auto=${a.lurches} none=${b.lurches} (${drop(a.lurches, b.lurches)})`)
|
||||
console.log(` max jump: auto=${a.maxJump.toFixed(0)}px none=${b.maxJump.toFixed(0)}px`)
|
||||
console.log(` native moves: auto=${a.nativeMoves} none=${b.nativeMoves} (browser anchoring should ~vanish at none)`)
|
||||
if (a.reverseJumps + a.lurches > 0 && b.reverseJumps + b.lurches < a.reverseJumps + a.lurches) {
|
||||
console.log('\n → Jumps drop sharply with overflow-anchor:none → root cause confirmed.')
|
||||
} else if (a.reverseJumps + a.lurches === 0) {
|
||||
console.log('\n → No jumps captured this run. Use a longer thread (many code/tool blocks),')
|
||||
console.log(' raise NOTCHES, and ensure you start scrolled up from the bottom.')
|
||||
}
|
||||
|
||||
ws.close()
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
enqueueQueuedPrompt,
|
||||
type QueuedPromptEntry,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrainOnSettle,
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $messages } from '@/store/session'
|
||||
@@ -124,6 +125,12 @@ export function ChatBar({
|
||||
const draftRef = useRef(draft)
|
||||
const previousBusyRef = useRef(busy)
|
||||
const drainingQueueRef = useRef(false)
|
||||
// Set when the user explicitly interrupts the running turn via the Stop
|
||||
// button (busy + empty composer). It suppresses the next busy→false
|
||||
// auto-drain so an explicit Stop actually halts instead of immediately
|
||||
// firing the head of the queue. The queue is preserved; the user resumes
|
||||
// it deliberately via Cmd/Ctrl+K, Enter, or the per-row "send now" arrow.
|
||||
const userInterruptedRef = useRef(false)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [urlOpen, setUrlOpen] = useState(false)
|
||||
@@ -414,6 +421,14 @@ export function ChatBar({
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
// Set synchronously in keydown when the open trigger popover consumes a
|
||||
// navigation/control key (Arrow/Enter/Tab/Escape). The subsequent keyup must
|
||||
// NOT run refreshTrigger for that keypress: it never edits text, and for
|
||||
// Escape the keydown has already set trigger=null, so a keyup refresh would
|
||||
// re-detect the still-present `/` and instantly reopen the menu. A ref is
|
||||
// used instead of reading `trigger` in keyup because by keyup time React has
|
||||
// re-rendered and the handler closure sees the post-keydown state.
|
||||
const triggerKeyConsumedRef = useRef(false)
|
||||
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
@@ -442,7 +457,14 @@ export function ChatBar({
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
setTrigger(detected)
|
||||
setTriggerActive(0)
|
||||
|
||||
// Only reset the highlight when the trigger actually changed (opened, or
|
||||
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
|
||||
// caret move (mouseup) or a stray refresh — must preserve the user's
|
||||
// current selection instead of snapping back to the first item.
|
||||
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
||||
setTriggerActive(0)
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
@@ -558,6 +580,7 @@ export function ChatBar({
|
||||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
||||
|
||||
return
|
||||
@@ -565,6 +588,7 @@ export function ChatBar({
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
||||
|
||||
return
|
||||
@@ -572,6 +596,7 @@ export function ChatBar({
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
const item = triggerItems[triggerActive]
|
||||
|
||||
if (item) {
|
||||
@@ -583,6 +608,7 @@ export function ChatBar({
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
@@ -603,6 +629,18 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
const handleEditorKeyUp = () => {
|
||||
// If this keyup belongs to a key the open trigger popover already consumed
|
||||
// in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
|
||||
// edit text, and for Escape the keydown already closed the menu — a refresh
|
||||
// here would re-detect the still-present `/` and instantly reopen it. We
|
||||
// read a ref set during keydown rather than `trigger`, because by keyup
|
||||
// time React has re-rendered and `trigger` may already be null.
|
||||
if (triggerKeyConsumedRef.current) {
|
||||
triggerKeyConsumedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
@@ -844,26 +882,42 @@ export function ChatBar({
|
||||
[queueEdit, runDrain]
|
||||
)
|
||||
|
||||
const interruptAndSendNextQueued = useCallback(async () => {
|
||||
if (queuedPrompts.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
await Promise.resolve(onCancel())
|
||||
|
||||
return drainNextQueued()
|
||||
}, [drainNextQueued, onCancel, queuedPrompts.length])
|
||||
|
||||
// Auto-drain on busy → false (turn settled).
|
||||
// Auto-drain on busy → false (turn settled). An explicit user interrupt
|
||||
// (Stop button) sets userInterruptedRef so we skip exactly one auto-drain:
|
||||
// the user asked to halt, so we must not immediately re-send the queue.
|
||||
// The queued turns stay intact and the user resumes them on demand.
|
||||
useEffect(() => {
|
||||
const wasBusy = previousBusyRef.current
|
||||
previousBusyRef.current = busy
|
||||
|
||||
if (busy || !wasBusy || queuedPrompts.length === 0) {
|
||||
// Clear the interrupt latch when a new turn starts (false → true). This
|
||||
// guards the sub-frame race where a Stop click lands after busy already
|
||||
// flipped false (button not yet unmounted): the stale latch can no longer
|
||||
// survive into the next turn and wrongly suppress its natural auto-drain.
|
||||
if (busy && !wasBusy) {
|
||||
userInterruptedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void drainNextQueued()
|
||||
const interrupted = userInterruptedRef.current
|
||||
|
||||
// Consume the interrupt latch on any settle so a later natural completion
|
||||
// is not wrongly suppressed.
|
||||
if (!busy && wasBusy && interrupted) {
|
||||
userInterruptedRef.current = false
|
||||
}
|
||||
|
||||
if (
|
||||
shouldAutoDrainOnSettle({
|
||||
isBusy: busy,
|
||||
queueLength: queuedPrompts.length,
|
||||
userInterrupted: interrupted,
|
||||
wasBusy
|
||||
})
|
||||
) {
|
||||
void drainNextQueued()
|
||||
}
|
||||
}, [busy, drainNextQueued, queuedPrompts.length])
|
||||
|
||||
// Clean up queue edit when its target disappears (session swap or external delete).
|
||||
@@ -886,9 +940,13 @@ export function ChatBar({
|
||||
} else if (busy) {
|
||||
if (hasComposerPayload) {
|
||||
queueCurrentDraft()
|
||||
} else if (queuedPrompts.length > 0) {
|
||||
void interruptAndSendNextQueued()
|
||||
} else {
|
||||
// Stop button: an explicit interrupt must actually halt the running
|
||||
// turn. Mark the interrupt so the busy→false auto-drain effect skips
|
||||
// re-sending the queue — otherwise a queued follow-up would fire the
|
||||
// instant we cancel and Stop would appear to "never work". Queued
|
||||
// turns are preserved; the user sends them on demand.
|
||||
userInterruptedRef.current = true
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { act, fireEvent, render } from '@testing-library/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useLiveCompletionAdapter } from './hooks/use-live-completion-adapter'
|
||||
import { detectTrigger, type TriggerState } from './text-utils'
|
||||
|
||||
// Faithful mirror of index.tsx's trigger wiring, driven through REAL DOM
|
||||
// keydown+keyup events on a contentEditable. Exercises the parts a direct
|
||||
// reducer-call repro misses: the keyup -> refreshTrigger path, the
|
||||
// keydown-set "consumed" ref that guards it, and per-press keydown+keyup
|
||||
// ordering (critical for Escape, whose keydown nulls `trigger` before keyup).
|
||||
function Harness({
|
||||
onState
|
||||
}: {
|
||||
onState: (s: { active: number; items: readonly Unstable_TriggerItem[]; open: boolean }) => void
|
||||
}) {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const triggerKeyConsumedRef = useRef(false)
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
|
||||
const { adapter } = useLiveCompletionAdapter({
|
||||
enabled: true,
|
||||
debounceMs: 0,
|
||||
fetcher: async (query: string) => ({
|
||||
query,
|
||||
items: Array.from({ length: 5 }, (_, i) => ({ text: `/cmd${i}`, display: `/cmd${i}`, meta: '' }))
|
||||
}),
|
||||
toItem: (entry, index) => ({ id: `${entry.text}|${index}`, type: 'slash', label: entry.text.slice(1) })
|
||||
})
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '/' ? adapter : null
|
||||
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {return}
|
||||
const raw = editor.textContent ?? ''
|
||||
|
||||
if (!raw.includes('@') && !raw.includes('/')) {
|
||||
if (trigger) {
|
||||
setTrigger(null)
|
||||
setTriggerActive(0)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const detected = detectTrigger(raw)
|
||||
setTrigger(detected)
|
||||
|
||||
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
||||
setTriggerActive(0)
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger || !triggerAdapter?.search) {
|
||||
setTriggerItems([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setTriggerItems(triggerAdapter.search(trigger.query))
|
||||
}, [trigger, triggerAdapter])
|
||||
|
||||
useEffect(() => {
|
||||
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
||||
}, [triggerItems.length])
|
||||
|
||||
onState({ active: triggerActive, items: triggerItems, open: trigger !== null })
|
||||
|
||||
const closeTrigger = () => {
|
||||
setTrigger(null)
|
||||
setTriggerItems([])
|
||||
setTriggerActive(0)
|
||||
}
|
||||
|
||||
// Exact copies of index.tsx handlers, including the keydown-set "consumed"
|
||||
// ref that the keyup consults.
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = () => {
|
||||
if (triggerKeyConsumedRef.current) {
|
||||
triggerKeyConsumedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// index.tsx defers via setTimeout(refreshTrigger, 0); call synchronously
|
||||
// here so the test deterministically observes the keyup-driven refresh.
|
||||
refreshTrigger()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
contentEditable
|
||||
data-testid="editor"
|
||||
onInput={() => refreshTrigger()}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
ref={editorRef}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
})
|
||||
}
|
||||
|
||||
describe('slash menu navigation — real DOM keydown+keyup', () => {
|
||||
it('cycles through ALL items and Esc closes (and stays closed)', async () => {
|
||||
vi.useRealTimers()
|
||||
let latest = { active: 0, items: [] as readonly Unstable_TriggerItem[], open: false }
|
||||
const { getByTestId } = render(<Harness onState={s => (latest = s)} />)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
// Simulate typing '/'.
|
||||
await act(async () => {
|
||||
editor.textContent = '/'
|
||||
fireEvent.input(editor)
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(latest.open).toBe(true)
|
||||
expect(latest.items.length).toBe(5)
|
||||
|
||||
// ArrowDown 6x with REAL keydown+keyup pairs. Bug = stuck [0,1,0,1,...].
|
||||
const seen: number[] = [latest.active]
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(editor, { key: 'ArrowDown' })
|
||||
fireEvent.keyUp(editor, { key: 'ArrowDown' })
|
||||
await Promise.resolve()
|
||||
})
|
||||
seen.push(latest.active)
|
||||
}
|
||||
|
||||
expect(seen).toEqual([0, 1, 2, 3, 4, 0, 1])
|
||||
|
||||
// Escape: keydown closes; keyup must NOT reopen (the '/' is still in text).
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(editor, { key: 'Escape' })
|
||||
fireEvent.keyUp(editor, { key: 'Escape' })
|
||||
await Promise.resolve()
|
||||
})
|
||||
await flush()
|
||||
expect(latest.open).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { detectTrigger } from './text-utils'
|
||||
|
||||
describe('detectTrigger', () => {
|
||||
it('detects a bare slash trigger with an empty query', () => {
|
||||
expect(detectTrigger('/')).toEqual({ kind: '/', query: '', tokenLength: 1 })
|
||||
})
|
||||
|
||||
it('detects a slash command query', () => {
|
||||
expect(detectTrigger('/skill')).toEqual({ kind: '/', query: 'skill', tokenLength: 6 })
|
||||
})
|
||||
|
||||
it('detects a bare at-mention trigger with an empty query', () => {
|
||||
expect(detectTrigger('@')).toEqual({ kind: '@', query: '', tokenLength: 1 })
|
||||
})
|
||||
|
||||
it('detects an at-mention query', () => {
|
||||
expect(detectTrigger('@file')).toEqual({ kind: '@', query: 'file', tokenLength: 5 })
|
||||
})
|
||||
|
||||
it('returns null for plain text', () => {
|
||||
expect(detectTrigger('hello there')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef } from 'react'
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
$gatewayState,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
mergeWorkingSessions,
|
||||
sessionPinId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
@@ -59,10 +61,11 @@ import { ChatSidebar } from './chat/sidebar'
|
||||
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||
import { RightSidebarPane } from './right-sidebar'
|
||||
import { $terminalTakeover } from './right-sidebar/store'
|
||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from './routes'
|
||||
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
||||
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
|
||||
import { useCwdActions } from './session/hooks/use-cwd-actions'
|
||||
import { useHermesConfig } from './session/hooks/use-hermes-config'
|
||||
@@ -77,6 +80,7 @@ import { AppShell } from './shell/app-shell'
|
||||
import { useOverlayRouting } from './shell/hooks/use-overlay-routing'
|
||||
import { useStatusSnapshot } from './shell/hooks/use-status-snapshot'
|
||||
import { useStatusbarItems } from './shell/hooks/use-statusbar-items'
|
||||
import { ModelMenuPanel } from './shell/model-menu-panel'
|
||||
import type { StatusbarItem } from './shell/statusbar-controls'
|
||||
import type { TitlebarTool } from './shell/titlebar-controls'
|
||||
import { useGroupRegistry } from './shell/use-group-registry'
|
||||
@@ -204,7 +208,12 @@ export function DesktopController() {
|
||||
const result = await listSessions(limit, 1)
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
setSessions(result.sessions)
|
||||
// Don't hard-replace: a session whose first turn is still in flight has
|
||||
// message_count 0 in the DB, so min_messages=1 omits it. Since every
|
||||
// message.complete refreshes the list, a plain replace would drop the
|
||||
// other still-running new chats the moment one of them finishes. Keep
|
||||
// any working session the server hasn't surfaced yet.
|
||||
setSessions(prev => mergeWorkingSessions(prev, result.sessions, $workingSessionIds.get()))
|
||||
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
|
||||
}
|
||||
} finally {
|
||||
@@ -274,6 +283,22 @@ export function DesktopController() {
|
||||
requestGateway
|
||||
})
|
||||
|
||||
const openProviderSettings = useCallback(() => {
|
||||
navigate(`${SETTINGS_ROUTE}?tab=keys`)
|
||||
}, [navigate])
|
||||
|
||||
const modelMenuContent = useMemo(
|
||||
() =>
|
||||
gatewayState === 'open' ? (
|
||||
<ModelMenuPanel
|
||||
gateway={gatewayRef.current || undefined}
|
||||
onSelectModel={selectModel}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
) : null,
|
||||
[gatewayRef, gatewayState, requestGateway, selectModel]
|
||||
)
|
||||
|
||||
useContextSuggestions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
@@ -497,6 +522,7 @@ export function DesktopController() {
|
||||
gatewayLogLines,
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
modelMenuContent,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
statusSnapshot,
|
||||
@@ -531,6 +557,7 @@ export function DesktopController() {
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
|
||||
<UpdatesOverlay />
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { ModelVisibilityDialog } from '@/components/model-visibility-dialog'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { $modelVisibilityOpen, setModelVisibilityOpen } from '@/store/model-visibility'
|
||||
import { $activeSessionId, $gatewayState } from '@/store/session'
|
||||
|
||||
interface ModelVisibilityOverlayProps {
|
||||
gateway?: HermesGateway
|
||||
onOpenProviders: () => void
|
||||
}
|
||||
|
||||
export function ModelVisibilityOverlay({ gateway, onOpenProviders }: ModelVisibilityOverlayProps) {
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const gatewayOpen = useStore($gatewayState) === 'open'
|
||||
const open = useStore($modelVisibilityOpen)
|
||||
|
||||
if (!gatewayOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ModelVisibilityDialog
|
||||
gw={gateway}
|
||||
onOpenChange={setModelVisibilityOpen}
|
||||
onOpenProviders={onOpenProviders}
|
||||
open={open}
|
||||
sessionId={activeSessionId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useCallback } from 'react'
|
||||
|
||||
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentModel, setCurrentProvider } from '@/store/session'
|
||||
import { $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
interface ModelSelection {
|
||||
@@ -48,38 +48,53 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Returns whether the switch succeeded so callers can await it before
|
||||
// applying follow-up changes (e.g. editing a model's reasoning/fast must land
|
||||
// on the right active model — bail rather than write to the previous one).
|
||||
const selectModel = useCallback(
|
||||
(selection: ModelSelection) => {
|
||||
async (selection: ModelSelection): Promise<boolean> => {
|
||||
const includeGlobal = selection.persistGlobal || !activeSessionId
|
||||
// Snapshot for rollback: the switch is applied optimistically, so a
|
||||
// failure must restore the prior model/provider (store + query cache)
|
||||
// rather than leave the UI showing a model the backend never selected.
|
||||
const prevModel = $currentModel.get()
|
||||
const prevProvider = $currentProvider.get()
|
||||
|
||||
setCurrentModel(selection.model)
|
||||
setCurrentProvider(selection.provider)
|
||||
updateModelOptionsCache(selection.provider, selection.model, selection.persistGlobal || !activeSessionId)
|
||||
updateModelOptionsCache(selection.provider, selection.model, includeGlobal)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (activeSessionId) {
|
||||
await requestGateway('slash.exec', {
|
||||
session_id: activeSessionId,
|
||||
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
|
||||
})
|
||||
try {
|
||||
if (activeSessionId) {
|
||||
await requestGateway('slash.exec', {
|
||||
session_id: activeSessionId,
|
||||
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
|
||||
})
|
||||
|
||||
if (selection.persistGlobal) {
|
||||
void refreshCurrentModel()
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
|
||||
})
|
||||
|
||||
return
|
||||
if (selection.persistGlobal) {
|
||||
void refreshCurrentModel()
|
||||
}
|
||||
|
||||
await setGlobalModel(selection.provider, selection.model)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Model switch failed')
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
})()
|
||||
|
||||
await setGlobalModel(selection.provider, selection.model)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
setCurrentModel(prevModel)
|
||||
setCurrentProvider(prevProvider)
|
||||
updateModelOptionsCache(prevProvider, prevModel, includeGlobal)
|
||||
notifyError(err, 'Model switch failed')
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
[activeSessionId, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
|
||||
)
|
||||
|
||||
@@ -65,7 +65,7 @@ interface PromptActionsOptions {
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
busyRef: MutableRefObject<boolean>
|
||||
branchCurrentSession: () => Promise<boolean>
|
||||
createBackendSessionForSend: () => Promise<string | null>
|
||||
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
|
||||
handleSkinCommand: (arg: string) => string
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||
@@ -296,7 +296,7 @@ export function usePromptActions({
|
||||
|
||||
if (!sessionId) {
|
||||
try {
|
||||
sessionId = await createBackendSessionForSend()
|
||||
sessionId = await createBackendSessionForSend(visibleText)
|
||||
} catch (err) {
|
||||
dropOptimistic(null)
|
||||
releaseBusy()
|
||||
|
||||
@@ -303,7 +303,7 @@ export function useSessionActions({
|
||||
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
|
||||
)
|
||||
|
||||
const createBackendSessionForSend = useCallback(async (): Promise<string | null> => {
|
||||
const createBackendSessionForSend = useCallback(async (preview: string | null = null): Promise<string | null> => {
|
||||
const startingActiveSessionId = activeSessionIdRef.current
|
||||
const startingStoredSessionId = selectedStoredSessionIdRef.current
|
||||
const startingRouteToken = getRouteToken()
|
||||
@@ -330,7 +330,11 @@ export function useSessionActions({
|
||||
ensureSessionState(created.session_id, stored)
|
||||
|
||||
if (stored) {
|
||||
upsertOptimisticSession(created, stored)
|
||||
// Seed the sidebar preview with the user's first message so the row
|
||||
// reads meaningfully while the turn is in flight, instead of flashing
|
||||
// "Untitled session" until the turn persists and auto-title runs. The
|
||||
// server later returns its own preview/title and supersedes this.
|
||||
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
|
||||
navigate(sessionRoute(stored), { replace: true })
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,19 @@ export function useSessionStateCache({
|
||||
|
||||
const syncSessionStateToView = useCallback(
|
||||
(sessionId: string, state: ClientSessionState) => {
|
||||
// Only the currently-viewed session may stage into the shared `$messages`
|
||||
// view. A background session (e.g. one still busy and emitting stream /
|
||||
// error updates after the user toggled away) must update its own cache
|
||||
// entry but never the view — otherwise its messages clobber the
|
||||
// foreground transcript and appear to "bleed" into every other session.
|
||||
// The flush below also re-checks the active id, but staging here is what
|
||||
// prevents a background write from overwriting an already-pending
|
||||
// foreground write within the same animation frame (only one RAF is
|
||||
// scheduled, so the last `pendingViewStateRef` writer would otherwise win).
|
||||
if (sessionId !== activeSessionIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingViewStateRef.current = { sessionId, state }
|
||||
|
||||
if (viewSyncRafRef.current !== null) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
||||
import { Activity, AlertCircle, Clock, Command, Cpu, Hash, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { Activity, AlertCircle, ChevronDown, Clock, Command, Hash, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -11,8 +13,10 @@ import { $desktopActionTasks } from '@/store/activity'
|
||||
import { $previewServerRestartStatus } from '@/store/preview'
|
||||
import {
|
||||
$busy,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
$currentUsage,
|
||||
$sessionStartedAt,
|
||||
$turnStartedAt,
|
||||
@@ -34,6 +38,7 @@ interface StatusbarItemsOptions {
|
||||
gatewayLogLines: readonly string[]
|
||||
gatewayState: string
|
||||
inferenceStatus: RuntimeReadinessResult | null
|
||||
modelMenuContent?: ReactNode
|
||||
openAgents: () => void
|
||||
openCommandCenterSection: (section: CommandCenterSection) => void
|
||||
statusSnapshot: StatusResponse | null
|
||||
@@ -48,14 +53,17 @@ export function useStatusbarItems({
|
||||
gatewayLogLines,
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
modelMenuContent,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
statusSnapshot,
|
||||
toggleCommandCenter
|
||||
}: StatusbarItemsOptions) {
|
||||
const busy = useStore($busy)
|
||||
const currentFastMode = useStore($currentFastMode)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
||||
const currentUsage = useStore($currentUsage)
|
||||
const desktopActionTasks = useStore($desktopActionTasks)
|
||||
const previewServerRestartStatus = useStore($previewServerRestartStatus)
|
||||
@@ -269,17 +277,51 @@ export function useStatusbarItems({
|
||||
variant: 'text'
|
||||
},
|
||||
{
|
||||
detail: currentProvider || '',
|
||||
icon: <Cpu className="size-3" />,
|
||||
id: 'model-summary',
|
||||
label: currentModel || 'No model selected',
|
||||
onSelect: () => setModelPickerOpen(true),
|
||||
title: currentProvider ? `Switch model · ${currentProvider}: ${currentModel || ''}` : 'Open model picker',
|
||||
variant: 'action'
|
||||
label: (
|
||||
<span className="inline-flex min-w-0 items-center gap-0.5">
|
||||
<span className="truncate">
|
||||
{formatModelStatusLabel(currentModel, {
|
||||
fastMode: currentFastMode,
|
||||
reasoningEffort: currentReasoningEffort
|
||||
})}
|
||||
</span>
|
||||
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
|
||||
</span>
|
||||
),
|
||||
...(modelMenuContent
|
||||
? {
|
||||
menuAlign: 'end' as const,
|
||||
menuClassName: 'w-64',
|
||||
menuContent: modelMenuContent,
|
||||
title: currentProvider
|
||||
? `Model · ${currentProvider}: ${currentModel || 'none'}`
|
||||
: 'Switch model',
|
||||
variant: 'menu' as const
|
||||
}
|
||||
: {
|
||||
onSelect: () => setModelPickerOpen(true),
|
||||
title: currentProvider
|
||||
? `${currentProvider} · ${currentModel || 'no model'}`
|
||||
: 'Open model picker',
|
||||
variant: 'action' as const
|
||||
})
|
||||
},
|
||||
versionItem
|
||||
],
|
||||
[busy, contextBar, contextUsage, currentModel, currentProvider, sessionStartedAt, turnStartedAt, versionItem]
|
||||
[
|
||||
busy,
|
||||
contextBar,
|
||||
contextUsage,
|
||||
currentFastMode,
|
||||
currentModel,
|
||||
currentProvider,
|
||||
currentReasoningEffort,
|
||||
modelMenuContent,
|
||||
sessionStartedAt,
|
||||
turnStartedAt,
|
||||
versionItem
|
||||
]
|
||||
)
|
||||
|
||||
const leftStatusbarItems = useMemo(
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
dropdownMenuRow,
|
||||
dropdownMenuSectionLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSubContent
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentReasoningEffort,
|
||||
setCurrentFastMode,
|
||||
setCurrentReasoningEffort
|
||||
} from '@/store/session'
|
||||
|
||||
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
|
||||
// by the Thinking toggle, not the radio.
|
||||
const EFFORT_OPTIONS = [
|
||||
{ value: 'minimal', label: 'Minimal' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'xhigh', label: 'Max' }
|
||||
] as const
|
||||
|
||||
/** How "fast" is achieved for a given model — two different mechanisms:
|
||||
* - `param`: the Anthropic/OpenAI `speed=fast` request parameter.
|
||||
* - `variant`: a separate `…-fast` sibling model selected via the model field.
|
||||
*/
|
||||
export type FastControl =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'param'; on: boolean }
|
||||
| { kind: 'variant'; baseId: string; fastId: string; on: boolean }
|
||||
|
||||
/** Resolve the fast mechanism for a model: prefer the speed=fast parameter
|
||||
* when the backend supports it, else fall back to a `…-fast` sibling model. */
|
||||
export function resolveFastControl(
|
||||
model: string,
|
||||
providerModels: readonly string[],
|
||||
paramSupported: boolean,
|
||||
currentFastMode: boolean
|
||||
): FastControl {
|
||||
if (paramSupported) {
|
||||
return { kind: 'param', on: currentFastMode }
|
||||
}
|
||||
|
||||
if (/-fast$/i.test(model)) {
|
||||
const baseId = model.replace(/-fast$/i, '')
|
||||
|
||||
// Only a toggle if there's a base to switch back to; otherwise it's a
|
||||
// standalone fast model with no "off" state.
|
||||
return providerModels.includes(baseId)
|
||||
? { kind: 'variant', baseId, fastId: model, on: true }
|
||||
: { kind: 'none' }
|
||||
}
|
||||
|
||||
const fastId = `${model}-fast`
|
||||
|
||||
if (providerModels.includes(fastId)) {
|
||||
return { kind: 'variant', baseId: model, fastId, on: false }
|
||||
}
|
||||
|
||||
// Fast isn't natively offered here, but if the session still has the speed
|
||||
// param on (carried over from a previous model), expose the toggle so it can
|
||||
// be turned off rather than stranded.
|
||||
if (currentFastMode) {
|
||||
return { kind: 'param', on: true }
|
||||
}
|
||||
|
||||
return { kind: 'none' }
|
||||
}
|
||||
|
||||
interface ModelEditSubmenuProps {
|
||||
/** How fast mode is offered for this model (param toggle vs. variant swap). */
|
||||
fastControl: FastControl
|
||||
/** Whether this row's model is the active one. */
|
||||
isActive: boolean
|
||||
/** Switch to this model (resolves false on failure). Awaited before applying
|
||||
* edits when not active so a failed switch doesn't write to the old model. */
|
||||
onActivate: () => Promise<boolean> | void
|
||||
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
|
||||
onSelectModel: (model: string) => Promise<boolean> | void
|
||||
/** Whether this model supports reasoning effort. */
|
||||
reasoning: boolean
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function ModelEditSubmenu({
|
||||
fastControl,
|
||||
isActive,
|
||||
onActivate,
|
||||
onSelectModel,
|
||||
reasoning,
|
||||
requestGateway
|
||||
}: ModelEditSubmenuProps) {
|
||||
// Reactive session state comes straight from the stores rather than being
|
||||
// drilled through the panel, so editing it re-renders only this submenu.
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
||||
|
||||
const effort = normalizeEffort(currentReasoningEffort)
|
||||
const thinkingOn = isThinkingEnabled(currentReasoningEffort)
|
||||
|
||||
// Reasoning/fast are session-scoped (they apply to the active model), so
|
||||
// editing a non-active model first switches to it. Returns false if the
|
||||
// switch failed, so callers skip applying to the wrong (previous) model.
|
||||
const ensureActive = async (): Promise<boolean> => {
|
||||
if (isActive) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (await onActivate()) !== false
|
||||
}
|
||||
|
||||
const patchReasoning = async (next: string, rollback: string) => {
|
||||
setCurrentReasoningEffort(next)
|
||||
|
||||
try {
|
||||
if (!(await ensureActive())) {
|
||||
setCurrentReasoningEffort(rollback)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await requestGateway('config.set', {
|
||||
key: 'reasoning',
|
||||
session_id: activeSessionId ?? '',
|
||||
value: next
|
||||
})
|
||||
} catch (err) {
|
||||
setCurrentReasoningEffort(rollback)
|
||||
notifyError(err, 'Model option update failed')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFast = (enabled: boolean) => {
|
||||
if (fastControl.kind === 'variant') {
|
||||
// Fast is a separate model id — swap to it (or back to the base).
|
||||
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (fastControl.kind === 'param') {
|
||||
setCurrentFastMode(enabled)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (!(await ensureActive())) {
|
||||
setCurrentFastMode(!enabled)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await requestGateway('config.set', {
|
||||
key: 'fast',
|
||||
session_id: activeSessionId ?? '',
|
||||
value: enabled ? 'fast' : 'normal'
|
||||
})
|
||||
} catch (err) {
|
||||
setCurrentFastMode(!enabled)
|
||||
notifyError(err, 'Fast mode update failed')
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
const hasFast = fastControl.kind !== 'none'
|
||||
const fastOn = fastControl.kind === 'none' ? false : fastControl.on
|
||||
|
||||
return (
|
||||
<DropdownMenuSubContent className="w-52 p-0" sideOffset={4}>
|
||||
{!hasFast && !reasoning ? (
|
||||
<div className="px-2.5 py-3 text-xs text-(--ui-text-tertiary)">No options for this model</div>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Options</DropdownMenuLabel>
|
||||
{reasoning ? (
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
Thinking
|
||||
<Switch
|
||||
checked={thinkingOn}
|
||||
className="ml-auto cursor-pointer"
|
||||
onCheckedChange={checked => void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{hasFast ? (
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
Fast
|
||||
<Switch checked={fastOn} className="ml-auto cursor-pointer" onCheckedChange={toggleFast} />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{reasoning ? (
|
||||
<>
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Effort</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
|
||||
value={effort}
|
||||
>
|
||||
{EFFORT_OPTIONS.map(option => (
|
||||
<DropdownMenuRadioItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
key={option.value}
|
||||
onSelect={event => event.preventDefault()}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
)
|
||||
}
|
||||
|
||||
function isThinkingEnabled(effort: string): boolean {
|
||||
// Empty = Hermes default (medium) = on; only an explicit "none" is off.
|
||||
return (effort || 'medium').trim().toLowerCase() !== 'none'
|
||||
}
|
||||
|
||||
function normalizeEffort(effort: string): string {
|
||||
const value = (effort || 'medium').trim().toLowerCase()
|
||||
|
||||
// Thinking off → no effort selected in the radio group.
|
||||
if (value === 'none') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return EFFORT_OPTIONS.some(option => option.value === value) ? value : 'medium'
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
dropdownMenuRow,
|
||||
DropdownMenuSearch,
|
||||
dropdownMenuSectionLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$visibleModels,
|
||||
collapseModelFamilies,
|
||||
DEFAULT_VISIBLE_PER_PROVIDER,
|
||||
type ModelFamily,
|
||||
modelVisibilityKey,
|
||||
setModelVisibilityOpen
|
||||
} from '@/store/model-visibility'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort
|
||||
} from '@/store/session'
|
||||
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
|
||||
|
||||
interface ModelMenuPanelProps {
|
||||
gateway?: HermesGateway
|
||||
onSelectModel: (selection: { model: string; persistGlobal: boolean; provider: string }) => Promise<boolean> | void
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
interface ProviderGroup {
|
||||
families: ModelFamily[]
|
||||
provider: ModelOptionProvider
|
||||
}
|
||||
|
||||
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
// Reactive session state is read from the stores here (not drilled in), so
|
||||
// toggling effort/fast/model re-renders this panel in place without forcing
|
||||
// the parent to rebuild the menu content (which would close the dropdown).
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const currentFastMode = useStore($currentFastMode)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
||||
const visibleModels = useStore($visibleModels)
|
||||
|
||||
const modelOptions = useQuery({
|
||||
queryKey: ['model-options', activeSessionId || 'global'],
|
||||
queryFn: (): Promise<ModelOptionsResponse> => {
|
||||
if (gateway && activeSessionId) {
|
||||
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
|
||||
}
|
||||
|
||||
return getGlobalModelOptions()
|
||||
}
|
||||
})
|
||||
|
||||
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
|
||||
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
|
||||
const loading = modelOptions.isPending && !modelOptions.data
|
||||
|
||||
const error = modelOptions.error
|
||||
? modelOptions.error instanceof Error
|
||||
? modelOptions.error.message
|
||||
: String(modelOptions.error)
|
||||
: null
|
||||
|
||||
const providers = modelOptions.data?.providers
|
||||
|
||||
const switchTo = (model: string, provider: string) =>
|
||||
onSelectModel({ model, persistGlobal: !activeSessionId, provider })
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, visibleModels),
|
||||
[providers, search, optionsModel, optionsProvider, visibleModels]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSearch
|
||||
aria-label="Search models"
|
||||
onValueChange={setSearch}
|
||||
placeholder="Search models"
|
||||
value={search}
|
||||
/>
|
||||
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
|
||||
{loading ? (
|
||||
<DropdownMenuGroup className="py-1">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<DropdownMenuItem
|
||||
className={dropdownMenuRow}
|
||||
disabled
|
||||
key={index}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
) : error ? (
|
||||
<DropdownMenuItem className={dropdownMenuRow} disabled>
|
||||
{error}
|
||||
</DropdownMenuItem>
|
||||
) : groups.length === 0 ? (
|
||||
<DropdownMenuItem className={dropdownMenuRow} disabled>
|
||||
No models found
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<div className="max-h-80 overflow-y-auto py-0.5">
|
||||
{groups.map(group => (
|
||||
<DropdownMenuGroup className="py-0.5" key={group.provider.slug}>
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{group.provider.name}</DropdownMenuLabel>
|
||||
{group.families.map(family => {
|
||||
// The active id may be the base or its -fast sibling; either
|
||||
// way this one family row represents both.
|
||||
const activeId =
|
||||
group.provider.slug === optionsProvider &&
|
||||
(optionsModel === family.id || optionsModel === family.fastId)
|
||||
? optionsModel
|
||||
: null
|
||||
|
||||
const isCurrent = activeId !== null
|
||||
const name = modelDisplayParts(family.id).name
|
||||
// Capabilities are looked up against the active/base id; the
|
||||
// -fast variant carries the same param support as its base.
|
||||
const caps = group.provider.capabilities?.[family.id]
|
||||
|
||||
// Single source of truth for the active row's fast state — keeps
|
||||
// the row label in lock-step with the submenu's Fast toggle and
|
||||
// handles the standalone `-fast` id case.
|
||||
const fastControl = resolveFastControl(
|
||||
activeId ?? family.id,
|
||||
group.provider.models ?? [],
|
||||
caps?.fast ?? false,
|
||||
currentFastMode
|
||||
)
|
||||
|
||||
// Grayed text: active row shows live state (Fast + effort);
|
||||
// others show a fast-capability hint.
|
||||
const meta = isCurrent
|
||||
? [fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null, reasoningEffortLabel(currentReasoningEffort) || 'Med']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: caps?.fast || family.fastId
|
||||
? 'Fast'
|
||||
: ''
|
||||
|
||||
// Every row is a hover-Edit submenu trigger. Activating it
|
||||
// (pointer or keyboard) switches to the family's base model;
|
||||
// the Fast toggle inside swaps to the -fast sibling (or flips
|
||||
// the speed param). The sub-trigger has no `onSelect`, so wire
|
||||
// both click and Enter/Space for keyboard parity.
|
||||
const activate = () => {
|
||||
if (!isCurrent) {
|
||||
void switchTo(family.id, group.provider.slug)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
|
||||
<DropdownMenuSubTrigger
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
hideChevron
|
||||
onClick={activate}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
activate()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{name}
|
||||
{meta ? <span className="text-(--ui-text-tertiary)"> {meta}</span> : null}
|
||||
</span>
|
||||
{isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
|
||||
</DropdownMenuSubTrigger>
|
||||
<ModelEditSubmenu
|
||||
fastControl={fastControl}
|
||||
isActive={isCurrent}
|
||||
onActivate={() => switchTo(family.id, group.provider.slug)}
|
||||
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
|
||||
reasoning={caps?.reasoning ?? true}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer text-(--ui-text-tertiary)')}
|
||||
onSelect={() => setModelVisibilityOpen(true)}
|
||||
>
|
||||
Edit Models…
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsed we show the user's chosen models (or the curated default); typing
|
||||
// spans every available model so anything is reachable past the cut.
|
||||
const PER_PROVIDER_SEARCH = 12
|
||||
|
||||
function groupModels(
|
||||
providers: ModelOptionProvider[],
|
||||
search: string,
|
||||
current: { model: string; provider: string },
|
||||
visible: Set<string> | null
|
||||
): ProviderGroup[] {
|
||||
const q = search.trim().toLowerCase()
|
||||
const groups: ProviderGroup[] = []
|
||||
|
||||
for (const provider of providers) {
|
||||
const allFamilies = collapseModelFamilies(provider.models ?? [])
|
||||
|
||||
if (allFamilies.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const matches = (family: ModelFamily) =>
|
||||
`${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}`
|
||||
.toLowerCase()
|
||||
.includes(q)
|
||||
|
||||
// Which model ids to show (the active one is always added on top of this).
|
||||
let shown: Set<string>
|
||||
|
||||
if (q) {
|
||||
// Search spans every family, regardless of visibility.
|
||||
shown = new Set(allFamilies.filter(matches).map(family => family.id))
|
||||
} else if (visible) {
|
||||
// User has customized which models show — honor their selection exactly.
|
||||
shown = new Set(
|
||||
allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id)
|
||||
)
|
||||
} else {
|
||||
// Default: curated top-N families per provider.
|
||||
shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id))
|
||||
}
|
||||
|
||||
// Always include the active model — but keep every row in the provider's
|
||||
// stable curated order (filter `allFamilies`, never reorder), so selecting
|
||||
// a model can't shuffle the list.
|
||||
const activeId =
|
||||
provider.slug === current.provider && current.model
|
||||
? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id
|
||||
: undefined
|
||||
|
||||
let families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId)
|
||||
|
||||
if (q) {
|
||||
families = families.slice(0, PER_PROVIDER_SEARCH)
|
||||
}
|
||||
|
||||
if (families.length > 0) {
|
||||
groups.push({ families, provider })
|
||||
}
|
||||
}
|
||||
|
||||
// Stable, logical group order: alphabetical by provider name. (The backend
|
||||
// floats the current provider first, which would reshuffle on every switch.)
|
||||
groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name))
|
||||
|
||||
return groups
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export interface StatusbarItem {
|
||||
disabled?: boolean
|
||||
hidden?: boolean
|
||||
href?: string
|
||||
menuAlign?: 'center' | 'end' | 'start'
|
||||
menuClassName?: string
|
||||
menuContent?: ReactNode
|
||||
menuItems?: readonly StatusbarMenuItem[]
|
||||
@@ -104,7 +105,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
align={item.menuAlign ?? 'start'}
|
||||
className={cn('w-56', item.menuContent && 'p-0', item.menuClassName)}
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
|
||||
@@ -637,7 +638,7 @@ function messageAttachmentRefs(value: unknown): string[] {
|
||||
function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="group/user-message sticky top-0 z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
|
||||
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
@@ -685,6 +686,32 @@ const UserMessage: FC<{
|
||||
return messageAttachmentRefs(custom.attachmentRefs)
|
||||
})
|
||||
|
||||
// Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt
|
||||
// doesn't dominate the viewport while the response streams underneath; the
|
||||
// clamp lifts on hover / focus (see styles.css). We measure the *unclamped*
|
||||
// inner wrapper so the ResizeObserver only fires on real content / width
|
||||
// changes, not on every frame while the outer max-height animates open.
|
||||
const clampInnerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [bodyClamped, setBodyClamped] = useState(false)
|
||||
|
||||
const measureClamp = useCallback(() => {
|
||||
const inner = clampInnerRef.current
|
||||
const outer = inner?.parentElement
|
||||
|
||||
if (!inner || !outer) {
|
||||
return
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(inner)
|
||||
const lineHeight = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
|
||||
const fullHeight = inner.scrollHeight
|
||||
|
||||
outer.style.setProperty('--human-msg-full', `${fullHeight}px`)
|
||||
setBodyClamped(fullHeight > lineHeight * 2 + 1)
|
||||
}, [])
|
||||
|
||||
useResizeObserver(measureClamp, clampInnerRef)
|
||||
|
||||
const hasBody = messageText.trim().length > 0
|
||||
const isLatestUser = messageId === latestUserId
|
||||
const showStop = isLatestUser && threadRunning && Boolean(onCancel)
|
||||
@@ -707,7 +734,11 @@ const UserMessage: FC<{
|
||||
// Render the user's text through a minimal markdown pipeline:
|
||||
// backtick `code` and ``` fenced ``` blocks, with directive chips
|
||||
// (`@file:` etc.) still resolved inside the plain-text spans.
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
|
||||
<div ref={clampInnerRef}>
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
@@ -842,6 +873,10 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
// See index.tsx: set in keydown when the open popover consumes a nav/control
|
||||
// key so the matching keyup skips refreshTrigger (timing-immune vs reading
|
||||
// `trigger`, which keyup sees as already-null after Escape).
|
||||
const triggerKeyConsumedRef = useRef(false)
|
||||
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
@@ -966,8 +1001,15 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
}
|
||||
|
||||
setTrigger(detected)
|
||||
setTriggerActive(0)
|
||||
}, [])
|
||||
|
||||
// Only reset the highlight when the trigger actually changed (opened, or
|
||||
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
|
||||
// caret move (mouseup) or a stray refresh — must preserve the user's
|
||||
// current selection instead of snapping back to the first item.
|
||||
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
||||
setTriggerActive(0)
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
const closeTrigger = useCallback(() => {
|
||||
setTrigger(null)
|
||||
@@ -1200,6 +1242,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
||||
|
||||
return
|
||||
@@ -1207,6 +1250,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
||||
|
||||
return
|
||||
@@ -1214,6 +1258,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
const item = triggerItems[triggerActive]
|
||||
|
||||
if (item) {
|
||||
@@ -1225,6 +1270,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
@@ -1244,6 +1290,22 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = () => {
|
||||
// If this keyup belongs to a key the open trigger popover already consumed
|
||||
// in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
|
||||
// edit text, and for Escape the keydown already closed the menu — a refresh
|
||||
// here would re-detect the still-present `/` and instantly reopen it. We
|
||||
// read a ref set during keydown rather than `trigger`, because by keyup
|
||||
// time React has re-rendered and `trigger` may already be null.
|
||||
if (triggerKeyConsumedRef.current) {
|
||||
triggerKeyConsumedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="contents" data-slot="aui_edit-composer-root">
|
||||
<StickyHumanMessageContainer>
|
||||
@@ -1294,7 +1356,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
onFocus={() => markActiveComposer('edit')}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={() => window.setTimeout(refreshTrigger, 0)}
|
||||
onKeyUp={handleKeyUp}
|
||||
onMouseUp={refreshTrigger}
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
|
||||
@@ -107,8 +107,9 @@ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
|
||||
anthropic: { order: 1, title: 'Anthropic Claude' },
|
||||
'openai-codex': { order: 2, title: 'OpenAI Codex / ChatGPT' },
|
||||
'minimax-oauth': { order: 3, title: 'MiniMax' },
|
||||
'claude-code': { order: 4, title: 'Claude Code' },
|
||||
'qwen-oauth': { order: 5, title: 'Qwen Code' }
|
||||
'xai-oauth': { order: 4, title: 'xAI Grok' },
|
||||
'claude-code': { order: 5, title: 'Claude Code' },
|
||||
'qwen-oauth': { order: 6, title: 'Qwen Code' }
|
||||
}
|
||||
|
||||
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
|
||||
@@ -116,6 +117,7 @@ const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/
|
||||
const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
|
||||
pkce: 'Opens your browser to sign in, then continues here',
|
||||
device_code: 'Opens a verification page in your browser — Hermes connects automatically',
|
||||
loopback: 'Opens your browser to sign in — Hermes connects automatically',
|
||||
external: 'Sign in once in your terminal, then come back to chat'
|
||||
}
|
||||
|
||||
@@ -565,6 +567,24 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
|
||||
)
|
||||
}
|
||||
|
||||
if (flow.status === 'awaiting_browser') {
|
||||
return (
|
||||
<Step title={`Sign in with ${title}`}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We opened {title} in your browser. Authorize Hermes there and you'll be connected
|
||||
automatically — nothing to copy or paste.
|
||||
</p>
|
||||
<FlowFooter left={<DocsLink href={flow.start.auth_url}>Re-open sign-in page</DocsLink>}>
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Waiting for you to authorize...
|
||||
</span>
|
||||
<CancelBtn size="sm" />
|
||||
</FlowFooter>
|
||||
</Step>
|
||||
)
|
||||
}
|
||||
|
||||
if (flow.status === 'external_pending') {
|
||||
return (
|
||||
<Step title={`Sign in with ${title}`}>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
|
||||
import {
|
||||
$visibleModels,
|
||||
collapseModelFamilies,
|
||||
effectiveVisibleKeys,
|
||||
modelVisibilityKey,
|
||||
setVisibleModels
|
||||
} from '@/store/model-visibility'
|
||||
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
interface ModelVisibilityDialogProps {
|
||||
gw?: HermesGateway
|
||||
onOpenChange: (open: boolean) => void
|
||||
onOpenProviders: () => void
|
||||
open: boolean
|
||||
sessionId?: string | null
|
||||
}
|
||||
|
||||
export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open, sessionId }: ModelVisibilityDialogProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const stored = useStore($visibleModels)
|
||||
|
||||
const modelOptions = useQuery({
|
||||
queryKey: ['model-options', sessionId || 'global'],
|
||||
queryFn: (): Promise<ModelOptionsResponse> => {
|
||||
if (gw && sessionId) {
|
||||
return gw.request<ModelOptionsResponse>('model.options', { session_id: sessionId })
|
||||
}
|
||||
|
||||
return getGlobalModelOptions()
|
||||
},
|
||||
enabled: open
|
||||
})
|
||||
|
||||
const providers = useMemo(
|
||||
() => (modelOptions.data?.providers ?? []).filter(provider => (provider.models ?? []).length > 0),
|
||||
[modelOptions.data]
|
||||
)
|
||||
|
||||
const visible = effectiveVisibleKeys(stored, providers)
|
||||
|
||||
const toggle = (provider: ModelOptionProvider, model: string) => {
|
||||
const next = new Set(effectiveVisibleKeys($visibleModels.get(), providers))
|
||||
const key = modelVisibilityKey(provider.slug, model)
|
||||
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
|
||||
setVisibleModels(next)
|
||||
}
|
||||
|
||||
const q = search.trim().toLowerCase()
|
||||
|
||||
const matches = (provider: ModelOptionProvider, model: string) =>
|
||||
!q || `${model} ${provider.name} ${provider.slug} ${displayModelName(model)}`.toLowerCase().includes(q)
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-xs gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="px-3 pb-1 pt-3">
|
||||
<DialogTitle className="text-[0.8125rem]">Models</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-3 py-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
className="h-5 w-full bg-transparent text-xs text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
|
||||
onChange={event => setSearch(event.target.value)}
|
||||
placeholder="Search models"
|
||||
type="text"
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[55vh] overflow-y-auto pb-1">
|
||||
{providers.length === 0 ? (
|
||||
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
|
||||
{modelOptions.isPending ? 'Loading…' : 'No authenticated providers.'}
|
||||
</div>
|
||||
) : (
|
||||
providers.map(provider => {
|
||||
const models = collapseModelFamilies(provider.models ?? []).filter(family =>
|
||||
matches(provider, family.id)
|
||||
)
|
||||
|
||||
if (models.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-0.5" key={provider.slug}>
|
||||
<div className="px-3 pb-0.5 pt-1 text-[0.625rem] font-medium uppercase tracking-wide text-(--ui-text-tertiary)">
|
||||
{provider.name}
|
||||
</div>
|
||||
{models.map(family => {
|
||||
const { name, tag } = modelDisplayParts(family.id)
|
||||
const key = modelVisibilityKey(provider.slug, family.id)
|
||||
|
||||
return (
|
||||
<label
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs hover:bg-accent/50"
|
||||
key={key}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{name}
|
||||
{tag ? <span className="text-(--ui-text-tertiary)"> {tag}</span> : null}
|
||||
</span>
|
||||
<Switch
|
||||
checked={visible.has(key)}
|
||||
className="cursor-pointer"
|
||||
onCheckedChange={() => toggle(provider, family.id)}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<button
|
||||
className="text-xs text-(--ui-text-tertiary) transition-colors hover:text-foreground"
|
||||
onClick={() => {
|
||||
onOpenChange(false)
|
||||
onOpenProviders()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Add provider…
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,17 @@ import * as React from 'react'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Shared class tokens for edge-to-edge menus (use with `p-0` content): rows go
|
||||
// full-width, square, and compact so the highlight spans the whole surface.
|
||||
// Reuse these instead of re-deriving per menu so every searchable/compact menu
|
||||
// reads identically.
|
||||
export const dropdownMenuRow = 'gap-2 rounded-none px-2.5 py-1 text-xs'
|
||||
export const dropdownMenuSectionLabel = 'px-2.5 pt-1 pb-0.5 text-[0.625rem] font-medium uppercase tracking-wide'
|
||||
|
||||
// Keys that must reach Radix's menu handler (navigation/close). Everything else
|
||||
// is a filter keystroke and is stopped so the menu's typeahead doesn't hijack it.
|
||||
const DROPDOWN_NAV_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Enter', 'Escape', 'Tab'])
|
||||
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
@@ -16,8 +27,49 @@ function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownM
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Borderless filter input for a searchable dropdown. Autofocuses, keeps the
|
||||
* menu's typeahead from eating keystrokes, and still lets arrow/enter/escape
|
||||
* drive the list. Drop it in as the first child of a `DropdownMenuContent`.
|
||||
*/
|
||||
function DropdownMenuSearch({
|
||||
className,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onValueChange,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<'input'>, 'type'> & {
|
||||
onValueChange?: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="px-2.5 py-1.5" data-slot="dropdown-menu-search">
|
||||
<input
|
||||
autoFocus
|
||||
className={cn(
|
||||
'h-4 w-full bg-transparent text-xs leading-none text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none',
|
||||
className
|
||||
)}
|
||||
onChange={event => {
|
||||
onChange?.(event)
|
||||
onValueChange?.(event.target.value)
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (!DROPDOWN_NAV_KEYS.has(event.key)) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
onKeyDown?.(event)
|
||||
}}
|
||||
type="text"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
collisionPadding = 8,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
@@ -31,6 +83,9 @@ function DropdownMenuContent({
|
||||
'dt-portal-scrollbar z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
// Keep the menu inside the viewport: Radix flips/shifts away from edges
|
||||
// (avoidCollisions defaults on); the padding stops it kissing the edge.
|
||||
collisionPadding={collisionPadding}
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
@@ -76,18 +131,16 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
checked={checked}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Codicon name="check" size="1rem" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
<DropdownMenuPrimitive.ItemIndicator className="ml-auto flex items-center pl-2 text-foreground">
|
||||
<Codicon name="check" size="0.75rem" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
@@ -104,18 +157,16 @@ function DropdownMenuRadioItem({
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Codicon name="primitive-dot" size="0.5rem" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
<DropdownMenuPrimitive.ItemIndicator className="ml-auto flex items-center pl-2 text-foreground">
|
||||
<Codicon name="check" size="0.75rem" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
@@ -164,10 +215,13 @@ function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuP
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
hideChevron = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
/** Suppress the trailing caret — for triggers that own their right-side affordance. */
|
||||
hideChevron?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
@@ -180,29 +234,40 @@ function DropdownMenuSubTrigger({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Codicon className="ml-auto text-(--ui-text-tertiary)" name="chevron-right" size="1rem" />
|
||||
{!hideChevron && <Codicon className="ml-auto text-(--ui-text-tertiary)" name="chevron-right" size="1rem" />}
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
collisionPadding = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
// SubContent inherits the same portal/scrollbar issue as Content (Radix
|
||||
// renders it under document.body), so apply `dt-portal-scrollbar`. Use
|
||||
// a fixed `max-h-80` rather than the Radix available-height variable:
|
||||
// that variable is only published on Content, NOT SubContent — using
|
||||
// it here collapses the submenu to 0px height.
|
||||
className={cn(
|
||||
'dt-portal-scrollbar z-50 max-h-80 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
{...props}
|
||||
/>
|
||||
// Portal the submenu out of the parent Content so it escapes that Content's
|
||||
// `overflow` clip. Without this, a submenu opening from a scrollable menu
|
||||
// gets visually cut off at the parent's edges. Radix Popper still anchors
|
||||
// it to the SubTrigger and handles collision/flip, so portaling is safe.
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
// `dt-portal-scrollbar` reproduces the themed scrollbar for portaled
|
||||
// overlays (rendered under document.body). Use a fixed `max-h-80`
|
||||
// rather than the Radix available-height variable: that variable is
|
||||
// only published on Content, NOT SubContent — using it here collapses
|
||||
// the submenu to 0px height.
|
||||
className={cn(
|
||||
'dt-portal-scrollbar z-50 max-h-80 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
// Flip to the other side / shift vertically when near a viewport edge
|
||||
// (e.g. the status bar menu opening from the bottom-right corner) so
|
||||
// the submenu never gets clipped.
|
||||
collisionPadding={collisionPadding}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -216,6 +281,7 @@ export {
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSearch,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
|
||||
|
||||
describe('model-status-label', () => {
|
||||
it('formats display names consistently', () => {
|
||||
expect(displayModelName('anthropic/claude-opus-4.8-fast')).toBe('Opus 4.8')
|
||||
expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5')
|
||||
})
|
||||
|
||||
it('maps reasoning effort to compact labels', () => {
|
||||
expect(reasoningEffortLabel('high')).toBe('High')
|
||||
expect(reasoningEffortLabel('xhigh')).toBe('Max')
|
||||
expect(reasoningEffortLabel('')).toBe('')
|
||||
})
|
||||
|
||||
it('appends fast + effort session state to the status label', () => {
|
||||
expect(formatModelStatusLabel('openai/gpt-5.5', { fastMode: true, reasoningEffort: 'high' })).toBe(
|
||||
'GPT-5.5 · Fast High'
|
||||
)
|
||||
})
|
||||
|
||||
it('always surfaces the effort (default medium) so the level is visible', () => {
|
||||
expect(formatModelStatusLabel('openai/gpt-5.5', { reasoningEffort: 'medium' })).toBe('GPT-5.5 · Med')
|
||||
expect(formatModelStatusLabel('openai/gpt-5.5')).toBe('GPT-5.5 · Med')
|
||||
})
|
||||
|
||||
it('returns just the placeholder name when there is no model', () => {
|
||||
expect(formatModelStatusLabel('')).toBe('No model')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
const REASONING_LABELS: Record<string, string> = {
|
||||
none: 'Off',
|
||||
minimal: 'Min',
|
||||
low: 'Low',
|
||||
medium: 'Med',
|
||||
high: 'High',
|
||||
xhigh: 'Max'
|
||||
}
|
||||
|
||||
export function reasoningEffortLabel(effort: string): string {
|
||||
const key = effort.trim().toLowerCase()
|
||||
|
||||
if (!key) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return REASONING_LABELS[key] ?? effort
|
||||
}
|
||||
|
||||
/** Strip provider prefix and normalize for display. */
|
||||
export function modelBaseId(model: string): string {
|
||||
const trimmed = model.trim()
|
||||
const slash = trimmed.lastIndexOf('/')
|
||||
|
||||
return slash >= 0 ? trimmed.slice(slash + 1) : trimmed
|
||||
}
|
||||
|
||||
// Trailing model-id variants that should render as a grayed tag beside the
|
||||
// name (e.g. "Opus 4.8" + "Fast") rather than collapsing two distinct ids to
|
||||
// the same display name.
|
||||
const VARIANT_TAGS: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
[/-fast$/i, 'Fast'],
|
||||
[/-thinking$/i, 'Thinking'],
|
||||
[/-preview$/i, 'Preview'],
|
||||
[/-latest$/i, 'Latest']
|
||||
]
|
||||
|
||||
const titleCase = (text: string): string => text.replace(/\b\w/g, char => char.toUpperCase()).trim()
|
||||
|
||||
function prettifyBase(base: string): string {
|
||||
if (/^claude-/i.test(base)) {
|
||||
return titleCase(base.replace(/^claude-/i, '').replace(/-/g, ' '))
|
||||
}
|
||||
|
||||
if (/^gpt-/i.test(base)) {
|
||||
return base.replace(/^gpt-/i, 'GPT-')
|
||||
}
|
||||
|
||||
if (/^gemini-/i.test(base)) {
|
||||
return base.replace(/^gemini-/i, 'Gemini ').replace(/-/g, ' ')
|
||||
}
|
||||
|
||||
return titleCase(base.replace(/-/g, ' '))
|
||||
}
|
||||
|
||||
/** Split a model id into a clean display name plus an optional grayed variant
|
||||
* tag, so distinct ids (e.g. `…-4.8` vs `…-4.8-fast`) don't collapse. */
|
||||
export function modelDisplayParts(model: string): { name: string; tag: string } {
|
||||
let base = modelBaseId(model)
|
||||
let tag = ''
|
||||
|
||||
for (const [pattern, label] of VARIANT_TAGS) {
|
||||
if (pattern.test(base)) {
|
||||
tag = label
|
||||
base = base.replace(pattern, '')
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { name: prettifyBase(base) || model.trim() || 'No model', tag }
|
||||
}
|
||||
|
||||
/** Friendly one-line model name for menus and the status bar. */
|
||||
export function displayModelName(model: string): string {
|
||||
return modelDisplayParts(model).name
|
||||
}
|
||||
|
||||
/** Status bar trigger label — model name plus the live session state (effort/fast). */
|
||||
export function formatModelStatusLabel(
|
||||
model: string,
|
||||
options?: { fastMode?: boolean; reasoningEffort?: string }
|
||||
): string {
|
||||
const name = displayModelName(model)
|
||||
|
||||
if (!model.trim()) {
|
||||
return name
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
// Fast is shown when the speed=fast param is on (options.fastMode) OR the
|
||||
// active model is a `…-fast` variant (fast via a separate model id).
|
||||
if (options?.fastMode || /-fast$/i.test(modelBaseId(model))) {
|
||||
parts.push('Fast')
|
||||
}
|
||||
|
||||
// Always surface the effort (empty = Hermes default of medium) so the
|
||||
// current reasoning level is visible at a glance, not just when non-default.
|
||||
parts.push(reasoningEffortLabel(options?.reasoningEffort ?? '') || 'Med')
|
||||
|
||||
return `${name} · ${parts.join(' ')}`
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
enqueueQueuedPrompt,
|
||||
getQueuedPrompts,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrainOnSettle,
|
||||
updateQueuedPrompt,
|
||||
updateQueuedPromptText
|
||||
} from './composer-queue'
|
||||
@@ -100,3 +101,37 @@ describe('composer queue store', () => {
|
||||
expect(parsed[SESSION_KEY]?.[0]?.text).toBe('persist me')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldAutoDrainOnSettle', () => {
|
||||
const base = { isBusy: false, queueLength: 1, userInterrupted: false, wasBusy: true }
|
||||
|
||||
it('drains the next queued prompt when a turn completes naturally', () => {
|
||||
expect(shouldAutoDrainOnSettle(base)).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT drain when the user explicitly interrupted (Stop button)', () => {
|
||||
// Regression: previously the Stop button "never worked" because cancelling
|
||||
// a turn flipped busy → false and the queue immediately re-fired its head.
|
||||
expect(shouldAutoDrainOnSettle({ ...base, userInterrupted: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not drain when the queue is empty', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0 })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not drain when interrupted even if the queue is also empty', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0, userInterrupted: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores steady busy state (no true → false transition)', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores busy entry (false → true, not a settle)', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true, wasBusy: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores steady idle state (was not busy)', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, wasBusy: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -188,3 +188,39 @@ export const clearQueuedPrompts = (key: string | null | undefined) => {
|
||||
|
||||
writeSession(sid, [])
|
||||
}
|
||||
|
||||
/** Inputs to {@link shouldAutoDrainOnSettle}, captured at a `busy` transition. */
|
||||
export interface AutoDrainSettleInput {
|
||||
wasBusy: boolean
|
||||
isBusy: boolean
|
||||
queueLength: number
|
||||
userInterrupted: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the composer should auto-drain the next queued prompt when a
|
||||
* turn settles (busy transitions true → false).
|
||||
*
|
||||
* The queue auto-advances when a turn *completes naturally*, but must NOT
|
||||
* advance when the user *explicitly interrupted* the turn via the Stop button.
|
||||
* Conflating the two made the Stop button appear to "never work": cancelling a
|
||||
* turn flipped busy → false, the queue immediately re-fired its head, and the
|
||||
* agent kept running. An explicit interrupt means stop — the queued turns are
|
||||
* preserved and the user resumes them deliberately (Cmd/Ctrl+K, Enter, or the
|
||||
* per-row "send now" arrow).
|
||||
*/
|
||||
export const shouldAutoDrainOnSettle = (params: AutoDrainSettleInput): boolean => {
|
||||
const { isBusy, queueLength, userInterrupted, wasBusy } = params
|
||||
|
||||
// Only react to a true → false transition; ignore steady state and entry.
|
||||
if (isBusy || !wasBusy) {
|
||||
return false
|
||||
}
|
||||
|
||||
// An explicit Stop suppresses exactly one auto-drain.
|
||||
if (userInterrupted) {
|
||||
return false
|
||||
}
|
||||
|
||||
return queueLength > 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import type { ModelOptionProvider } from '@/types/hermes'
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.visible-models'
|
||||
|
||||
/** Models shown per provider in the status-bar dropdown before the user has
|
||||
* customized the list. Backend `models` are already relevance-ordered. */
|
||||
export const DEFAULT_VISIBLE_PER_PROVIDER = 5
|
||||
|
||||
/** Stable key for a provider/model pair (`::` avoids colliding with model ids
|
||||
* that contain a single colon, e.g. `model:tag`). */
|
||||
export const modelVisibilityKey = (provider: string, model: string): string => `${provider}::${model}`
|
||||
|
||||
/** A model and its optional `…-fast` sibling, collapsed into one logical row.
|
||||
* `id` is the canonical (base) model; `fastId` is the fast variant if present. */
|
||||
export interface ModelFamily {
|
||||
fastId: string | null
|
||||
id: string
|
||||
}
|
||||
|
||||
/** Collapse a provider's model list so a base model and its `…-fast` variant
|
||||
* become a single family (one row, one toggle). Order is preserved by the
|
||||
* base model's position. A `…-fast` model with no base stands on its own. */
|
||||
export function collapseModelFamilies(models: readonly string[]): ModelFamily[] {
|
||||
const present = new Set(models)
|
||||
const families: ModelFamily[] = []
|
||||
const consumed = new Set<string>()
|
||||
|
||||
for (const model of models) {
|
||||
if (consumed.has(model)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (/-fast$/i.test(model) && present.has(model.replace(/-fast$/i, ''))) {
|
||||
// Represented by its base entry — the base attaches it as `fastId`.
|
||||
continue
|
||||
}
|
||||
|
||||
const fastId = `${model}-fast`
|
||||
const hasFast = present.has(fastId)
|
||||
families.push({ fastId: hasFast ? fastId : null, id: model })
|
||||
consumed.add(model)
|
||||
|
||||
if (hasFast) {
|
||||
consumed.add(fastId)
|
||||
}
|
||||
}
|
||||
|
||||
return families
|
||||
}
|
||||
|
||||
function loadVisible(): Set<string> | null {
|
||||
const raw = storedString(STORAGE_KEY)
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
return Array.isArray(parsed) ? new Set(parsed.filter((x): x is string => typeof x === 'string')) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicit set of visible `provider::model` keys, or null when the user
|
||||
* hasn't customized — in which case the curated default applies. */
|
||||
export const $visibleModels = atom<Set<string> | null>(loadVisible())
|
||||
|
||||
export const $modelVisibilityOpen = atom(false)
|
||||
|
||||
export function setVisibleModels(keys: Set<string>): void {
|
||||
$visibleModels.set(new Set(keys))
|
||||
persistString(STORAGE_KEY, JSON.stringify([...keys]))
|
||||
}
|
||||
|
||||
export function setModelVisibilityOpen(open: boolean): void {
|
||||
$modelVisibilityOpen.set(open)
|
||||
}
|
||||
|
||||
/** The default-visible key set: the curated top-N per provider. Used both as
|
||||
* the dropdown fallback and to seed the Edit Models dialog. */
|
||||
export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): Set<string> {
|
||||
const keys = new Set<string>()
|
||||
|
||||
for (const provider of providers) {
|
||||
const families = collapseModelFamilies(provider.models ?? [])
|
||||
|
||||
for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
|
||||
keys.add(modelVisibilityKey(provider.slug, family.id))
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Resolve which keys are currently visible: the user's explicit set when
|
||||
* configured, otherwise the curated default for the given providers. */
|
||||
export function effectiveVisibleKeys(
|
||||
stored: Set<string> | null,
|
||||
providers: readonly ModelOptionProvider[]
|
||||
): Set<string> {
|
||||
return stored ?? defaultVisibleKeys(providers)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/t
|
||||
|
||||
type PkceStart = Extract<OAuthStartResponse, { flow: 'pkce' }>
|
||||
type DeviceStart = Extract<OAuthStartResponse, { flow: 'device_code' }>
|
||||
type LoopbackStart = Extract<OAuthStartResponse, { flow: 'loopback' }>
|
||||
|
||||
export type OnboardingMode = 'apikey' | 'oauth'
|
||||
|
||||
@@ -26,6 +27,10 @@ export type OnboardingFlow =
|
||||
| { provider: OAuthProvider; status: 'starting' }
|
||||
| { code: string; provider: OAuthProvider; start: PkceStart; status: 'awaiting_user' }
|
||||
| { copied: boolean; provider: OAuthProvider; start: DeviceStart; status: 'polling' }
|
||||
// Loopback PKCE (xAI Grok): browser opens, the local backend's 127.0.0.1
|
||||
// listener catches the redirect, and we poll until the worker finishes.
|
||||
// No code to paste and no user_code to show — just a waiting state.
|
||||
| { provider: OAuthProvider; start: LoopbackStart; status: 'awaiting_browser' }
|
||||
| { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' }
|
||||
| { copied: boolean; provider: OAuthProvider; status: 'external_pending' }
|
||||
| { provider: OAuthProvider; status: 'success' }
|
||||
@@ -406,6 +411,26 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Open a sign-in URL via the desktop bridge, falling back to window.open
|
||||
// when the bridge isn't present (e.g. the web dashboard / dev preview) so
|
||||
// the flow never silently stalls in a waiting state. Mirrors the pattern in
|
||||
// apps/desktop/src/app/artifacts/index.tsx.
|
||||
async function openSignInUrl(url: string) {
|
||||
if (window.hermesDesktop?.openExternal) {
|
||||
try {
|
||||
await window.hermesDesktop.openExternal(url)
|
||||
|
||||
return
|
||||
} catch {
|
||||
// Bridge present but failed (no OS handler, user denied, etc.). Fall
|
||||
// through to window.open so the sign-in URL still opens and the flow
|
||||
// doesn't strand a pending OAuth session in a waiting state.
|
||||
}
|
||||
}
|
||||
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
export async function startProviderOAuth(provider: OAuthProvider, ctx: OnboardingContext) {
|
||||
clearPoll()
|
||||
|
||||
@@ -419,7 +444,8 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin
|
||||
|
||||
try {
|
||||
const start = await startOAuthLogin(provider.id)
|
||||
await window.hermesDesktop?.openExternal(start.flow === 'pkce' ? start.auth_url : start.verification_url)
|
||||
const browserUrl = start.flow === 'device_code' ? start.verification_url : start.auth_url
|
||||
await openSignInUrl(browserUrl)
|
||||
|
||||
if (start.flow === 'pkce') {
|
||||
setFlow({ status: 'awaiting_user', provider, start, code: '' })
|
||||
@@ -427,14 +453,26 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin
|
||||
return
|
||||
}
|
||||
|
||||
if (start.flow === 'loopback') {
|
||||
// No code to paste: the redirect lands on the backend's loopback
|
||||
// listener. Just wait and poll the session until the worker finishes.
|
||||
setFlow({ status: 'awaiting_browser', provider, start })
|
||||
pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setFlow({ status: 'polling', provider, start, copied: false })
|
||||
pollTimer = window.setInterval(() => void pollDevice(provider, start, ctx), POLL_MS)
|
||||
pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)
|
||||
} catch (error) {
|
||||
setFlow({ status: 'error', provider, message: `Could not start sign-in: ${errMessage(error)}` })
|
||||
}
|
||||
}
|
||||
|
||||
async function pollDevice(provider: OAuthProvider, start: DeviceStart, ctx: OnboardingContext) {
|
||||
// Poll a session-backed flow (device_code or loopback) until it resolves.
|
||||
// Both shapes only need the session_id to poll; the start is threaded
|
||||
// through to the error flow so the user can retry from the same context.
|
||||
async function pollSession(provider: OAuthProvider, start: DeviceStart | LoopbackStart, ctx: OnboardingContext) {
|
||||
try {
|
||||
const { error_message, status } = await pollOAuthSession(provider.id, start.session_id)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { sessionPinId } from './session'
|
||||
import { mergeWorkingSessions, sessionPinId } from './session'
|
||||
|
||||
const session = (over: Partial<SessionInfo>): SessionInfo => ({
|
||||
archived: false,
|
||||
@@ -34,3 +34,46 @@ describe('sessionPinId', () => {
|
||||
expect(sessionPinId(session({ id: 'tip', _lineage_root_id: 'root' }))).toBe('root')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeWorkingSessions', () => {
|
||||
it('returns the server page untouched when nothing is working', () => {
|
||||
const previous = [session({ id: 'a' }), session({ id: 'b' })]
|
||||
const incoming = [session({ id: 'a' })]
|
||||
|
||||
expect(mergeWorkingSessions(previous, incoming, [])).toBe(incoming)
|
||||
})
|
||||
|
||||
it('keeps a still-working session the server omitted', () => {
|
||||
// Repro of the disappearing-sessions bug: A finished and is returned by the
|
||||
// server, but B and C are mid-first-response (message_count 0 in the DB) so
|
||||
// listSessions(min_messages=1) skips them. They must survive the refresh.
|
||||
const previous = [session({ id: 'c' }), session({ id: 'b' }), session({ id: 'a' })]
|
||||
const incoming = [session({ id: 'a', message_count: 2 })]
|
||||
|
||||
const merged = mergeWorkingSessions(previous, incoming, ['b', 'c'])
|
||||
|
||||
expect(merged.map(s => s.id)).toEqual(['c', 'b', 'a'])
|
||||
// The finished session comes from the fresh server payload, not the stale
|
||||
// optimistic copy.
|
||||
expect(merged.find(s => s.id === 'a')?.message_count).toBe(2)
|
||||
})
|
||||
|
||||
it('does not duplicate a working session the server already returned', () => {
|
||||
const previous = [session({ id: 'b' }), session({ id: 'a' })]
|
||||
const incoming = [session({ id: 'b', message_count: 4 }), session({ id: 'a' })]
|
||||
|
||||
const merged = mergeWorkingSessions(previous, incoming, ['b'])
|
||||
|
||||
expect(merged.map(s => s.id)).toEqual(['b', 'a'])
|
||||
expect(merged.find(s => s.id === 'b')?.message_count).toBe(4)
|
||||
})
|
||||
|
||||
it('never resurrects a non-working session the server dropped', () => {
|
||||
// A deleted/archived session is removed from `previous` optimistically and
|
||||
// is not in the working set, so it must stay gone after a refresh.
|
||||
const previous = [session({ id: 'b' }), session({ id: 'gone' })]
|
||||
const incoming = [session({ id: 'b' })]
|
||||
|
||||
expect(mergeWorkingSessions(previous, incoming, ['b']).map(s => s.id)).toEqual(['b'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,33 @@ function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
|
||||
export const sessionPinId = (session: Pick<SessionInfo, '_lineage_root_id' | 'id'>): string =>
|
||||
session._lineage_root_id ?? session.id
|
||||
|
||||
/** Merge a fresh server session page into the in-memory list, keeping any
|
||||
* still-"working" session the server omitted.
|
||||
*
|
||||
* A brand-new session's first user message isn't flushed to the SessionDB
|
||||
* until its turn is persisted, so `listSessions(min_messages=1)` skips
|
||||
* sessions that are mid-first-response. Because every `message.complete`
|
||||
* triggers a full refresh, a hard replace makes concurrent new chats vanish
|
||||
* the instant any one of them finishes. Preserving the working-but-absent
|
||||
* rows keeps them visible until their own turn persists and the server
|
||||
* starts returning them. Optimistic deletes/archives already drop the row
|
||||
* from `previous`, so a removed session can't be resurrected here. */
|
||||
export function mergeWorkingSessions(
|
||||
previous: SessionInfo[],
|
||||
incoming: SessionInfo[],
|
||||
workingIds: readonly string[]
|
||||
): SessionInfo[] {
|
||||
if (workingIds.length === 0) {
|
||||
return incoming
|
||||
}
|
||||
|
||||
const working = new Set(workingIds)
|
||||
const incomingIds = new Set(incoming.map(session => session.id))
|
||||
const survivors = previous.filter(session => working.has(session.id) && !incomingIds.has(session.id))
|
||||
|
||||
return survivors.length ? [...survivors, ...incoming] : incoming
|
||||
}
|
||||
|
||||
export const $connection = atom<HermesConnection | null>(null)
|
||||
export const $gatewayState = atom('idle')
|
||||
export const $sessions = atom<SessionInfo[]>([])
|
||||
|
||||
+67
-19
@@ -76,8 +76,7 @@
|
||||
--shadow-header:
|
||||
0 0.0625rem 0 color-mix(in srgb, var(--dt-foreground) 7%, transparent),
|
||||
0 0.625rem 1.5rem -1.25rem color-mix(in srgb, #000 16%, transparent);
|
||||
--shadow-composer:
|
||||
0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent);
|
||||
--shadow-composer: 0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent);
|
||||
--shadow-composer-focus:
|
||||
0 0 0 0.125rem color-mix(in srgb, var(--dt-composer-ring) calc(10% * var(--composer-ring-strength)), transparent),
|
||||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-composer-ring) calc(22% * var(--composer-ring-strength)), transparent),
|
||||
@@ -133,15 +132,23 @@
|
||||
--ui-cyan: #4c7f8c;
|
||||
--ui-blue: #0053fd;
|
||||
--ui-purple: #9e94d5;
|
||||
--ui-bg-chrome: color-mix(in srgb, var(--theme-background-seed) var(--theme-mix-chrome), var(--theme-neutral-chrome));
|
||||
--ui-bg-sidebar: color-mix(in srgb, var(--theme-sidebar-seed) var(--theme-mix-sidebar), var(--theme-neutral-sidebar));
|
||||
--ui-bg-editor: color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card));
|
||||
--ui-bg-elevated: color-mix(in srgb, var(--theme-elevated-seed) var(--theme-mix-elevated), var(--theme-neutral-card));
|
||||
--ui-bg-card: color-mix(
|
||||
--ui-bg-chrome: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) 4%,
|
||||
color-mix(in srgb, var(--ui-base) 4%, transparent)
|
||||
var(--theme-background-seed) var(--theme-mix-chrome),
|
||||
var(--theme-neutral-chrome)
|
||||
);
|
||||
--ui-bg-sidebar: color-mix(
|
||||
in srgb,
|
||||
var(--theme-sidebar-seed) var(--theme-mix-sidebar),
|
||||
var(--theme-neutral-sidebar)
|
||||
);
|
||||
--ui-bg-editor: color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card));
|
||||
--ui-bg-elevated: color-mix(
|
||||
in srgb,
|
||||
var(--theme-elevated-seed) var(--theme-mix-elevated),
|
||||
var(--theme-neutral-card)
|
||||
);
|
||||
--ui-bg-card: color-mix(in srgb, var(--ui-accent) 4%, color-mix(in srgb, var(--ui-base) 4%, transparent));
|
||||
--ui-bg-input: #fcfcfc;
|
||||
--ui-bg-primary: color-mix(
|
||||
in srgb,
|
||||
@@ -218,7 +225,11 @@
|
||||
--ui-sidebar-surface-background: var(--ui-bg-sidebar);
|
||||
--ui-chat-surface-background: var(--ui-bg-chrome);
|
||||
--ui-editor-surface-background: var(--ui-bg-chrome);
|
||||
--ui-chat-bubble-background: color-mix(in srgb, var(--theme-bubble-seed) var(--theme-mix-bubble), var(--theme-neutral-card));
|
||||
--ui-chat-bubble-background: color-mix(
|
||||
in srgb,
|
||||
var(--theme-bubble-seed) var(--theme-mix-bubble),
|
||||
var(--theme-neutral-card)
|
||||
);
|
||||
--ui-chat-bubble-opaque-background: var(--ui-bg-editor);
|
||||
--ui-inline-code-background: color-mix(in srgb, #141414 5%, transparent);
|
||||
--ui-inline-code-border: color-mix(in srgb, #141414 8%, transparent);
|
||||
@@ -272,6 +283,7 @@
|
||||
--conversation-line-height: 1.125rem;
|
||||
--conversation-caption-line-height: 1rem;
|
||||
--conversation-turn-gap: 0.375rem;
|
||||
--sticky-human-top: 0.23rem;
|
||||
--file-tree-row-height: 1.375rem;
|
||||
|
||||
--composer-width: 48.75rem;
|
||||
@@ -626,7 +638,7 @@ canvas {
|
||||
.scrollbar-dt::-webkit-scrollbar-thumb,
|
||||
.scrollbar-dt *::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--dt-midground) 18%, transparent);
|
||||
border-radius: 9999rem;
|
||||
border-radius: 9999rem;
|
||||
border: 0.125rem solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
@@ -704,11 +716,52 @@ canvas {
|
||||
padding-inline-start: var(--md-text-indent, 0.5rem);
|
||||
}
|
||||
|
||||
[data-slot='aui_user-message-root'] {
|
||||
top: var(--sticky-human-top);
|
||||
}
|
||||
|
||||
[data-slot='aui_user-message-root'],
|
||||
[data-slot='aui_edit-composer-root'] {
|
||||
font-size: var(--conversation-text-font-size);
|
||||
}
|
||||
|
||||
/* Sticky human bubbles clamp to ~2 lines with a soft bottom fade so a long
|
||||
prompt doesn't dominate the viewport while you read the response stuck
|
||||
beneath it. The clamp lifts on hover / focus (clicking the bubble opens the
|
||||
edit composer, which already shows the full text). --human-msg-full is the
|
||||
measured content height (set in UserMessage) so expand/collapse animates to
|
||||
the real height instead of overshooting the cap. */
|
||||
.sticky-human-clamp {
|
||||
max-height: calc(2 * var(--dt-line-height) * var(--conversation-text-font-size) + 0.15rem);
|
||||
overflow: hidden;
|
||||
transition: max-height 0.08s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sticky-human-clamp[data-clamped='true'] {
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent);
|
||||
mask-image: linear-gradient(to bottom, #000 55%, transparent);
|
||||
}
|
||||
|
||||
.composer-human-message:hover .sticky-human-clamp,
|
||||
.composer-human-message:focus-within .sticky-human-clamp {
|
||||
max-height: min(var(--human-msg-full, 24rem), 24rem);
|
||||
overflow-y: auto;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
/* The thread renders items in natural document flow (padding spacers, not
|
||||
transforms) and @tanstack/react-virtual already adjusts scrollTop itself
|
||||
when an off-screen turn is measured and its real height differs from the
|
||||
220px estimate. The browser's native scroll anchoring (overflow-anchor:
|
||||
auto) would adjust scrollTop for that SAME size delta, so the two
|
||||
double-correct and the view lurches — most visibly on Windows mouse wheels,
|
||||
whose coarse notches mount/measure several under-estimated turns per tick.
|
||||
Opt out of native anchoring so only the virtualizer compensates. */
|
||||
[data-slot='aui_thread-viewport'] {
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
[data-slot='aui_thread-content'] {
|
||||
max-width: var(--composer-width);
|
||||
padding-inline: 1.5rem;
|
||||
@@ -897,8 +950,7 @@ canvas {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content']
|
||||
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
|
||||
[data-slot='aui_assistant-message-content'] > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
|
||||
opacity: 0.67;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
@@ -929,12 +981,8 @@ canvas {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content']
|
||||
[data-slot='aui_thinking-disclosure']
|
||||
+ [data-slot='tool-block'],
|
||||
[data-slot='aui_assistant-message-content']
|
||||
[data-slot='tool-block']
|
||||
+ [data-slot='aui_thinking-disclosure'] {
|
||||
[data-slot='aui_assistant-message-content'] [data-slot='aui_thinking-disclosure'] + [data-slot='tool-block'],
|
||||
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'] + [data-slot='aui_thinking-disclosure'] {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface OAuthProviderStatus {
|
||||
export interface OAuthProvider {
|
||||
cli_command: string
|
||||
docs_url: string
|
||||
flow: 'device_code' | 'external' | 'pkce'
|
||||
flow: 'device_code' | 'external' | 'loopback' | 'pkce'
|
||||
id: string
|
||||
name: string
|
||||
status: OAuthProviderStatus
|
||||
@@ -73,6 +73,12 @@ export type OAuthStartResponse =
|
||||
user_code: string
|
||||
verification_url: string
|
||||
}
|
||||
| {
|
||||
auth_url: string
|
||||
expires_in: number
|
||||
flow: 'loopback'
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export interface OAuthSubmitResponse {
|
||||
message?: string
|
||||
@@ -210,6 +216,14 @@ export interface ModelOptionProvider {
|
||||
free_tier?: boolean
|
||||
/** Nous only: paid models a free-tier user cannot select (shown disabled). */
|
||||
unavailable_models?: string[]
|
||||
/** Per-model option support, keyed by model id (present when the picker
|
||||
* requested capabilities). Lets the UI gate fast/reasoning controls. */
|
||||
capabilities?: Record<string, ModelCapabilities>
|
||||
}
|
||||
|
||||
export interface ModelCapabilities {
|
||||
fast: boolean
|
||||
reasoning: boolean
|
||||
}
|
||||
|
||||
export interface ModelOptionsResponse {
|
||||
|
||||
@@ -1115,10 +1115,36 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
|
||||
from tools.skills_tool import skill_view
|
||||
from tools.skill_usage import bump_use
|
||||
from agent.skill_bundles import build_bundle_invocation_message, resolve_bundle_command_key
|
||||
|
||||
parts = []
|
||||
skipped: list[str] = []
|
||||
for skill_name in skill_names:
|
||||
# Cron jobs historically accepted only skill names here, but the CLI/gateway
|
||||
# slash-command path lets bundles shadow skills with the same slug. Mirror
|
||||
# that behavior so `skills: ["my-bundle"]` expands bundle members instead
|
||||
# of being treated as a missing skill.
|
||||
bundle_key = resolve_bundle_command_key(skill_name.lstrip("/"))
|
||||
if bundle_key:
|
||||
bundle_payload = build_bundle_invocation_message(
|
||||
bundle_key,
|
||||
user_instruction="",
|
||||
task_id=str(job.get("id") or "") or None,
|
||||
)
|
||||
if bundle_payload:
|
||||
bundle_message, _loaded_bundle_skills, _missing_bundle_skills = bundle_payload
|
||||
if parts:
|
||||
parts.append("")
|
||||
parts.append(bundle_message)
|
||||
continue
|
||||
logger.warning(
|
||||
"Cron job '%s': bundle '%s' could not load any skills, skipping",
|
||||
job.get("name", job.get("id")),
|
||||
skill_name,
|
||||
)
|
||||
skipped.append(skill_name)
|
||||
continue
|
||||
|
||||
try:
|
||||
loaded = json.loads(skill_view(skill_name))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
|
||||
@@ -278,6 +278,38 @@ if [ ! -f "$HERMES_HOME/auth.json" ] && [ -n "${HERMES_AUTH_JSON_BOOTSTRAP:-}" ]
|
||||
chmod 600 "$HERMES_HOME/auth.json"
|
||||
fi
|
||||
|
||||
# gateway_state.json: declare the gateway's INITIAL supervised state on a
|
||||
# fresh volume. Same first-boot-only env-seed pattern as auth.json above.
|
||||
#
|
||||
# On a blank volume there is no gateway_state.json, so the boot reconciler
|
||||
# (cont-init.d/02-reconcile-profiles → container_boot.reconcile_profile_gateways)
|
||||
# registers the gateway-default s6 slot but leaves it DOWN — it only
|
||||
# auto-starts when the last recorded state was "running". That means a
|
||||
# freshly-provisioned container comes up with the gateway down until
|
||||
# someone starts it (e.g. from the dashboard). An orchestrator that
|
||||
# provisions a fresh volume and wants the gateway running from first boot
|
||||
# can set HERMES_GATEWAY_BOOTSTRAP_STATE=running; we seed the state file
|
||||
# here, BEFORE 02-reconcile-profiles runs (cont-init.d scripts run in
|
||||
# lexicographic order), so the reconciler sees prior_state=running and
|
||||
# brings the supervised slot up on the very first boot.
|
||||
#
|
||||
# This is a generic container contract, not specific to any host: it seeds
|
||||
# the SAME gateway_state.json the reconciler already consults, exactly as
|
||||
# HERMES_AUTH_JSON_BOOTSTRAP seeds auth.json. The [ ! -f ] guard is the
|
||||
# load-bearing part — on every subsequent boot the persisted state wins,
|
||||
# so a gateway the operator deliberately stopped stays stopped across
|
||||
# restarts and we never clobber real runtime state.
|
||||
#
|
||||
# Only a literal "running" is honoured (the sole value in the reconciler's
|
||||
# _AUTOSTART_STATES); any other value is ignored so a typo can't write a
|
||||
# bogus state the reconciler would treat as "no prior state" anyway.
|
||||
if [ ! -f "$HERMES_HOME/gateway_state.json" ] && \
|
||||
[ "${HERMES_GATEWAY_BOOTSTRAP_STATE:-}" = "running" ]; then
|
||||
printf '{"gateway_state":"running"}\n' > "$HERMES_HOME/gateway_state.json"
|
||||
chown hermes:hermes "$HERMES_HOME/gateway_state.json" 2>/dev/null || true
|
||||
chmod 644 "$HERMES_HOME/gateway_state.json"
|
||||
fi
|
||||
|
||||
# --- Sync bundled skills ---
|
||||
# Invoke the venv's python by absolute path so we don't need a `sh -c`
|
||||
# wrapper to source the activate script. This is safe because
|
||||
|
||||
@@ -4195,8 +4195,25 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Stop the aiohttp web server."""
|
||||
"""Stop the aiohttp web server and release all owned resources.
|
||||
|
||||
Closes the ResponseStore SQLite connection in addition to stopping
|
||||
the aiohttp web server. Without this, every adapter instance leaks
|
||||
2 file descriptors (the database file and its WAL sidecar) — the
|
||||
reconnect loop in ``gateway.run`` constructs a fresh adapter on
|
||||
every retry, so 2 fds/retry × 300s backoff cap ≈ 12 fds/hour, which
|
||||
exhausts the default 2560 fd limit after ~12h of failed reconnects
|
||||
and turns the whole gateway into a zombie
|
||||
(OSError: [Errno 24] Too many open files, #37011).
|
||||
"""
|
||||
self._mark_disconnected()
|
||||
if self._response_store is not None:
|
||||
try:
|
||||
self._response_store.close()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to close response store for %s", self.name, exc_info=True,
|
||||
)
|
||||
if self._site:
|
||||
await self._site.stop()
|
||||
self._site = None
|
||||
|
||||
+113
-6
@@ -1755,6 +1755,60 @@ def _preserve_queued_followup_history_offset(
|
||||
return merged
|
||||
|
||||
|
||||
async def _dispose_unused_adapter(adapter: "BasePlatformAdapter | None") -> None:
|
||||
"""Best-effort dispose for an adapter that never made it onto ``self.adapters``.
|
||||
|
||||
The reconnect watcher in ``GatewayRunner._platform_reconnect_watcher``
|
||||
constructs a fresh adapter on every retry attempt. When the connect
|
||||
call fails — for any of the three reasons (non-retryable error,
|
||||
retryable error, exception during connect) — the adapter is dropped
|
||||
without ever being installed, so nothing else will call its
|
||||
``disconnect()``. Any resources the adapter opened in ``__init__``
|
||||
(e.g. ``APIServerAdapter`` opens a SQLite ``ResponseStore`` that
|
||||
holds 2 fds — the db file and its WAL sidecar) stay open until
|
||||
garbage collection sweeps the unreachable object, which Python's
|
||||
cyclic GC does not do promptly for asyncio-bound objects with
|
||||
native handles. The cumulative leak is 2 fds × every retry at the
|
||||
300s backoff cap ≈ 12 fds/hour, and the default 2560-fd ulimit
|
||||
is exhausted in ~12h of continuous failure, after which every
|
||||
open() call on the gateway raises ``OSError: [Errno 24] Too many
|
||||
open files`` and the gateway becomes a zombie (#37011).
|
||||
|
||||
This helper centralises the dispose-with-suppression so the three
|
||||
failure paths in the reconnect watcher can all call it without
|
||||
each one having to know that ``disconnect()`` may itself raise
|
||||
on a half-constructed adapter.
|
||||
|
||||
``adapter`` may be ``None``: the reconnect watcher initialises
|
||||
``adapter = None`` before the ``try`` so the ``except Exception``
|
||||
arm can dispose a half-constructed object, and also early-returns
|
||||
here when ``_create_adapter()`` returned ``None``.
|
||||
"""
|
||||
if adapter is None:
|
||||
return
|
||||
try:
|
||||
await adapter.disconnect()
|
||||
except Exception:
|
||||
# Half-constructed adapters (e.g. APIServerAdapter that
|
||||
# crashed during aiohttp app setup) can raise from
|
||||
# disconnect() on objects that never finished initializing.
|
||||
# We must not let that escape and abort the watcher loop.
|
||||
#
|
||||
# On Python 3.8+, ``asyncio.CancelledError`` inherits from
|
||||
# ``BaseException`` (not ``Exception``), so this ``except
|
||||
# Exception`` does not swallow task cancellation. We don't
|
||||
# re-raise explicitly because the watcher loop intentionally
|
||||
# treats dispose failures as best-effort: a failed ``disconnect``
|
||||
# call should not take down the reconnect watcher that
|
||||
# itself is what's keeping the gateway alive during a partial
|
||||
# outage.
|
||||
logger.debug(
|
||||
"Adapter dispose raised on unowned adapter %r",
|
||||
getattr(adapter, "name", type(adapter).__name__),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
class GatewayRunner:
|
||||
"""
|
||||
Main gateway controller.
|
||||
@@ -6175,6 +6229,7 @@ class GatewayRunner:
|
||||
platform.value, attempt,
|
||||
)
|
||||
|
||||
adapter = None
|
||||
try:
|
||||
adapter = self._create_adapter(platform, platform_config)
|
||||
if not adapter:
|
||||
@@ -6224,6 +6279,15 @@ class GatewayRunner:
|
||||
"Reconnect %s: non-retryable error (%s), removing from retry queue",
|
||||
platform.value, adapter.fatal_error_message,
|
||||
)
|
||||
# The adapter is about to be dropped from the queue
|
||||
# without ever being installed on self.adapters, so
|
||||
# nothing else will call disconnect() on it. We must
|
||||
# dispose it here, otherwise the resource owners it
|
||||
# constructed in __init__ (ResponseStore for
|
||||
# APIServerAdapter, etc.) leak 2 fds each. The
|
||||
# gateway hits the 2560-fd limit after ~12h of
|
||||
# failed reconnects at the 300s backoff cap (#37011).
|
||||
await _dispose_unused_adapter(adapter)
|
||||
del self._failed_platforms[platform]
|
||||
else:
|
||||
self._update_platform_runtime_status(
|
||||
@@ -6239,6 +6303,14 @@ class GatewayRunner:
|
||||
"Reconnect %s failed, next retry in %ds",
|
||||
platform.value, backoff,
|
||||
)
|
||||
# Same fd-leak concern as the non-retryable branch
|
||||
# above: the adapter failed to connect and is being
|
||||
# thrown away. Without an explicit dispose call, the
|
||||
# resources it opened in __init__ stay open until
|
||||
# the next GC pass — and aiohttp/SQLite handles
|
||||
# don't get GC'd promptly, so 2 fds/retry leak at
|
||||
# 300s backoff cap = ~12 fds/hour (#37011).
|
||||
await _dispose_unused_adapter(adapter)
|
||||
# Retryable failures (network/DNS blips) keep retrying
|
||||
# at the backoff cap indefinitely — they self-heal once
|
||||
# connectivity returns. We do NOT auto-pause them: a
|
||||
@@ -6248,6 +6320,14 @@ class GatewayRunner:
|
||||
# `not fatal_error_retryable` branch above, so anything
|
||||
# reaching here is by definition retryable.
|
||||
except Exception as e:
|
||||
if adapter is not None:
|
||||
# An exception escaping the connect call path
|
||||
# (DNS timeout, aiohttp server.start() crash, etc.)
|
||||
# leaves the adapter in the same unowned state as
|
||||
# the two branches above. Dispose so __init__
|
||||
# resources don't accumulate while the watcher
|
||||
# keeps retrying.
|
||||
await _dispose_unused_adapter(adapter)
|
||||
self._update_platform_runtime_status(
|
||||
platform.value,
|
||||
platform_state="retrying",
|
||||
@@ -12508,14 +12588,41 @@ class GatewayRunner:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Send media files
|
||||
# Send media files, routing each by type so a TTS clip
|
||||
# arrives as a voice bubble / a clip as a video rather than
|
||||
# a generic document. Mirrors the streaming + kanban paths.
|
||||
from gateway.platforms.base import (
|
||||
should_send_media_as_audio as _should_send_media_as_audio,
|
||||
)
|
||||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
|
||||
for media_path, _is_voice in (media_files or []):
|
||||
_ext = os.path.splitext(media_path)[1].lower()
|
||||
try:
|
||||
await adapter.send_document(
|
||||
chat_id=source.chat_id,
|
||||
file_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
if _should_send_media_as_audio(source.platform, _ext, _is_voice):
|
||||
await adapter.send_voice(
|
||||
chat_id=source.chat_id,
|
||||
audio_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
elif _ext in _VIDEO_EXTS:
|
||||
await adapter.send_video(
|
||||
chat_id=source.chat_id,
|
||||
video_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
elif _ext in _IMAGE_EXTS:
|
||||
await adapter.send_image_file(
|
||||
chat_id=source.chat_id,
|
||||
image_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
else:
|
||||
await adapter.send_document(
|
||||
chat_id=source.chat_id,
|
||||
file_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
|
||||
@@ -41,6 +41,8 @@ _EPILOGUE = """
|
||||
Examples:
|
||||
hermes Start interactive chat
|
||||
hermes chat -q "Hello" Single query mode
|
||||
hermes --tui Launch the modern TUI (or set display.interface: tui)
|
||||
hermes --cli Force the classic REPL (overrides display.interface: tui)
|
||||
hermes -c Resume the most recent session
|
||||
hermes -c "my project" Resume a session by name (latest in lineage)
|
||||
hermes --resume <session_id> Resume a specific session by ID
|
||||
@@ -218,6 +220,13 @@ def build_top_level_parser():
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--cli",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Force the classic prompt_toolkit REPL (overrides display.interface=tui)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--dev",
|
||||
@@ -369,6 +378,13 @@ def build_top_level_parser():
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--cli",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Force the classic prompt_toolkit REPL (overrides display.interface=tui)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--dev",
|
||||
|
||||
@@ -1283,6 +1283,12 @@ DEFAULT_CONFIG = {
|
||||
# behavior of showing tool-call summaries inline.
|
||||
"resume_skip_tool_only": True,
|
||||
"busy_input_mode": "interrupt", # interrupt | queue | steer
|
||||
# Which interface bare `hermes` (and `hermes chat`) launches by default:
|
||||
# "cli" — the classic prompt_toolkit REPL (default, preserves prior behavior)
|
||||
# "tui" — the modern Ink TUI (same as passing `--tui`)
|
||||
# Explicit flags always win over this setting: `--cli` forces the classic
|
||||
# REPL and `--tui` (or HERMES_TUI=1) forces the TUI regardless of config.
|
||||
"interface": "cli",
|
||||
# When true, `hermes --tui` auto-resumes the most recent human-
|
||||
# facing session on launch instead of forging a fresh one.
|
||||
# Mirrors `hermes -c` muscle memory. Default off so existing
|
||||
@@ -2284,7 +2290,7 @@ DEFAULT_CONFIG = {
|
||||
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 25,
|
||||
"_config_version": 26,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
"""Short-lived single-use tickets for WS-upgrade auth in gated mode.
|
||||
"""WS-upgrade auth credentials for gated mode.
|
||||
|
||||
Browsers cannot set ``Authorization`` on a WebSocket upgrade. In loopback
|
||||
mode the legacy ``?token=<_SESSION_TOKEN>`` query param works because the
|
||||
token is injected into the SPA bundle. In gated mode there is no injected
|
||||
token — the SPA gets a fresh ticket via the authenticated REST endpoint
|
||||
``POST /api/auth/ws-ticket`` and passes that as ``?ticket=`` on the
|
||||
WS upgrade.
|
||||
token — so this module provides two credential shapes:
|
||||
|
||||
Tickets are single-use, TTL = 30 seconds. In-memory; the dashboard is a
|
||||
single process so no distributed coordination is needed. The module
|
||||
exposes a small functional API rather than a class so tests can patch
|
||||
``time.time`` cleanly.
|
||||
1. **Single-use browser tickets** (``mint_ticket`` / ``consume_ticket``).
|
||||
The SPA gets a fresh ticket via the authenticated REST endpoint
|
||||
``POST /api/auth/ws-ticket`` and passes it as ``?ticket=`` on the WS
|
||||
upgrade. Single-use, TTL = 30 seconds — a leaked ticket is uninteresting.
|
||||
|
||||
2. **A process-lifetime internal credential** (``internal_ws_credential`` /
|
||||
``consume_internal_credential``). This authenticates *server-spawned*
|
||||
WS clients — specifically the embedded-TUI PTY child, which attaches to
|
||||
``/api/ws`` (JSON-RPC gateway) and ``/api/pub`` (event sidecar) over
|
||||
loopback. A single-use 30s ticket is the wrong shape for that link: the
|
||||
child reads its attach URL once at startup and **reuses it on every
|
||||
reconnect**, and on a slow cold boot the child may not dial within 30s.
|
||||
The internal credential is minted once per process, never expires, is
|
||||
multi-use, and — critically — is **never injected into any HTML/SPA**:
|
||||
it only ever leaves the process via the spawned child's environment, so
|
||||
browser-side XSS cannot read it. A leaked internal credential grants no
|
||||
more than a single-use ticket already does (the same two internal WS
|
||||
endpoints), and the same Origin / host guards still apply downstream.
|
||||
|
||||
In-memory; the dashboard is a single process so no distributed coordination
|
||||
is needed. The module exposes a small functional API rather than a class so
|
||||
tests can patch ``time.time`` cleanly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,7 +34,7 @@ from __future__ import annotations
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Tuple
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
#: Time-to-live for newly-minted tickets in seconds. 30 s is long enough
|
||||
#: that the SPA can call ``getWsTicket()`` and immediately open the WS,
|
||||
@@ -28,6 +44,16 @@ TTL_SECONDS = 30
|
||||
_lock = threading.Lock()
|
||||
_tickets: Dict[str, Tuple[int, Dict[str, Any]]] = {} # ticket -> (expires_at, info)
|
||||
|
||||
#: The process-lifetime internal credential (see module docstring). Lazily
|
||||
#: minted on first ``internal_ws_credential()`` call and stable for the life
|
||||
#: of the process. Guarded by ``_lock``.
|
||||
_internal_credential: Optional[str] = None
|
||||
|
||||
#: Identity recorded for connections that authenticate via the internal
|
||||
#: credential, so audit logs distinguish them from browser-initiated tickets.
|
||||
INTERNAL_USER_ID = "server-internal"
|
||||
INTERNAL_PROVIDER = "server-internal"
|
||||
|
||||
|
||||
class TicketInvalid(Exception):
|
||||
"""Ticket missing, expired, or already consumed."""
|
||||
@@ -81,7 +107,55 @@ def _gc_expired_locked() -> None:
|
||||
_tickets.pop(t, None)
|
||||
|
||||
|
||||
def internal_ws_credential() -> str:
|
||||
"""Return the process-lifetime internal WS credential, minting it once.
|
||||
|
||||
Used by the server to authenticate WS clients it spawns itself (the
|
||||
embedded-TUI PTY child). The value is stable for the life of the process,
|
||||
multi-use, and never expires — so a server-spawned child can reconnect
|
||||
its ``/api/ws`` / ``/api/pub`` sockets indefinitely without re-minting.
|
||||
|
||||
The credential is never injected into the SPA HTML or returned over any
|
||||
REST endpoint; it is only ever passed to a child process via its
|
||||
environment. See the module docstring for the threat-model rationale.
|
||||
"""
|
||||
global _internal_credential
|
||||
with _lock:
|
||||
if _internal_credential is None:
|
||||
_internal_credential = secrets.token_urlsafe(32)
|
||||
return _internal_credential
|
||||
|
||||
|
||||
def consume_internal_credential(value: str) -> Dict[str, Any]:
|
||||
"""Validate an internal credential. Raises :class:`TicketInvalid` on mismatch.
|
||||
|
||||
Unlike :func:`consume_ticket` this is **not** single-use — the value is
|
||||
not removed on success, so a server-spawned child can present it on every
|
||||
(re)connect. Returns the fixed server-internal identity ``info`` dict
|
||||
(``{user_id, provider}``), mirroring the ``info`` shape ``consume_ticket``
|
||||
returns, so a caller that wants to record the connecting identity can; the
|
||||
current ``_ws_auth_ok`` caller validates for the boolean outcome only and
|
||||
discards the dict.
|
||||
|
||||
A constant-time compare against the (lazily-minted) credential avoids
|
||||
leaking length / prefix information on mismatch. If no internal
|
||||
credential has been minted yet, any value is rejected.
|
||||
"""
|
||||
with _lock:
|
||||
expected = _internal_credential
|
||||
if not value or expected is None:
|
||||
raise TicketInvalid("no internal credential")
|
||||
if not secrets.compare_digest(value.encode(), expected.encode()):
|
||||
raise TicketInvalid("internal credential mismatch")
|
||||
return {
|
||||
"user_id": INTERNAL_USER_ID,
|
||||
"provider": INTERNAL_PROVIDER,
|
||||
}
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Test-only: drop all tickets."""
|
||||
"""Test-only: drop all tickets and the internal credential."""
|
||||
global _internal_credential
|
||||
with _lock:
|
||||
_tickets.clear()
|
||||
_internal_credential = None
|
||||
|
||||
@@ -115,6 +115,7 @@ def build_models_payload(
|
||||
picker_hints: bool = False,
|
||||
canonical_order: bool = False,
|
||||
pricing: bool = False,
|
||||
capabilities: bool = False,
|
||||
max_models: int = 50,
|
||||
) -> dict:
|
||||
"""Build the ``{providers, model, provider}`` shape every consumer
|
||||
@@ -134,6 +135,10 @@ def build_models_payload(
|
||||
show $/Mtok columns and gate paid models on free accounts —
|
||||
mirroring the ``hermes model`` CLI picker. Adds network calls
|
||||
(pricing fetch + Nous tier check); only set for interactive pickers.
|
||||
- ``capabilities``: add a per-row ``capabilities`` map
|
||||
``{model: {fast, reasoning}}`` so pickers can gate the model-options
|
||||
controls (fast toggle / reasoning) to what each model actually
|
||||
supports, instead of offering knobs the backend would reject.
|
||||
"""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
@@ -154,6 +159,8 @@ def build_models_payload(
|
||||
rows = _reorder_canonical(rows)
|
||||
if pricing:
|
||||
_apply_pricing(rows)
|
||||
if capabilities:
|
||||
_apply_capabilities(rows)
|
||||
|
||||
return {
|
||||
"providers": rows,
|
||||
@@ -162,6 +169,44 @@ def build_models_payload(
|
||||
}
|
||||
|
||||
|
||||
def _apply_capabilities(rows: list[dict]) -> None:
|
||||
"""Attach a ``{model: {fast, reasoning}}`` map to each provider row.
|
||||
|
||||
`fast` mirrors ``model_supports_fast_mode`` (the same gate the runtime
|
||||
enforces). `reasoning` comes from the models.dev catalog when known and
|
||||
defaults to True otherwise — the effort dial is broadly accepted and a
|
||||
no-op on models that ignore it, whereas hiding it from a capable-but-
|
||||
uncatalogued model is the worse failure.
|
||||
"""
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
try:
|
||||
from agent.models_dev import get_model_capabilities
|
||||
except Exception:
|
||||
get_model_capabilities = None # type: ignore[assignment]
|
||||
|
||||
for row in rows:
|
||||
slug = row.get("slug") or ""
|
||||
caps: dict[str, dict[str, bool]] = {}
|
||||
|
||||
for model in row.get("models") or []:
|
||||
reasoning = True
|
||||
if get_model_capabilities is not None and slug:
|
||||
try:
|
||||
meta = get_model_capabilities(slug, model)
|
||||
if meta is not None:
|
||||
reasoning = bool(meta.supports_reasoning)
|
||||
except Exception:
|
||||
reasoning = True
|
||||
|
||||
caps[model] = {
|
||||
"fast": bool(model_supports_fast_mode(model)),
|
||||
"reasoning": reasoning,
|
||||
}
|
||||
|
||||
row["capabilities"] = caps
|
||||
|
||||
|
||||
# ─── Internal: row post-processing ──────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
+268
-56
@@ -105,6 +105,58 @@ def _set_process_title() -> None:
|
||||
pass
|
||||
|
||||
|
||||
# Cheap, dependency-free read of `display.interface` from config.yaml for the
|
||||
# earliest hot-path decisions (mouse-residue suppression, Termux fast launch)
|
||||
# that run *before* hermes_cli.config is importable. Mirrors the explicit
|
||||
# precedence used everywhere else: `--cli` always wins, then `--tui`/env, then
|
||||
# this config value. Cached so the multiple early callers don't re-parse YAML.
|
||||
_EARLY_INTERFACE_CACHE: "list | None" = None
|
||||
|
||||
|
||||
def _config_default_interface_early() -> str:
|
||||
"""Return the configured default interface ("cli"/"tui") via a minimal
|
||||
YAML read. Best-effort: any error falls back to "cli" (legacy behavior)."""
|
||||
global _EARLY_INTERFACE_CACHE
|
||||
if _EARLY_INTERFACE_CACHE is not None:
|
||||
return _EARLY_INTERFACE_CACHE[0]
|
||||
value = "cli"
|
||||
try:
|
||||
home = os.environ.get("HERMES_HOME")
|
||||
if home:
|
||||
cfg_path = os.path.join(home, "config.yaml")
|
||||
else:
|
||||
cfg_path = os.path.join(os.path.expanduser("~"), ".hermes", "config.yaml")
|
||||
if os.path.exists(cfg_path):
|
||||
import yaml as _yaml_iface
|
||||
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
raw = _yaml_iface.safe_load(_f) or {}
|
||||
disp = raw.get("display", {})
|
||||
if isinstance(disp, dict):
|
||||
iface = disp.get("interface")
|
||||
if isinstance(iface, str) and iface.strip().lower() == "tui":
|
||||
value = "tui"
|
||||
except Exception:
|
||||
value = "cli" # best-effort — default to classic REPL on any error
|
||||
_EARLY_INTERFACE_CACHE = [value]
|
||||
return value
|
||||
|
||||
|
||||
def _wants_tui_early(argv: "list[str] | None" = None) -> bool:
|
||||
"""Earliest TUI decision, usable before argparse/config imports.
|
||||
|
||||
Precedence: explicit ``--cli`` wins (forces classic REPL), then
|
||||
``--tui``/``HERMES_TUI=1``, then ``display.interface`` in config.
|
||||
"""
|
||||
if argv is None:
|
||||
argv = sys.argv[1:]
|
||||
if "--cli" in argv:
|
||||
return False
|
||||
if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv:
|
||||
return True
|
||||
return _config_default_interface_early() == "tui"
|
||||
|
||||
|
||||
# Mouse-tracking residue suppression — runs BEFORE every other import on the
|
||||
# TUI hot path so the terminal stops emitting SGR/X10 mouse reports while the
|
||||
# Python launcher is still doing imports (≈100–300ms in cooked + echo mode,
|
||||
@@ -116,7 +168,7 @@ def _set_process_title() -> None:
|
||||
def _suppress_mouse_residue_early() -> None:
|
||||
if os.environ.get("HERMES_TUI_NO_EARLY_DISABLE") == "1":
|
||||
return
|
||||
if not (os.environ.get("HERMES_TUI") == "1" or "--tui" in sys.argv[1:]):
|
||||
if not _wants_tui_early():
|
||||
return
|
||||
try:
|
||||
# Skip when stdout is redirected (`hermes --tui … >log`, CI capture):
|
||||
@@ -204,6 +256,7 @@ import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -1184,6 +1237,33 @@ to avoid false-positive reinstalls on every launch.
|
||||
"""
|
||||
|
||||
|
||||
def _workspace_root(dir: Path) -> Path:
|
||||
"""Return the npm workspace root for *dir*.
|
||||
|
||||
In a workspace checkout the single ``package-lock.json`` and hoisted
|
||||
``node_modules/`` live at the workspace root (the parent of the
|
||||
sub-package directory). Heuristic: if *dir* has a ``package.json``
|
||||
but **no** ``package-lock.json``, and its **parent** has a
|
||||
``package-lock.json``, the parent is the workspace root.
|
||||
Otherwise *dir* itself is the root (standalone project or
|
||||
prebuilt-bundle layout).
|
||||
|
||||
Used by ``_tui_need_npm_install``, ``_make_tui_argv``, and
|
||||
``_build_web_ui`` so that lockfile/node_modules resolution and
|
||||
``npm install`` cwd stay consistent — a single helper prevents
|
||||
the checks from diverging if someone accidentally creates a
|
||||
sub-package lockfile (e.g. running ``npm install`` in the wrong
|
||||
directory).
|
||||
"""
|
||||
if (
|
||||
(dir / "package.json").is_file()
|
||||
and not (dir / "package-lock.json").is_file()
|
||||
and (dir.parent / "package-lock.json").is_file()
|
||||
):
|
||||
return dir.parent
|
||||
return dir
|
||||
|
||||
|
||||
def _tui_need_npm_install(root: Path) -> bool:
|
||||
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
|
||||
|
||||
@@ -1192,6 +1272,12 @@ def _tui_need_npm_install(root: Path) -> bool:
|
||||
``package.json``), skip reinstall entirely — the bundle is self-contained
|
||||
and there is nothing to install.
|
||||
|
||||
With npm workspaces the single ``package-lock.json`` and the hoisted
|
||||
``node_modules/`` live at the workspace root (the parent of the
|
||||
``ui-tui/`` directory). The lockfile / ink / marker checks use that
|
||||
workspace root; only the prebuilt-bundle sentinel stays relative to
|
||||
*root* (``ui-tui/dist/entry.js``).
|
||||
|
||||
Compares ``package-lock.json`` against ``node_modules/.package-lock.json``
|
||||
(npm's hidden lockfile) by **content**, not mtime: git checkouts and npm
|
||||
rewrites can bump the root lockfile's timestamp even when installed deps
|
||||
@@ -1209,19 +1295,21 @@ def _tui_need_npm_install(root: Path) -> bool:
|
||||
we'd rather not force a reinstall for them. Falls back to mtime
|
||||
comparison if either lockfile is unparseable.
|
||||
"""
|
||||
lock = root / "package-lock.json"
|
||||
entry = root / "dist" / "entry.js"
|
||||
# Prebuilt self-contained bundle (nix / packaged release): no lockfile
|
||||
# shipped, dist/entry.js is the single runtime artefact.
|
||||
entry = root / "dist" / "entry.js"
|
||||
# With npm workspaces the lockfile lives at the workspace root.
|
||||
ws_root = _workspace_root(root)
|
||||
lock = ws_root / "package-lock.json"
|
||||
if entry.is_file() and not lock.is_file():
|
||||
return False
|
||||
|
||||
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
|
||||
ink = ws_root / "node_modules" / "@hermes" / "ink" / "package.json"
|
||||
if not ink.is_file():
|
||||
return True
|
||||
if not lock.is_file():
|
||||
return False
|
||||
marker = root / "node_modules" / ".package-lock.json"
|
||||
marker = ws_root / "node_modules" / ".package-lock.json"
|
||||
if not marker.is_file():
|
||||
return True
|
||||
|
||||
@@ -1270,7 +1358,6 @@ _TUI_BUILD_INPUT_FILES = (
|
||||
"babel.compiler.config.cjs",
|
||||
"scripts/build.mjs",
|
||||
"packages/hermes-ink/package.json",
|
||||
"packages/hermes-ink/package-lock.json",
|
||||
"packages/hermes-ink/index.js",
|
||||
"packages/hermes-ink/text-input.js",
|
||||
)
|
||||
@@ -1437,6 +1524,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
|
||||
# 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js.
|
||||
# --dev flow: npm install if needed, then tsx src/entry.tsx.
|
||||
# npm install runs from the workspace root (where package-lock.json lives);
|
||||
# npm workspaces resolves ui-tui deps automatically.
|
||||
did_install = False
|
||||
if _tui_need_npm_install(tui_dir):
|
||||
npm = _node_bin("npm")
|
||||
@@ -1444,7 +1533,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
print("Installing TUI dependencies…")
|
||||
result = subprocess.run(
|
||||
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
|
||||
cwd=str(tui_dir),
|
||||
cwd=str(_workspace_root(tui_dir)),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
@@ -1731,9 +1820,34 @@ def _sync_bundled_skills_quietly() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_use_tui(args) -> bool:
|
||||
"""Decide whether to launch the TUI for a chat/bare invocation.
|
||||
|
||||
Precedence (highest first):
|
||||
1. ``--cli`` flag → always classic REPL
|
||||
2. ``--tui`` flag / ``HERMES_TUI=1`` → always TUI
|
||||
3. ``display.interface`` config value ("cli" | "tui")
|
||||
4. default → classic REPL
|
||||
|
||||
Explicit flags always win over config so muscle memory and scripts keep
|
||||
working regardless of the configured default.
|
||||
"""
|
||||
if getattr(args, "cli", False):
|
||||
return False
|
||||
if getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1":
|
||||
return True
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
iface = (load_config().get("display", {}) or {}).get("interface", "cli")
|
||||
return isinstance(iface, str) and iface.strip().lower() == "tui"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def cmd_chat(args):
|
||||
"""Run interactive chat CLI."""
|
||||
use_tui = getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1"
|
||||
use_tui = _resolve_use_tui(args)
|
||||
|
||||
# Resolve --continue into --resume with the latest session or by name
|
||||
continue_val = getattr(args, "continue_last", None)
|
||||
@@ -6604,7 +6718,6 @@ def _web_ui_build_needed(web_dir: Path) -> bool:
|
||||
return True
|
||||
for meta in (
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"yarn.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"vite.config.ts",
|
||||
@@ -6613,6 +6726,10 @@ def _web_ui_build_needed(web_dir: Path) -> bool:
|
||||
mp = web_dir / meta
|
||||
if mp.exists() and mp.stat().st_mtime > dist_mtime:
|
||||
return True
|
||||
# Workspace root lockfile (single package-lock.json covers all workspaces).
|
||||
root_lock = project_root / "package-lock.json"
|
||||
if root_lock.exists() and root_lock.stat().st_mtime > dist_mtime:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -6809,7 +6926,11 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
if text:
|
||||
_say(text)
|
||||
|
||||
r1 = _run_npm_install_deterministic(npm, web_dir, extra_args=("--silent",))
|
||||
r1 = _run_npm_install_deterministic(
|
||||
npm,
|
||||
_workspace_root(web_dir),
|
||||
extra_args=("--silent",),
|
||||
)
|
||||
if r1.returncode != 0:
|
||||
_say(
|
||||
f" {'✗' if fatal else '⚠'} Web UI npm install failed"
|
||||
@@ -7070,6 +7191,45 @@ def _desktop_macos_relaunchable_fixup(desktop_dir: Path) -> None:
|
||||
except Exception as exc:
|
||||
print(f" (warning: macOS relaunch fixup skipped: {exc})")
|
||||
|
||||
|
||||
def _desktop_linux_sandbox_fixup(packaged_executable: Path) -> bool:
|
||||
"""Configure Electron's Linux SUID sandbox helper when required."""
|
||||
if sys.platform != "linux":
|
||||
return True
|
||||
|
||||
sandbox = packaged_executable.parent / "chrome-sandbox"
|
||||
if not sandbox.exists():
|
||||
print(f"✗ Hermes Desktop is missing Electron's Linux sandbox helper: {sandbox}")
|
||||
return False
|
||||
|
||||
# Reject symlinks — chown/chmod must not follow an attacker-controlled
|
||||
# link to an arbitrary path. Use lstat() so we inspect the link itself
|
||||
# rather than the target, and require a regular file.
|
||||
try:
|
||||
sandbox_lstat = sandbox.lstat()
|
||||
except OSError:
|
||||
print(f"✗ Cannot stat Electron's Linux sandbox helper: {sandbox}")
|
||||
return False
|
||||
if not stat.S_ISREG(sandbox_lstat.st_mode):
|
||||
print(f"✗ Electron's Linux sandbox helper is not a regular file: {sandbox}")
|
||||
return False
|
||||
|
||||
if sandbox_lstat.st_uid == 0 and stat.S_IMODE(sandbox_lstat.st_mode) == 0o4755:
|
||||
return True
|
||||
|
||||
sudo = shutil.which("sudo")
|
||||
if not sudo:
|
||||
print("✗ Hermes Desktop requires sudo to configure Electron's Linux sandbox helper.")
|
||||
return False
|
||||
|
||||
print("→ Configuring Electron Linux sandbox helper (sudo required)...")
|
||||
for command in ([sudo, "chown", "root:root", str(sandbox)], [sudo, "chmod", "4755", str(sandbox)]):
|
||||
if subprocess.run(command, check=False).returncode != 0:
|
||||
print(f"✗ Failed to configure Electron's Linux sandbox helper: {sandbox}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def cmd_gui(args: argparse.Namespace):
|
||||
"""Build and launch the native Electron desktop GUI."""
|
||||
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
|
||||
@@ -7195,6 +7355,9 @@ def cmd_gui(args: argparse.Namespace):
|
||||
print(" Expected an unpacked Electron app for the current OS.")
|
||||
sys.exit(1)
|
||||
|
||||
if not _desktop_linux_sandbox_fixup(packaged_executable):
|
||||
sys.exit(1)
|
||||
|
||||
print(f"→ Launching packaged Hermes Desktop: {packaged_executable}")
|
||||
launch_result = subprocess.run([str(packaged_executable)], cwd=desktop_dir, env=env, check=False)
|
||||
sys.exit(launch_result.returncode)
|
||||
@@ -7636,8 +7799,21 @@ def _update_via_zip(args):
|
||||
# individually so update does not silently strip working capabilities.
|
||||
print("→ Updating Python dependencies...")
|
||||
|
||||
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv
|
||||
|
||||
# Keep managed uv current — runs `uv self update` if we already have one.
|
||||
update_managed_uv()
|
||||
|
||||
uv_bin, fresh_bootstrap = ensure_uv()
|
||||
# First-time managed uv install on an existing checkout: the old venv
|
||||
# may point to a Python without FTS5. Rebuild it so the new managed
|
||||
# uv provides a fresh interpreter with FTS5 guaranteed.
|
||||
if fresh_bootstrap and uv_bin:
|
||||
rebuild_venv(uv_bin, PROJECT_ROOT / "venv")
|
||||
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
|
||||
if not uv_bin:
|
||||
uv_bin = _ensure_uv_for_termux(pip_cmd)
|
||||
if uv_bin:
|
||||
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
|
||||
if _is_termux_env(uv_env):
|
||||
@@ -8737,16 +8913,27 @@ def _install_psutil_android_compat(
|
||||
|
||||
|
||||
def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
|
||||
"""Best-effort uv bootstrap on Termux for faster update installs."""
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin or not _is_termux_env():
|
||||
return uv_bin
|
||||
"""Best-effort uv bootstrap on Termux for faster update installs.
|
||||
|
||||
The normal path (``ensure_uv()`` in managed_uv) installs the managed
|
||||
standalone uv into ``$HERMES_HOME/bin/uv``, but on Termux the official
|
||||
installer may not work (glibc vs bionic). Fall back to ``pip install uv``
|
||||
which gets a Termux-compatible binary.
|
||||
"""
|
||||
from hermes_cli.managed_uv import resolve_uv
|
||||
|
||||
existing = resolve_uv()
|
||||
if existing:
|
||||
return existing
|
||||
if not _is_termux_env():
|
||||
return None
|
||||
try:
|
||||
print(" → Termux detected: trying to install uv for faster dependency updates...")
|
||||
subprocess.run(pip_cmd + ["install", "uv"], cwd=PROJECT_ROOT, check=False)
|
||||
except Exception:
|
||||
pass
|
||||
return shutil.which("uv")
|
||||
# After pip install, check managed path first, then PATH
|
||||
return resolve_uv() or shutil.which("uv")
|
||||
|
||||
|
||||
def _update_node_dependencies() -> None:
|
||||
@@ -8754,45 +8941,48 @@ def _update_node_dependencies() -> None:
|
||||
if not npm:
|
||||
return
|
||||
|
||||
paths = (
|
||||
("repo root", PROJECT_ROOT),
|
||||
("ui-tui", PROJECT_ROOT / "ui-tui"),
|
||||
)
|
||||
if not any((path / "package.json").exists() for _, path in paths):
|
||||
if not (PROJECT_ROOT / "package.json").exists():
|
||||
return
|
||||
|
||||
# With a single workspace lockfile the root install would cover ALL
|
||||
# workspaces — but apps/desktop pulls in Electron as a devDependency,
|
||||
# and its postinstall downloads a ~200MB binary. Most users don't
|
||||
# need desktop during `hermes update`, so we install root-only first
|
||||
# then add just the workspaces the CLI/TUI/web build actually requires.
|
||||
# Desktop deps are installed on demand by the desktop launcher
|
||||
# (see _desktop_build_needed).
|
||||
print("→ Updating Node.js dependencies...")
|
||||
for label, path in paths:
|
||||
if not (path / "package.json").exists():
|
||||
continue
|
||||
extra_args = ["--no-fund", "--no-audit", "--progress=false"]
|
||||
|
||||
# Stream npm output (no `--silent`, no `capture_output`) so any
|
||||
# optional dependency postinstall scripts (e.g. `agent-browser`'s
|
||||
# Chromium fetch on first install) print progress instead of
|
||||
# appearing to hang silently for minutes (#18840). The
|
||||
# `_UpdateOutputStream` wrapper installed by the updater mirrors
|
||||
# streamed output to ``~/.hermes/logs/update.log`` so nothing is lost.
|
||||
#
|
||||
# The repo root install also passes `--workspaces=false` so npm
|
||||
# does not recursively install every `apps/*` workspace (dashboard,
|
||||
# desktop, shared) — those are installed/built on demand via
|
||||
# `_build_web_ui()` and the desktop launchers.
|
||||
extra_args = ["--no-fund", "--no-audit", "--progress=false"]
|
||||
if path == PROJECT_ROOT:
|
||||
extra_args.append("--workspaces=false")
|
||||
# Step 1: root install (no workspace recursion).
|
||||
root_args = [*extra_args, "--workspaces=false"]
|
||||
root_result = _run_npm_install_deterministic(
|
||||
npm,
|
||||
PROJECT_ROOT,
|
||||
extra_args=tuple(root_args),
|
||||
capture_output=False,
|
||||
)
|
||||
if root_result.returncode != 0:
|
||||
print(" ⚠ npm install failed in repo root")
|
||||
stderr = (root_result.stderr or "").strip() if root_result.stderr else ""
|
||||
if stderr:
|
||||
print(f" {stderr.splitlines()[-1]}")
|
||||
return
|
||||
|
||||
result = _run_npm_install_deterministic(
|
||||
npm,
|
||||
path,
|
||||
extra_args=tuple(extra_args),
|
||||
capture_output=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(f" ✓ {label}")
|
||||
continue
|
||||
|
||||
print(f" ⚠ npm install failed in {label}")
|
||||
stderr = (result.stderr or "").strip() if result.stderr else ""
|
||||
# Step 2: install only the workspaces update needs (ui-tui, web).
|
||||
# --workspace selects specific workspaces; the rest (desktop) are skipped.
|
||||
ws_args = [*extra_args, "--workspace", "ui-tui", "--workspace", "web"]
|
||||
ws_result = _run_npm_install_deterministic(
|
||||
npm,
|
||||
PROJECT_ROOT,
|
||||
extra_args=tuple(ws_args),
|
||||
capture_output=False,
|
||||
)
|
||||
if ws_result.returncode == 0:
|
||||
print(" ✓ repo root + ui-tui, web workspaces (desktop skipped)")
|
||||
else:
|
||||
print(" ⚠ npm workspace install failed")
|
||||
stderr = (ws_result.stderr or "").strip() if ws_result.stderr else ""
|
||||
if stderr:
|
||||
print(f" {stderr.splitlines()[-1]}")
|
||||
|
||||
@@ -9394,7 +9584,12 @@ def _cmd_update_pip(args):
|
||||
print(f"→ Current version: {__version__}")
|
||||
print("→ Checking PyPI for updates...")
|
||||
|
||||
uv = shutil.which("uv")
|
||||
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
|
||||
|
||||
# Keep managed uv current before using it.
|
||||
update_managed_uv()
|
||||
|
||||
uv, _fresh_bootstrap = ensure_uv()
|
||||
in_venv = sys.prefix != sys.base_prefix
|
||||
# pipx-managed installs live under .../pipx/venvs/<name>/...
|
||||
pipx_managed = "pipx" in sys.prefix.split(os.sep)
|
||||
@@ -9409,7 +9604,8 @@ def _cmd_update_pip(args):
|
||||
|
||||
if is_uv_tool_install():
|
||||
if not uv:
|
||||
print("✗ Detected a uv-tool install but `uv` is not on PATH; install uv and retry.")
|
||||
print("✗ Detected a uv-tool install but managed uv install failed.")
|
||||
print(" Install uv manually: https://docs.astral.sh/uv/getting-started/installation/")
|
||||
sys.exit(1)
|
||||
cmd = [uv, "tool", "upgrade", "hermes-agent"]
|
||||
elif pipx_managed and pipx:
|
||||
@@ -9805,8 +10001,21 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# breaks on this machine, keep base deps and reinstall the remaining extras
|
||||
# individually so update does not silently strip working capabilities.
|
||||
print("→ Updating Python dependencies...")
|
||||
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv
|
||||
|
||||
# Keep managed uv current — runs `uv self update` if we already have one.
|
||||
update_managed_uv()
|
||||
|
||||
uv_bin, fresh_bootstrap = ensure_uv()
|
||||
# First-time managed uv install on an existing checkout: the old venv
|
||||
# may point to a Python without FTS5. Rebuild it so the new managed
|
||||
# uv provides a fresh interpreter with FTS5 guaranteed.
|
||||
if fresh_bootstrap and uv_bin:
|
||||
rebuild_venv(uv_bin, PROJECT_ROOT / "venv")
|
||||
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
|
||||
if not uv_bin:
|
||||
uv_bin = _ensure_uv_for_termux(pip_cmd)
|
||||
install_group = "all"
|
||||
|
||||
if uv_bin:
|
||||
@@ -11852,7 +12061,10 @@ def _try_termux_fast_cli_launch() -> bool:
|
||||
argv = sys.argv[1:]
|
||||
if "-h" in argv or "--help" in argv:
|
||||
return False
|
||||
if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv:
|
||||
# Let the TUI fast path (or full dispatch) handle anything that resolves to
|
||||
# the TUI — explicit --tui/env or display.interface=tui. `--cli` forces this
|
||||
# to stay False so the classic fast path still runs.
|
||||
if _wants_tui_early(argv):
|
||||
return False
|
||||
|
||||
if _is_termux_fast_version_argv(argv):
|
||||
@@ -11927,7 +12139,7 @@ def _try_termux_fast_tui_launch() -> bool:
|
||||
if "-h" in sys.argv[1:] or "--help" in sys.argv[1:]:
|
||||
return False
|
||||
|
||||
wants_tui = os.environ.get("HERMES_TUI") == "1" or "--tui" in sys.argv[1:]
|
||||
wants_tui = _wants_tui_early(sys.argv[1:])
|
||||
if not wants_tui:
|
||||
return False
|
||||
|
||||
@@ -11946,7 +12158,7 @@ def _try_termux_fast_tui_launch() -> bool:
|
||||
return False
|
||||
if getattr(args, "command", None) not in {None, "chat"}:
|
||||
return False
|
||||
if not (getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1"):
|
||||
if not _resolve_use_tui(args):
|
||||
return False
|
||||
|
||||
cmd_chat(args)
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Managed uv — one path, no guessing.
|
||||
|
||||
Hermes owns its own uv binary at ``$HERMES_HOME/bin/uv`` (or ``uv.exe`` on
|
||||
Windows). Every code path that needs uv resolves it from that single location.
|
||||
If the binary is missing, ``ensure_uv()`` bootstraps it via the official
|
||||
standalone installer with ``UV_UNMANAGED_INSTALL`` / ``UV_INSTALL_DIR`` pointed
|
||||
at ``$HERMES_HOME/bin`` so the installer writes directly there — no PATH
|
||||
probing, no conda guards, no multi-location resolution chains.
|
||||
|
||||
When ``ensure_uv()`` bootstraps uv for the first time (i.e. there was no
|
||||
managed uv before), it returns ``(path, True)`` instead of just ``path``.
|
||||
Callers in the update path use that signal to nuke and recreate the venv
|
||||
with the now-current managed uv, guaranteeing a Python with FTS5.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def managed_uv_path() -> Path:
|
||||
"""Return the path where Hermes keeps *its* uv binary.
|
||||
|
||||
``$HERMES_HOME/bin/uv`` on POSIX, ``$HERMES_HOME\\bin\\uv.exe`` on
|
||||
Windows. The directory may not exist yet — callers should use
|
||||
``ensure_uv()`` to bootstrap it.
|
||||
"""
|
||||
home = get_hermes_home()
|
||||
if platform.system() == "Windows":
|
||||
return home / "bin" / "uv.exe"
|
||||
return home / "bin" / "uv"
|
||||
|
||||
|
||||
def resolve_uv() -> Optional[str]:
|
||||
"""Return the managed uv path if it exists, else ``None``.
|
||||
|
||||
No side effects — pure lookup.
|
||||
"""
|
||||
p = managed_uv_path()
|
||||
if p.is_file() and os.access(p, os.X_OK):
|
||||
return str(p)
|
||||
return None
|
||||
|
||||
|
||||
def ensure_uv() -> Tuple[Optional[str], bool]:
|
||||
"""Return the managed uv path, installing it first if necessary.
|
||||
|
||||
Returns ``(path, freshly_bootstrapped)`` where *freshly_bootstrapped* is
|
||||
``True`` when we just installed managed uv for the first time (there was
|
||||
no managed uv before this call). Callers can use that signal to rebuild
|
||||
the venv so Python is guaranteed to have FTS5.
|
||||
|
||||
On failure returns ``(None, False)`` (never raises) so callers can fall
|
||||
back to pip gracefully.
|
||||
"""
|
||||
existing = resolve_uv()
|
||||
if existing:
|
||||
return (existing, False)
|
||||
|
||||
target = managed_uv_path()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f" → Installing managed uv into {target.parent} ...")
|
||||
|
||||
try:
|
||||
_install_uv(target)
|
||||
except Exception as exc:
|
||||
logger.warning("Managed uv install failed: %s", exc)
|
||||
print(f" ✗ Failed to install managed uv: {exc}")
|
||||
return (None, False)
|
||||
|
||||
# Verify
|
||||
result = resolve_uv()
|
||||
if result:
|
||||
version = subprocess.run(
|
||||
[result, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
).stdout.strip()
|
||||
print(f" ✓ Managed uv installed ({version})")
|
||||
else:
|
||||
print(" ✗ Managed uv install appeared to succeed but binary not found")
|
||||
return (result, result is not None)
|
||||
|
||||
|
||||
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
|
||||
"""Nuke and recreate the venv with managed uv.
|
||||
|
||||
Called when managed uv is first bootstrapped on an existing install — the
|
||||
old venv may point to a Python without FTS5, so we rebuild it with a
|
||||
fresh interpreter from the current managed uv. Returns ``True`` on
|
||||
success.
|
||||
"""
|
||||
if venv_dir.exists():
|
||||
print(f" → Rebuilding venv (old Python may lack FTS5)...")
|
||||
shutil.rmtree(venv_dir, ignore_errors=True)
|
||||
|
||||
result = subprocess.run(
|
||||
[uv_bin, "venv", str(venv_dir), "--python", python_version],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python"
|
||||
py_ver = subprocess.run(
|
||||
[str(venv_python), "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
).stdout.strip()
|
||||
print(f" ✓ venv rebuilt ({py_ver})")
|
||||
return True
|
||||
else:
|
||||
logger.warning("venv rebuild failed: %s", result.stderr)
|
||||
print(f" ✗ venv rebuild failed: {result.stderr.strip()}")
|
||||
return False
|
||||
|
||||
|
||||
def update_managed_uv() -> Optional[str]:
|
||||
"""Run ``uv self update`` on the managed uv binary.
|
||||
|
||||
Call this during ``hermes update`` so the managed copy stays current.
|
||||
Returns the managed path on success, ``None`` if uv isn't available or
|
||||
the self-update fails (non-fatal — the old version still works).
|
||||
"""
|
||||
existing = resolve_uv()
|
||||
if not existing:
|
||||
# Not installed yet — ensure_uv() will handle that elsewhere.
|
||||
return None
|
||||
|
||||
result = subprocess.run(
|
||||
[existing, "self", "update"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
version = subprocess.run(
|
||||
[existing, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
).stdout.strip()
|
||||
print(f" ✓ Managed uv updated ({version})")
|
||||
else:
|
||||
# Non-fatal — old uv still works fine.
|
||||
logger.debug("uv self update failed (rc=%d): %s", result.returncode, result.stderr)
|
||||
return existing
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Installer internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _install_uv(target: Path) -> None:
|
||||
"""Bootstrap uv into *target* using the official standalone installer.
|
||||
|
||||
Uses ``UV_UNMANAGED_INSTALL`` (POSIX) or ``UV_INSTALL_DIR`` (Windows)
|
||||
so the astral installer writes the binary directly into
|
||||
``$HERMES_HOME/bin/`` instead of ``~/.local/bin/``.
|
||||
"""
|
||||
system = platform.system()
|
||||
env = {
|
||||
**os.environ,
|
||||
# Tell the astral installer to drop the binary in our dir, not
|
||||
# ~/.local/bin. UV_UNMANAGED_INSTALL is the POSIX env var; Windows
|
||||
# uses UV_INSTALL_DIR.
|
||||
"UV_UNMANAGED_INSTALL": str(target.parent),
|
||||
"UV_INSTALL_DIR": str(target.parent),
|
||||
}
|
||||
|
||||
if system == "Windows":
|
||||
_install_uv_windows(env)
|
||||
else:
|
||||
_install_uv_posix(env)
|
||||
|
||||
|
||||
def _install_uv_posix(env: dict[str, str]) -> None:
|
||||
"""Download + sh the POSIX installer (two-stage to avoid curl|sh pitfalls)."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".sh", delete=False) as f:
|
||||
installer_path = f.name
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["curl", "-LsSf", "https://astral.sh/uv/install.sh", "-o", installer_path],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["sh", installer_path],
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(installer_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _install_uv_windows(env: dict[str, str]) -> None:
|
||||
"""Invoke the PowerShell installer."""
|
||||
cmd = (
|
||||
'irm https://astral.sh/uv/install.ps1 | iex'
|
||||
)
|
||||
subprocess.run(
|
||||
["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd],
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
@@ -1868,19 +1868,21 @@ def model_supports_fast_mode(model_id: Optional[str]) -> bool:
|
||||
|
||||
|
||||
def _is_anthropic_fast_model(model_id: Optional[str]) -> bool:
|
||||
"""Return True if the model is a Claude model eligible for Anthropic Fast Mode.
|
||||
"""Return True if the model accepts the Anthropic Fast Mode ``speed`` param.
|
||||
|
||||
Fast mode is currently supported on Claude Opus 4.6 only. Per Anthropic's
|
||||
docs (https://platform.claude.com/docs/en/build-with-claude/fast-mode):
|
||||
"Fast mode is currently supported on Opus 4.6 only. Sending speed: fast
|
||||
with an unsupported model returns an error." Opus 4.7 explicitly rejects
|
||||
the ``speed`` parameter with HTTP 400.
|
||||
This gates the *speed=fast request parameter*, which Anthropic supports on
|
||||
Opus 4.6 only (Opus 4.7 explicitly 400s). It is deliberately NOT a general
|
||||
"is this a fast model" check: for Opus 4.8 the fast offering is a SEPARATE
|
||||
model id (``…-opus-4.8-fast``) selected via the model field, not the speed
|
||||
parameter — see ``agent.anthropic_adapter._supports_fast_mode`` and its
|
||||
test. Keep this in lock-step with that adapter gate so the UI never shows a
|
||||
Fast toggle that the runtime would silently drop.
|
||||
"""
|
||||
raw = _strip_vendor_prefix(str(model_id or ""))
|
||||
base = raw.split(":")[0]
|
||||
if not base.startswith("claude-"):
|
||||
return False
|
||||
# Only Opus 4.6 supports fast mode at present.
|
||||
# Only Opus 4.6 supports the speed=fast parameter at present.
|
||||
return "opus-4-6" in base or "opus-4.6" in base
|
||||
|
||||
|
||||
|
||||
+20
-17
@@ -202,6 +202,13 @@ TOOL_CATEGORIES = {
|
||||
"name": "Text-to-Speech",
|
||||
"icon": "🔊",
|
||||
"providers": [
|
||||
{
|
||||
"name": "Microsoft Edge TTS",
|
||||
"badge": "★ recommended · free",
|
||||
"tag": "Good quality, no API key needed",
|
||||
"env_vars": [],
|
||||
"tts_provider": "edge",
|
||||
},
|
||||
{
|
||||
"name": "Nous Subscription",
|
||||
"badge": "subscription",
|
||||
@@ -212,13 +219,6 @@ TOOL_CATEGORIES = {
|
||||
"managed_nous_feature": "tts",
|
||||
"override_env_vars": ["VOICE_TOOLS_OPENAI_KEY", "OPENAI_API_KEY"],
|
||||
},
|
||||
{
|
||||
"name": "Microsoft Edge TTS",
|
||||
"badge": "★ recommended · free",
|
||||
"tag": "Good quality, no API key needed",
|
||||
"env_vars": [],
|
||||
"tts_provider": "edge",
|
||||
},
|
||||
{
|
||||
"name": "OpenAI TTS",
|
||||
"badge": "paid",
|
||||
@@ -406,15 +406,26 @@ TOOL_CATEGORIES = {
|
||||
# Per-provider rows for Browserbase, Browser Use, and Firecrawl are
|
||||
# injected at runtime from plugins.browser.<vendor>.provider via
|
||||
# _plugin_browser_providers() in _visible_providers(). Only
|
||||
# non-provider UX setup-flow rows remain here:
|
||||
# non-provider UX setup-flow rows remain here. "Local Browser" is
|
||||
# listed FIRST so it is the default-highlighted (index 0) choice on a
|
||||
# fresh install — pressing Enter must land on the free, no-key local
|
||||
# backend, never on the paid Nous Subscription gateway row:
|
||||
# - "Local Browser" — non-cloud option, no CloudBrowserProvider.
|
||||
# - "Nous Subscription (Browser Use cloud)" — managed Browser Use
|
||||
# billed via Nous subscription (requires_nous_auth +
|
||||
# override_env_vars). Uses the browser-use plugin as the
|
||||
# underlying backend but has a distinct setup UX.
|
||||
# - "Local Browser" — non-cloud option, no CloudBrowserProvider.
|
||||
# - "Camofox" — anti-detection local Firefox; short-circuits the
|
||||
# cloud-provider dispatch path via _is_camofox_mode().
|
||||
"providers": [
|
||||
{
|
||||
"name": "Local Browser",
|
||||
"badge": "★ recommended · free",
|
||||
"tag": "Headless Chromium, no API key needed",
|
||||
"env_vars": [],
|
||||
"browser_provider": "local",
|
||||
"post_setup": "agent_browser",
|
||||
},
|
||||
{
|
||||
"name": "Nous Subscription (Browser Use cloud)",
|
||||
"badge": "subscription",
|
||||
@@ -426,14 +437,6 @@ TOOL_CATEGORIES = {
|
||||
"override_env_vars": ["BROWSER_USE_API_KEY"],
|
||||
"post_setup": "agent_browser",
|
||||
},
|
||||
{
|
||||
"name": "Local Browser",
|
||||
"badge": "★ recommended · free",
|
||||
"tag": "Headless Chromium, no API key needed",
|
||||
"env_vars": [],
|
||||
"browser_provider": "local",
|
||||
"post_setup": "agent_browser",
|
||||
},
|
||||
{
|
||||
"name": "Camofox",
|
||||
"badge": "free · local",
|
||||
|
||||
+433
-45
@@ -9,6 +9,8 @@ Usage:
|
||||
python -m hermes_cli.main web --port 8080
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
@@ -84,7 +86,43 @@ except ImportError:
|
||||
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="Hermes Agent", version=__version__)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard)
|
||||
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
|
||||
# the chat tab generates on mount; entries auto-evict when the last subscriber
|
||||
# drops AND the publisher has disconnected.
|
||||
#
|
||||
# State lives on app.state (not module-level globals) so that asyncio.Lock is
|
||||
# created on the running event loop during lifespan startup. A module-level
|
||||
# asyncio.Lock() binds to whatever loop was active at import time, which breaks
|
||||
# when the same module is used across TestClient instances or uvicorn reloads.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: "FastAPI"):
|
||||
app.state.event_channels = {} # dict[str, set]
|
||||
app.state.event_lock = asyncio.Lock()
|
||||
yield
|
||||
|
||||
|
||||
def _get_event_state(app: "FastAPI"):
|
||||
"""Return (event_channels, event_lock) from app.state.
|
||||
|
||||
Lazily initialises the state if the lifespan hasn't run (e.g. when
|
||||
TestClient is constructed without a ``with`` block). The lifespan
|
||||
path is preferred because it guarantees the Lock is created on the
|
||||
correct event loop, but the lazy path lets existing non-``with``
|
||||
TestClient usages keep working.
|
||||
"""
|
||||
try:
|
||||
return app.state.event_channels, app.state.event_lock
|
||||
except AttributeError:
|
||||
app.state.event_channels = {}
|
||||
app.state.event_lock = asyncio.Lock()
|
||||
return app.state.event_channels, app.state.event_lock
|
||||
|
||||
|
||||
app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session token for protecting sensitive endpoints (reveal).
|
||||
@@ -1627,7 +1665,9 @@ def get_model_options():
|
||||
try:
|
||||
from hermes_cli.inventory import build_models_payload, load_picker_context
|
||||
|
||||
return build_models_payload(load_picker_context(), max_models=50, pricing=True)
|
||||
return build_models_payload(
|
||||
load_picker_context(), max_models=50, pricing=True, capabilities=True
|
||||
)
|
||||
except Exception:
|
||||
_log.exception("GET /api/model/options failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to list model options")
|
||||
@@ -2935,6 +2975,17 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
|
||||
"docs_url": "https://www.minimax.io",
|
||||
"status_fn": None, # dispatched via auth.get_minimax_oauth_auth_status
|
||||
},
|
||||
{
|
||||
"id": "xai-oauth",
|
||||
"name": "xAI Grok OAuth (SuperGrok / Premium+)",
|
||||
# Loopback PKCE: the desktop's local backend binds a 127.0.0.1
|
||||
# callback server, the client opens the browser, and the redirect
|
||||
# lands back on the loopback listener — no code to copy/paste.
|
||||
"flow": "loopback",
|
||||
"cli_command": "hermes auth add xai-oauth",
|
||||
"docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth",
|
||||
"status_fn": None, # dispatched via auth.get_xai_oauth_auth_status
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -2988,6 +3039,20 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
|
||||
"expires_at": raw.get("expires_at"),
|
||||
"has_refresh_token": True,
|
||||
}
|
||||
if provider_id == "xai-oauth":
|
||||
raw = hauth.get_xai_oauth_auth_status()
|
||||
# source_label is meant to be a human-readable origin (auth-store
|
||||
# path / credential source), not the internal auth_mode string
|
||||
# ("oauth_pkce"). Prefer the store path, then the source slug.
|
||||
return {
|
||||
"logged_in": bool(raw.get("logged_in")),
|
||||
"source": raw.get("source") or "xai_oauth",
|
||||
"source_label": raw.get("auth_store") or raw.get("source") or "xAI Grok OAuth",
|
||||
"token_preview": _truncate_token(raw.get("api_key")),
|
||||
"expires_at": None,
|
||||
"has_refresh_token": True,
|
||||
"last_refresh": raw.get("last_refresh"),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"logged_in": False, "error": str(e)}
|
||||
return {"logged_in": False}
|
||||
@@ -3000,7 +3065,7 @@ async def list_oauth_providers():
|
||||
Response shape (per provider):
|
||||
id stable identifier (used in DELETE path)
|
||||
name human label
|
||||
flow "pkce" | "device_code" | "external"
|
||||
flow "pkce" | "device_code" | "external" | "loopback"
|
||||
cli_command fallback CLI command for users to run manually
|
||||
docs_url external docs/portal link for the "Learn more" link
|
||||
status:
|
||||
@@ -3100,6 +3165,19 @@ async def disconnect_oauth_provider(provider_id: str, request: Request):
|
||||
# 4. On "approved" the background thread has already saved creds; UI
|
||||
# refreshes the providers list.
|
||||
#
|
||||
# Loopback PKCE (xAI Grok):
|
||||
# 1. POST /api/providers/oauth/xai-oauth/start
|
||||
# → server binds a 127.0.0.1 callback listener, builds the xAI
|
||||
# authorize URL, spawns a background worker waiting on the redirect
|
||||
# → returns { session_id, flow: "loopback", auth_url, expires_in }
|
||||
# 2. UI opens auth_url in the browser. There is NO user_code/code to
|
||||
# paste — the redirect lands back on the loopback listener.
|
||||
# 3. UI polls GET /api/providers/oauth/{provider}/poll/{session_id}
|
||||
# (same endpoint as device_code) until status != "pending".
|
||||
# 4. The worker exchanges the code, persists creds, sets "approved".
|
||||
# DELETE /sessions/{id} cancels: the worker bails before persisting
|
||||
# and the callback server is shut down to free the port immediately.
|
||||
#
|
||||
# Sessions are kept in-memory only (single-process FastAPI) and time out
|
||||
# after 15 minutes. A periodic cleanup runs on each /start call to GC
|
||||
# expired sessions so the dict doesn't grow without bound.
|
||||
@@ -3483,6 +3561,220 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
|
||||
raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow")
|
||||
|
||||
|
||||
# xAI Grok OAuth uses a loopback-redirect PKCE flow (RFC 8252). Unlike the
|
||||
# device-code providers there is no user_code to display: the local backend
|
||||
# binds a 127.0.0.1 callback server, the client opens the authorize URL in
|
||||
# the browser, and the redirect lands back on the loopback listener. The
|
||||
# background worker waits for that callback, exchanges the code, and persists
|
||||
# the tokens exactly like `hermes auth add xai-oauth`.
|
||||
_XAI_LOOPBACK_TIMEOUT_SECONDS = 300.0
|
||||
|
||||
|
||||
def _start_xai_loopback_flow() -> Dict[str, Any]:
|
||||
"""Begin the xAI loopback PKCE flow.
|
||||
|
||||
Binds the local callback server, builds the authorize URL, and spawns a
|
||||
background worker that waits for the redirect and finishes the exchange.
|
||||
Returns the authorize URL for the client to open in the browser.
|
||||
"""
|
||||
from hermes_cli import auth as hauth
|
||||
|
||||
discovery = hauth._xai_oauth_discovery()
|
||||
server, thread, callback_result, redirect_uri = hauth._xai_start_callback_server()
|
||||
try:
|
||||
hauth._xai_validate_loopback_redirect_uri(redirect_uri)
|
||||
verifier = hauth._oauth_pkce_code_verifier()
|
||||
challenge = hauth._oauth_pkce_code_challenge(verifier)
|
||||
state = secrets.token_hex(16)
|
||||
nonce = secrets.token_hex(16)
|
||||
authorize_url = hauth._xai_oauth_build_authorize_url(
|
||||
authorization_endpoint=discovery["authorization_endpoint"],
|
||||
redirect_uri=redirect_uri,
|
||||
code_challenge=challenge,
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
)
|
||||
except Exception:
|
||||
# Binding succeeded but URL construction failed — release the socket
|
||||
# and join the serving thread so we don't leak a listener (or a
|
||||
# lingering daemon thread) on the loopback port.
|
||||
try:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
thread.join(timeout=1.0)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
sid, sess = _new_oauth_session("xai-oauth", "loopback")
|
||||
sess["server"] = server
|
||||
sess["thread"] = thread
|
||||
sess["callback_result"] = callback_result
|
||||
sess["redirect_uri"] = redirect_uri
|
||||
sess["verifier"] = verifier
|
||||
sess["challenge"] = challenge
|
||||
sess["state"] = state
|
||||
sess["token_endpoint"] = discovery["token_endpoint"]
|
||||
sess["discovery"] = discovery
|
||||
sess["expires_at"] = time.time() + _XAI_LOOPBACK_TIMEOUT_SECONDS
|
||||
threading.Thread(
|
||||
target=_xai_loopback_worker, args=(sid,), daemon=True,
|
||||
name=f"oauth-xai-{sid[:6]}",
|
||||
).start()
|
||||
return {
|
||||
"session_id": sid,
|
||||
"flow": "loopback",
|
||||
"auth_url": authorize_url,
|
||||
"expires_in": int(_XAI_LOOPBACK_TIMEOUT_SECONDS),
|
||||
}
|
||||
|
||||
|
||||
def _xai_loopback_worker(session_id: str) -> None:
|
||||
"""Wait for the xAI loopback callback, exchange the code, persist tokens."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from hermes_cli import auth as hauth
|
||||
|
||||
with _oauth_sessions_lock:
|
||||
sess = _oauth_sessions.get(session_id)
|
||||
if not sess:
|
||||
return
|
||||
|
||||
def _fail(message: str) -> None:
|
||||
with _oauth_sessions_lock:
|
||||
s = _oauth_sessions.get(session_id)
|
||||
if s is not None:
|
||||
s["status"] = "error"
|
||||
s["error_message"] = message
|
||||
|
||||
def _cancelled() -> bool:
|
||||
# The session is removed from the registry when the user cancels
|
||||
# (DELETE /sessions/{id}). If that happened while we were blocked on
|
||||
# the callback or token exchange, abort instead of persisting tokens
|
||||
# the user no longer wants.
|
||||
with _oauth_sessions_lock:
|
||||
return session_id not in _oauth_sessions
|
||||
|
||||
try:
|
||||
callback = hauth._xai_wait_for_callback(
|
||||
sess["server"],
|
||||
sess["thread"],
|
||||
sess["callback_result"],
|
||||
timeout_seconds=_XAI_LOOPBACK_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception as exc:
|
||||
_fail(f"xAI authorization timed out: {exc}")
|
||||
return
|
||||
|
||||
if _cancelled():
|
||||
return
|
||||
|
||||
if callback.get("error"):
|
||||
detail = callback.get("error_description") or callback["error"]
|
||||
_fail(f"xAI authorization failed: {detail}")
|
||||
return
|
||||
if callback.get("state") != sess["state"]:
|
||||
_fail("xAI authorization failed: state mismatch.")
|
||||
return
|
||||
code = str(callback.get("code") or "").strip()
|
||||
if not code:
|
||||
_fail("xAI authorization failed: missing authorization code.")
|
||||
return
|
||||
|
||||
try:
|
||||
payload = hauth._xai_oauth_exchange_code_for_tokens(
|
||||
token_endpoint=sess["token_endpoint"],
|
||||
code=code,
|
||||
redirect_uri=sess["redirect_uri"],
|
||||
code_verifier=sess["verifier"],
|
||||
code_challenge=sess["challenge"],
|
||||
)
|
||||
access_token = str(payload.get("access_token", "") or "").strip()
|
||||
refresh_token = str(payload.get("refresh_token", "") or "").strip()
|
||||
if not access_token or not refresh_token:
|
||||
_fail("xAI token exchange did not return the expected tokens.")
|
||||
return
|
||||
base_url = hauth._xai_validate_inference_base_url(
|
||||
os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/")
|
||||
or os.getenv("XAI_BASE_URL", "").strip().rstrip("/"),
|
||||
fallback=hauth.DEFAULT_XAI_OAUTH_BASE_URL,
|
||||
)
|
||||
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
tokens = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"id_token": str(payload.get("id_token", "") or "").strip(),
|
||||
"expires_in": payload.get("expires_in"),
|
||||
"token_type": str(payload.get("token_type") or "Bearer").strip() or "Bearer",
|
||||
}
|
||||
if _cancelled():
|
||||
return
|
||||
hauth._save_xai_oauth_tokens(
|
||||
tokens,
|
||||
discovery=sess.get("discovery"),
|
||||
redirect_uri=sess["redirect_uri"],
|
||||
last_refresh=last_refresh,
|
||||
)
|
||||
_add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh)
|
||||
except Exception as exc:
|
||||
_fail(f"xAI token exchange failed: {exc}")
|
||||
return
|
||||
|
||||
with _oauth_sessions_lock:
|
||||
s = _oauth_sessions.get(session_id)
|
||||
if s is not None:
|
||||
s["status"] = "approved"
|
||||
_log.info("oauth/loopback: xai-oauth login completed (session=%s)", session_id)
|
||||
|
||||
|
||||
def _add_xai_oauth_pool_entry(
|
||||
access_token: str, refresh_token: str, base_url: str, last_refresh: str
|
||||
) -> None:
|
||||
"""Mirror `hermes auth add xai-oauth`'s credential-pool insert.
|
||||
|
||||
Best-effort: the auth-store write in _save_xai_oauth_tokens is the source
|
||||
of truth for runtime resolution; the pool entry only matters for the
|
||||
rotation strategy.
|
||||
"""
|
||||
try:
|
||||
import uuid
|
||||
|
||||
from agent.credential_pool import (
|
||||
PooledCredential,
|
||||
load_pool,
|
||||
AUTH_TYPE_OAUTH,
|
||||
SOURCE_MANUAL,
|
||||
)
|
||||
pool = load_pool("xai-oauth")
|
||||
existing = [
|
||||
e for e in pool.entries()
|
||||
if getattr(e, "source", "").startswith(f"{SOURCE_MANUAL}:dashboard_xai_pkce")
|
||||
]
|
||||
for e in existing:
|
||||
try:
|
||||
pool.remove_entry(getattr(e, "id", ""))
|
||||
except Exception:
|
||||
pass
|
||||
entry = PooledCredential(
|
||||
provider="xai-oauth",
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label="dashboard PKCE",
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:dashboard_xai_pkce",
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
base_url=base_url,
|
||||
last_refresh=last_refresh,
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
except Exception as e:
|
||||
_log.warning("xai-oauth pool add (dashboard) failed: %s", e)
|
||||
|
||||
|
||||
def _nous_poller(session_id: str) -> None:
|
||||
"""Background poller that drives a Nous device-code flow to completion."""
|
||||
from hermes_cli.auth import (
|
||||
@@ -3772,6 +4064,10 @@ async def start_oauth_login(provider_id: str, request: Request):
|
||||
return _start_anthropic_pkce()
|
||||
if catalog_entry["flow"] == "device_code":
|
||||
return await _start_device_code_flow(provider_id)
|
||||
if catalog_entry["flow"] == "loopback" and provider_id == "xai-oauth":
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
None, _start_xai_loopback_flow
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -3798,7 +4094,13 @@ async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Re
|
||||
|
||||
@app.get("/api/providers/oauth/{provider_id}/poll/{session_id}")
|
||||
async def poll_oauth_session(provider_id: str, session_id: str):
|
||||
"""Poll a device-code session's status (no auth — read-only state)."""
|
||||
"""Poll a session's status (no auth — read-only state).
|
||||
|
||||
Shared by the device-code flows (Nous, OpenAI Codex, MiniMax) and the
|
||||
loopback flow (xAI Grok). Both surface progress through the same
|
||||
background-worker-updated ``status`` field, so a single poll endpoint
|
||||
serves them all.
|
||||
"""
|
||||
with _oauth_sessions_lock:
|
||||
sess = _oauth_sessions.get(session_id)
|
||||
if not sess:
|
||||
@@ -3821,6 +4123,33 @@ async def cancel_oauth_session(session_id: str, request: Request):
|
||||
sess = _oauth_sessions.pop(session_id, None)
|
||||
if sess is None:
|
||||
return {"ok": False, "message": "session not found"}
|
||||
# Loopback sessions own a bound 127.0.0.1 callback server. Without an
|
||||
# explicit shutdown the worker would keep that port held until
|
||||
# _xai_wait_for_callback times out (up to 5 min). Free it immediately so
|
||||
# an orphaned listener can't block a subsequent sign-in attempt.
|
||||
if sess.get("flow") == "loopback":
|
||||
# The worker is blocked in _xai_wait_for_callback, which polls
|
||||
# callback_result rather than the server state. Flag the result as
|
||||
# cancelled so that loop returns on its next tick instead of spinning
|
||||
# until the timeout — otherwise repeated cancel/retry piles up daemon
|
||||
# threads. (_cancelled() in the worker then short-circuits before any
|
||||
# persist.)
|
||||
result = sess.get("callback_result")
|
||||
if isinstance(result, dict):
|
||||
result["error"] = result.get("error") or "cancelled"
|
||||
server = sess.get("server")
|
||||
thread = sess.get("thread")
|
||||
try:
|
||||
if server is not None:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if thread is not None:
|
||||
thread.join(timeout=1.0)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "session_id": session_id}
|
||||
|
||||
|
||||
@@ -6276,13 +6605,26 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
|
||||
|
||||
parsed = urllib.parse.urlparse(origin)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
# Packaged Electron loads the desktop renderer over file://, so its
|
||||
# WebSocket handshake carries a non-web Origin such as file:// or null.
|
||||
# DNS-rebinding attacks originate from an http(s) site; they cannot
|
||||
# forge a file:// origin and still hold the loopback session token.
|
||||
# Public/gated binds have no legitimate non-web client, so keep
|
||||
# rejecting these origins there.
|
||||
return bound_host.lower() in _LOOPBACK_HOST_VALUES
|
||||
# Packaged Electron loads the desktop renderer over a non-web origin
|
||||
# such as file://, null, or a custom app:// scheme. This helper is
|
||||
# called only AFTER _ws_auth_ok has already accepted the WS credential,
|
||||
# which is the real auth boundary in every mode:
|
||||
# * loopback bind → legacy dashboard session token
|
||||
# * non-loopback --insecure → legacy session token (Tailscale / LAN)
|
||||
# * OAuth-gated public bind → single-use, 30s-TTL, identity-bound
|
||||
# ?ticket= minted at the cookie-authed POST /api/auth/ws-ticket
|
||||
# A non-web origin can only be produced by a native client (the desktop
|
||||
# shell); a DNS-rebinding attack always arrives from an http(s) origin
|
||||
# and is still match-checked against the bound host below. So once the
|
||||
# credential check upstream has passed, the Origin guard adds nothing
|
||||
# for a non-web origin — trust it in every mode.
|
||||
#
|
||||
# (Earlier revisions restricted this to loopback, then to non-gated
|
||||
# binds; both excluded the packaged desktop talking to a remote
|
||||
# OAuth-gated gateway, whose file:// renderer origin then got rejected
|
||||
# at the WS upgrade even with a valid ticket. The ticket is the gate,
|
||||
# not the origin.)
|
||||
return True
|
||||
|
||||
if not parsed.netloc:
|
||||
return False
|
||||
@@ -6301,10 +6643,21 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
|
||||
Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query
|
||||
parameter, constant-time compared.
|
||||
|
||||
Gated (public bind, no ``--insecure``): ``?ticket=<single-use>`` query
|
||||
parameter consumed against the dashboard-auth ticket store. The legacy
|
||||
token path is unconditionally rejected in this mode (the SPA bundle
|
||||
isn't carrying the token any longer).
|
||||
Gated (public bind, no ``--insecure``): one of two credentials —
|
||||
|
||||
* ``?ticket=<single-use>`` — a browser-minted, single-use, 30s-TTL ticket
|
||||
consumed against the dashboard-auth ticket store. This is what the SPA
|
||||
(and native clients) use.
|
||||
* ``?internal=<process-credential>`` — the process-lifetime internal
|
||||
credential, used only by WS clients the server spawns itself (the
|
||||
embedded-TUI PTY child attaching to ``/api/ws`` and ``/api/pub``). It
|
||||
is multi-use and never expires so the child can reconnect, and is never
|
||||
injected into the SPA — see ``dashboard_auth.ws_tickets`` for the
|
||||
threat model.
|
||||
|
||||
The legacy ``?token=`` path is unconditionally rejected in gated mode
|
||||
(the SPA bundle isn't carrying the token any longer, and a leaked
|
||||
``_SESSION_TOKEN`` must not grant WS access once the gate is engaged).
|
||||
|
||||
Returns True if the WS should be accepted; callers close with the
|
||||
appropriate WS code (4401) on False. Audit-logs the rejection so
|
||||
@@ -6312,17 +6665,36 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
|
||||
"""
|
||||
auth_required = bool(getattr(app.state, "auth_required", False))
|
||||
if auth_required:
|
||||
ticket = ws.query_params.get("ticket", "")
|
||||
if not ticket:
|
||||
return False
|
||||
# Lazy import — keeps this function importable in test harnesses
|
||||
# that don't bring in the dashboard_auth layer.
|
||||
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
|
||||
from hermes_cli.dashboard_auth.ws_tickets import (
|
||||
TicketInvalid,
|
||||
consume_internal_credential,
|
||||
consume_ticket,
|
||||
)
|
||||
|
||||
# Server-spawned children (PTY child → /api/ws, /api/pub) present the
|
||||
# multi-use internal credential rather than a single-use ticket, so
|
||||
# they survive reconnects and slow cold boots.
|
||||
internal = ws.query_params.get("internal", "")
|
||||
if internal:
|
||||
try:
|
||||
consume_internal_credential(internal)
|
||||
return True
|
||||
except TicketInvalid as exc:
|
||||
audit_log(
|
||||
AuditEvent.WS_TICKET_REJECTED,
|
||||
reason=f"internal: {exc}",
|
||||
ip=(ws.client.host if ws.client else ""),
|
||||
path=ws.url.path,
|
||||
)
|
||||
return False
|
||||
|
||||
ticket = ws.query_params.get("ticket", "")
|
||||
if not ticket:
|
||||
return False
|
||||
|
||||
try:
|
||||
consume_ticket(ticket)
|
||||
return True
|
||||
@@ -6342,8 +6714,7 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
|
||||
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
|
||||
# the chat tab generates on mount; entries auto-evict when the last subscriber
|
||||
# drops AND the publisher has disconnected.
|
||||
_event_channels: dict[str, set] = {}
|
||||
_event_lock = asyncio.Lock()
|
||||
# (State is initialised in _lifespan on app startup — see above.)
|
||||
|
||||
|
||||
def _resolve_chat_argv(
|
||||
@@ -6399,7 +6770,16 @@ def _resolve_chat_argv(
|
||||
|
||||
|
||||
def _build_gateway_ws_url() -> Optional[str]:
|
||||
"""ws:// URL the PTY child should attach to for JSON-RPC gateway traffic."""
|
||||
"""ws:// URL the PTY child should attach to for JSON-RPC gateway traffic.
|
||||
|
||||
Loopback / ``--insecure``: ``?token=<_SESSION_TOKEN>``.
|
||||
|
||||
Gated mode: the legacy token path is rejected by ``_ws_auth_ok``, so the
|
||||
server-spawned PTY child authenticates with the process-lifetime internal
|
||||
credential (``?internal=``). It must NOT use a single-use browser ticket:
|
||||
the child reads this URL once at startup and reuses it on every reconnect,
|
||||
and a 30s-TTL ticket can expire before a slow cold boot even dials.
|
||||
"""
|
||||
host = getattr(app.state, "bound_host", None)
|
||||
port = getattr(app.state, "bound_port", None)
|
||||
|
||||
@@ -6411,7 +6791,13 @@ def _build_gateway_ws_url() -> Optional[str]:
|
||||
if ":" in host and not host.startswith("[")
|
||||
else f"{host}:{port}"
|
||||
)
|
||||
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN})
|
||||
|
||||
if getattr(app.state, "auth_required", False):
|
||||
from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential
|
||||
|
||||
qs = urllib.parse.urlencode({"internal": internal_ws_credential()})
|
||||
else:
|
||||
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN})
|
||||
|
||||
return f"ws://{netloc}/api/ws?{qs}"
|
||||
|
||||
@@ -6421,16 +6807,14 @@ def _build_sidecar_url(channel: str) -> Optional[str]:
|
||||
|
||||
Loopback / ``--insecure``: uses ``?token=<_SESSION_TOKEN>``.
|
||||
|
||||
Gated mode: mints a single-use ticket via the dashboard-auth ticket
|
||||
store (server-side mint, no HTTP round trip — the PTY child is a
|
||||
server-spawned process and we trust it). The ticket binds to the
|
||||
pseudo-user ``"pty-sidecar"`` so audit logs can distinguish these from
|
||||
browser-initiated tickets.
|
||||
|
||||
The single-use lifetime means the PTY child cannot reconnect without a
|
||||
new sidecar URL. PTY children open ``/api/pub`` once at startup; if
|
||||
reconnect semantics ever become important, this should be upgraded to
|
||||
a long-lived process-scoped token.
|
||||
Gated mode: authenticates with the process-lifetime internal credential
|
||||
(``?internal=``), the same one ``_build_gateway_ws_url`` uses. The PTY
|
||||
child is a server-spawned process we trust; the credential is multi-use
|
||||
and never expires, so the child can reconnect ``/api/pub`` without a new
|
||||
URL. (This previously minted a single-use 30s ticket, which meant the
|
||||
child could not reconnect and could miss the window on a slow cold boot.)
|
||||
Connections authenticated this way are recorded under the
|
||||
``server-internal`` identity in the audit log.
|
||||
"""
|
||||
host = getattr(app.state, "bound_host", None)
|
||||
port = getattr(app.state, "bound_port", None)
|
||||
@@ -6441,21 +6825,24 @@ def _build_sidecar_url(channel: str) -> Optional[str]:
|
||||
netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}"
|
||||
|
||||
if getattr(app.state, "auth_required", False):
|
||||
# Gated mode — mint a ticket so the WS upgrade survives _ws_auth_ok.
|
||||
from hermes_cli.dashboard_auth.ws_tickets import mint_ticket
|
||||
# Gated mode — use the internal credential so the WS upgrade survives
|
||||
# _ws_auth_ok and the child can reconnect.
|
||||
from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential
|
||||
|
||||
ticket = mint_ticket(user_id="pty-sidecar", provider="server-internal")
|
||||
qs = urllib.parse.urlencode({"ticket": ticket, "channel": channel})
|
||||
qs = urllib.parse.urlencode(
|
||||
{"internal": internal_ws_credential(), "channel": channel}
|
||||
)
|
||||
else:
|
||||
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel})
|
||||
|
||||
return f"ws://{netloc}/api/pub?{qs}"
|
||||
|
||||
|
||||
async def _broadcast_event(channel: str, payload: str) -> None:
|
||||
async def _broadcast_event(app: Any, channel: str, payload: str) -> None:
|
||||
"""Fan out one publisher frame to every subscriber on `channel`."""
|
||||
async with _event_lock:
|
||||
subs = list(_event_channels.get(channel, ()))
|
||||
event_channels, event_lock = _get_event_state(app)
|
||||
async with event_lock:
|
||||
subs = list(event_channels.get(channel, ()))
|
||||
|
||||
for sub in subs:
|
||||
try:
|
||||
@@ -6646,7 +7033,7 @@ async def pub_ws(ws: WebSocket) -> None:
|
||||
|
||||
try:
|
||||
while True:
|
||||
await _broadcast_event(channel, await ws.receive_text())
|
||||
await _broadcast_event(ws.app, channel, await ws.receive_text())
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
|
||||
@@ -6672,8 +7059,9 @@ async def events_ws(ws: WebSocket) -> None:
|
||||
|
||||
await ws.accept()
|
||||
|
||||
async with _event_lock:
|
||||
_event_channels.setdefault(channel, set()).add(ws)
|
||||
event_channels, event_lock = _get_event_state(ws.app)
|
||||
async with event_lock:
|
||||
event_channels.setdefault(channel, set()).add(ws)
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -6684,14 +7072,14 @@ async def events_ws(ws: WebSocket) -> None:
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
async with _event_lock:
|
||||
subs = _event_channels.get(channel)
|
||||
async with event_lock:
|
||||
subs = event_channels.get(channel)
|
||||
|
||||
if subs is not None:
|
||||
subs.discard(ws)
|
||||
|
||||
if not subs:
|
||||
_event_channels.pop(channel, None)
|
||||
event_channels.pop(channel, None)
|
||||
|
||||
|
||||
def _normalise_prefix(raw: Optional[str]) -> str:
|
||||
|
||||
+3
-6
@@ -452,12 +452,9 @@ class SessionDB:
|
||||
self._fts_unavailable_warned = True
|
||||
logger.warning(
|
||||
"SQLite FTS5 unavailable for %s; full-text session search "
|
||||
"disabled. This usually means Hermes is running on an "
|
||||
"unsupported install (e.g. a pip-installed or pip-managed "
|
||||
"Python whose bundled SQLite lacks FTS5) rather than a "
|
||||
"mainline install. Some features may be missing or behave "
|
||||
"differently. Install the supported way: "
|
||||
"https://hermes-agent.nousresearch.com (underlying error: %s)",
|
||||
"disabled. Run `hermes update` to rebuild the venv with a "
|
||||
"current Python (managed uv guarantees FTS5). "
|
||||
"(underlying error: %s)",
|
||||
self.db_path,
|
||||
exc,
|
||||
)
|
||||
|
||||
@@ -58,6 +58,22 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
||||
echo "ok" > $out/result
|
||||
''
|
||||
);
|
||||
|
||||
# Verify the default package builds successfully (cross-platform).
|
||||
# On Linux the runtime checks below already depend on the package,
|
||||
# but this ensures darwin builders also build it during flake check.
|
||||
build-package = pkgs.runCommand "hermes-build-package" { } ''
|
||||
echo "PASS: package built at ${hermes-agent}"
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Verify the devShell builds successfully (cross-platform).
|
||||
build-devshell = pkgs.runCommand "hermes-build-devshell" { } ''
|
||||
echo "PASS: devShell built at ${self'.devShells.default}"
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
} // lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
|
||||
# Verify binaries exist and are executable
|
||||
package-contents = pkgs.runCommand "hermes-package-contents" { } ''
|
||||
|
||||
+26
-33
@@ -8,37 +8,20 @@
|
||||
# No reimplementation of the agent resolution in this wrapper.
|
||||
{ pkgs, lib, stdenv, makeWrapper, hermesNpmLib, electron, hermesAgent, ... }:
|
||||
let
|
||||
src = ../apps;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
src = ../apps/desktop;
|
||||
# buildNpmPackage uses `npm ci` which is strict — peer deps not in the
|
||||
# lockfile cause network fetch attempts. Fetcher v2 stages the full
|
||||
# cache (including peer-only deps) so `npm ci` can resolve them offline.
|
||||
fetcherVersion = 2;
|
||||
hash = "sha256-7W9ObYz08yDMtybY8+RkUXkKVsJXINLl0qBUB91hpao=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "apps/desktop"; attr = "desktop"; pname = "hermes-desktop"; };
|
||||
|
||||
packageJson = builtins.fromJSON (builtins.readFile (src + "/desktop/package.json"));
|
||||
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/apps/desktop/package.json"));
|
||||
version = packageJson.version;
|
||||
|
||||
# Build the renderer (dist/ + electron/ + package.json).
|
||||
renderer = pkgs.buildNpmPackage (npm // {
|
||||
pname = "hermes-desktop-renderer";
|
||||
inherit src npmDeps version;
|
||||
sourceRoot = "apps/desktop";
|
||||
inherit version;
|
||||
|
||||
doCheck = false;
|
||||
# buildNpmPackage uses `npm ci` which fails on peer deps not in the
|
||||
# lockfile. npmDepsFetcherVersion=2 stages the full cache (peer deps
|
||||
# included) so the offline `npm ci` resolves them.
|
||||
npmDepsFetcherVersion = 2;
|
||||
# `--ignore-scripts` skips the electron prebuild download (we use nixpkgs
|
||||
# electron instead). `--legacy-peer-deps` matches the dev workflow —
|
||||
# apps/desktop has conflicting peer deps (zod, @testing-library) that
|
||||
# the package.json relies on npm 7+ to relax.
|
||||
npmFlags = [ "--ignore-scripts" "--legacy-peer-deps" ];
|
||||
# The workspace lockfile resolves all peer deps
|
||||
# correctly so --legacy-peer-deps is not needed.
|
||||
# --ignore-scripts comes from mkNpmPassthru (shared).
|
||||
makeCacheWritable = true;
|
||||
|
||||
buildPhase = ''
|
||||
@@ -47,21 +30,23 @@ let
|
||||
# write-build-stamp.cjs replacement. Packaged Electron reads this
|
||||
# at first-launch to pin the install.ps1 git ref; informational in
|
||||
# nix builds (the backend comes from the derivation directly).
|
||||
mkdir -p build
|
||||
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > build/install-stamp.json
|
||||
mkdir -p apps/desktop/build
|
||||
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > apps/desktop/build/install-stamp.json
|
||||
|
||||
# The vite config aliases react/react-dom to ../../node_modules/react
|
||||
# (workspace root, where npm dedups them in dev). In the standalone
|
||||
# nix build there is no workspace root, so the deps are installed
|
||||
# locally — rewrite the aliases to point at the local copy.
|
||||
substituteInPlace vite.config.ts \
|
||||
--replace-quiet '../../node_modules/' './node_modules/'
|
||||
# Build from apps/desktop/ so vite.config.ts resolves correctly.
|
||||
# The workspace root's node_modules/ is accessible as ../../node_modules/.
|
||||
cd apps/desktop
|
||||
|
||||
# vite handles TS transpilation via esbuild — no type-checking.
|
||||
# We skip `tsc -b` to avoid type errors in test files that don't
|
||||
# ship in the bundle (real upstream peer-dep version mismatches
|
||||
# in @testing-library/react v16 — not blocking the build).
|
||||
npx vite build --outDir dist
|
||||
# Call vite directly from root node_modules to avoid npx resolving
|
||||
# through unpatched workspace symlinks.
|
||||
node ../../node_modules/vite/bin/vite.js build --outDir dist
|
||||
|
||||
# Return to source root so installPhase paths are correct.
|
||||
cd ../..
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
@@ -69,8 +54,12 @@ let
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out
|
||||
cp -r dist electron build $out/
|
||||
cp package.json $out/
|
||||
# vite writes to apps/desktop/dist/ (we cd'd there in buildPhase).
|
||||
# apps/desktop/build was created before the cd. electron/ is source.
|
||||
cp -r apps/desktop/dist $out/
|
||||
cp -r apps/desktop/electron $out/
|
||||
cp -r apps/desktop/build $out/
|
||||
cp apps/desktop/package.json $out/
|
||||
runHook postInstall
|
||||
'';
|
||||
});
|
||||
@@ -106,6 +95,10 @@ stdenv.mkDerivation {
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
inherit (renderer.passthru) packageJsonPath;
|
||||
};
|
||||
|
||||
meta = with lib; {
|
||||
description = "Native Electron desktop shell for Hermes Agent";
|
||||
homepage = "https://github.com/NousResearch/hermes-agent";
|
||||
|
||||
+23
-12
@@ -1,13 +1,28 @@
|
||||
# nix/devShell.nix — Dev shell that delegates setup to each package
|
||||
#
|
||||
# Each package in inputsFrom might expose passthru.devShellHook — a bash snippet
|
||||
# with stamp-checked setup logic. This file collects and runs them all.
|
||||
# Each npm workspace package exposes passthru.packageJsonPath (e.g.
|
||||
# "ui-tui/package.json"). This file collects them all and passes the
|
||||
# list to mkNpmDevShellHook, which stamps all package.jsons at once,
|
||||
# then runs a single `npm i --package-lock-only` if any changed and
|
||||
# `npm ci` if the lockfile changed.
|
||||
{ ... }:
|
||||
{
|
||||
perSystem =
|
||||
{ pkgs, self', ... }:
|
||||
let
|
||||
packages = builtins.attrValues self'.packages;
|
||||
hermesNpmLib = self'.packages.default.passthru.hermesNpmLib;
|
||||
fixLockfilesExe = pkgs.lib.getExe self'.packages.fix-lockfiles;
|
||||
|
||||
# Collect all packageJsonPath values from npm workspace packages.
|
||||
npmPackageJsonPaths = builtins.filter (p: p != null) (
|
||||
map (p: p.passthru.packageJsonPath or null) packages
|
||||
);
|
||||
|
||||
# Non-npm packages may have their own devShellHook (e.g. hermes-agent
|
||||
# stamps pyproject.toml + uv.lock for Python venv setup).
|
||||
nonNpmHooks = map (p: p.passthru.devShellHook or "") packages;
|
||||
combinedNonNpm = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") nonNpmHooks);
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
@@ -15,16 +30,12 @@
|
||||
packages = with pkgs; [
|
||||
uv
|
||||
];
|
||||
shellHook =
|
||||
let
|
||||
hooks = map (p: p.passthru.devShellHook or "") packages;
|
||||
combined = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") hooks);
|
||||
in
|
||||
''
|
||||
echo "Hermes Agent dev shell"
|
||||
${combined}
|
||||
echo "Ready. Run 'hermes' to start."
|
||||
'';
|
||||
shellHook = ''
|
||||
echo "Hermes Agent dev shell"
|
||||
${combinedNonNpm}
|
||||
${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths fixLockfilesExe}
|
||||
echo "Ready. Run 'hermes' to start."
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+175
-121
@@ -1,15 +1,40 @@
|
||||
# nix/lib.nix — Shared helpers for nix stuff
|
||||
#
|
||||
# All npm packages in this repo are workspace members sharing a single
|
||||
# root package-lock.json. mkNpmPassthru provides the shared src, npmDeps,
|
||||
# npmRoot, and npmDepsFetcherVersion so individual .nix files don't
|
||||
# duplicate them. One hash to rule them all.
|
||||
#
|
||||
# mkNpmPassthru returns packageJsonPath (e.g. "ui-tui/package.json")
|
||||
# instead of a per-package devShellHook. The root devshell hook
|
||||
# (mkNpmDevShellHook) collects all package.json paths, stamps them,
|
||||
# and if any changed, runs a single `npm i --package-lock-only` from
|
||||
# root to update the lockfile, then `npm ci` if the lockfile changed.
|
||||
{
|
||||
pkgs,
|
||||
npm-lockfile-fix,
|
||||
nodejs,
|
||||
}:
|
||||
let
|
||||
# The workspace root — where the single package-lock.json lives.
|
||||
src = ../.;
|
||||
|
||||
# Single npm deps fetch from the workspace root lockfile.
|
||||
# All workspace packages share this derivation.
|
||||
npmDepsHash = "sha256-WudVthIvvyqaKDr3SwRAswd8csvByzUb+T8jCqeai6g=";
|
||||
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
fetcherVersion = 2;
|
||||
hash = npmDepsHash;
|
||||
};
|
||||
in
|
||||
{
|
||||
# Returns a buildNpmPackage-compatible attrs set that provides:
|
||||
# patchPhase — ensures lockfile has exactly one trailing newline
|
||||
# src, npmDeps, npmRoot, npmDepsFetcherVersion
|
||||
# patchPhase — ensures root lockfile has exactly one trailing newline
|
||||
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
|
||||
# passthru.devShellHook — stamp-checked npm install + hash auto-update
|
||||
# passthru.npmLockfile — metadata for mkFixLockfiles
|
||||
# passthru.packageJsonPath — relative path to this workspace's package.json
|
||||
# nodejs — fixed nodejs version for all packages we use in the repo
|
||||
#
|
||||
# NOTE: npmConfigHook runs `diff` between the source lockfile and the
|
||||
@@ -19,22 +44,38 @@
|
||||
#
|
||||
# Usage:
|
||||
# npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
||||
# pkgs.buildNpmPackage (npm // { ... } # or:
|
||||
# pkgs.buildNpmPackage ({ ... } // npm)
|
||||
# pkgs.buildNpmPackage (npm // {
|
||||
# sourceRoot = "ui-tui";
|
||||
# buildPhase = '' ... '';
|
||||
# installPhase = '' ... '';
|
||||
# })
|
||||
mkNpmPassthru =
|
||||
{
|
||||
folder, # repo-relative folder with package.json, e.g. "ui-tui"
|
||||
attr, # flake package attr, e.g. "tui"
|
||||
pname, # e.g. "hermes-tui"
|
||||
nixFile ? "nix/${attr}.nix", # defaults to nix/<attr>.nix
|
||||
}:
|
||||
let
|
||||
# No sourceRoot — the workspace root (with the single package-lock.json)
|
||||
# is auto-detected as sourceRoot by nix. npmRoot stays at "."
|
||||
# so npmConfigHook finds the lockfile there.
|
||||
in
|
||||
{
|
||||
inherit nodejs;
|
||||
inherit src npmDeps nodejs;
|
||||
npmRoot = ".";
|
||||
npmDepsFetcherVersion = 2;
|
||||
|
||||
# --ignore-scripts: the workspace includes electron (apps/desktop)
|
||||
# which has a postinstall that tries to download from github.com.
|
||||
# nix builds are offline, so all scripts must be skipped. Each
|
||||
# package sets up its own build commands in buildPhase instead.
|
||||
npmFlags = [ "--ignore-scripts" ];
|
||||
|
||||
patchPhase = ''
|
||||
runHook prePatch
|
||||
# Normalize trailing newlines so source and npm-deps always match,
|
||||
# regardless of what fetchNpmDeps preserves.
|
||||
sed -i -z 's/\n*$/\n/' package-lock.json
|
||||
# Normalize trailing newlines on the root lockfile so source and
|
||||
# npm-deps always match, regardless of what fetchNpmDeps preserves.
|
||||
sed -i -z 's/\\n*$/\\n/' package-lock.json
|
||||
|
||||
# Make npmConfigHook's byte-for-byte diff newline-agnostic by
|
||||
# replacing its hardcoded /nix/store/.../diff with a wrapper that
|
||||
@@ -42,11 +83,11 @@
|
||||
mkdir -p "$TMPDIR/bin"
|
||||
cat > "$TMPDIR/bin/diff" << DIFFWRAP
|
||||
#!/bin/sh
|
||||
f1=\$(mktemp) && sed -z 's/\n*$/\n/' "\$1" > "\$f1"
|
||||
f2=\$(mktemp) && sed -z 's/\n*$/\n/' "\$2" > "\$f2"
|
||||
${pkgs.diffutils}/bin/diff "\$f1" "\$f2" && rc=0 || rc=\$?
|
||||
rm -f "\$f1" "\$f2"
|
||||
exit \$rc
|
||||
f1=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$1" > "\\$f1"
|
||||
f2=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$2" > "\\$f2"
|
||||
${pkgs.diffutils}/bin/diff "\\$f1" "\\$f2" && rc=0 || rc=\\$?
|
||||
rm -f "\\$f1" "\\$f2"
|
||||
exit \\$rc
|
||||
DIFFWRAP
|
||||
chmod +x "$TMPDIR/bin/diff"
|
||||
export PATH="$TMPDIR/bin:$PATH"
|
||||
@@ -60,62 +101,71 @@
|
||||
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
|
||||
cd "$REPO_ROOT/${folder}"
|
||||
# All workspace packages share the root lockfile.
|
||||
cd "$REPO_ROOT"
|
||||
rm -rf node_modules/
|
||||
${pkgs.lib.getExe' nodejs "npm"} cache clean --force
|
||||
CI=true ${pkgs.lib.getExe' nodejs "npm"} install
|
||||
CI=true ${pkgs.lib.getExe' nodejs "npm"} install --workspaces
|
||||
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
|
||||
|
||||
NIX_FILE="$REPO_ROOT/${nixFile}"
|
||||
sed -i "s/hash = \"[^\"]*\";/hash = \"\";/" $NIX_FILE
|
||||
NIX_OUTPUT=$(nix build .#${attr} 2>&1 || true)
|
||||
NEW_HASH=$(echo "$NIX_OUTPUT" | grep 'got:' | awk '{print $2}')
|
||||
echo got new hash $NEW_HASH
|
||||
sed -i "s|hash = \"[^\"]*\";|hash = \"$NEW_HASH\";|" $NIX_FILE
|
||||
# Hash lives in lib.nix — just rebuild to verify.
|
||||
nix build .#${attr}
|
||||
echo "Updated npm hash in $NIX_FILE to $NEW_HASH"
|
||||
echo "Lockfile updated and build verified for .#${attr}"
|
||||
'')
|
||||
];
|
||||
|
||||
passthru = {
|
||||
devShellHook = pkgs.writeShellScript "npm-dev-hook-${pname}" ''
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
|
||||
_hermes_npm_stamp() {
|
||||
sha256sum "${folder}/package.json" "${folder}/package-lock.json" \
|
||||
2>/dev/null | sha256sum | awk '{print $1}'
|
||||
}
|
||||
STAMP=".nix-stamps/${pname}"
|
||||
STAMP_VALUE="$(_hermes_npm_stamp)"
|
||||
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
|
||||
echo "${pname}: installing npm dependencies..."
|
||||
( cd ${folder} && CI=true ${pkgs.lib.getExe' nodejs "npm"} install --silent --no-fund --no-audit 2>/dev/null )
|
||||
|
||||
# Auto-update the nix hash so it stays in sync with the lockfile
|
||||
echo "${pname}: prefetching npm deps..."
|
||||
NIX_FILE="$REPO_ROOT/${nixFile}"
|
||||
if NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "${folder}/package-lock.json" 2>/dev/null); then
|
||||
sed -i "s|hash = \"sha256-[A-Za-z0-9+/=]+\"|hash = \"$NEW_HASH\";|" "$NIX_FILE"
|
||||
echo "${pname}: updated hash to $NEW_HASH"
|
||||
else
|
||||
echo "${pname}: warning: prefetch failed, run 'nix run .#fix-lockfiles' manually" >&2
|
||||
fi
|
||||
|
||||
mkdir -p .nix-stamps
|
||||
_hermes_npm_stamp > "$STAMP"
|
||||
fi
|
||||
unset -f _hermes_npm_stamp
|
||||
'';
|
||||
|
||||
npmLockfile = {
|
||||
inherit attr folder nixFile;
|
||||
};
|
||||
packageJsonPath = "${folder}/package.json";
|
||||
};
|
||||
};
|
||||
|
||||
# Aggregate `fix-lockfiles` bin from a list of packages carrying
|
||||
# passthru.npmLockfile = { attr; folder; nixFile; };
|
||||
# Invocations:
|
||||
# Single devshell hook for all npm workspace packages.
|
||||
#
|
||||
# Takes a list of package.json relative paths (from mkNpmPassthru .passthru.packageJsonPath),
|
||||
# stamps all of them, and if any changed:
|
||||
# 1. Runs `npm i --package-lock-only` from root to update the lockfile
|
||||
# 2. If the lockfile changed, runs `npm ci` + fix-lockfiles
|
||||
#
|
||||
# fixLockfilesExe: absolute path to the fix-lockfiles binary
|
||||
# (from pkgs.lib.getExe self'.packages.fix-lockfiles in devShell.nix).
|
||||
mkNpmDevShellHook =
|
||||
packageJsonPaths: fixLockfilesExe:
|
||||
pkgs.writeShellScript "npm-dev-hook" ''
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
|
||||
# Stamp all workspace package.jsons into one file.
|
||||
STAMP_DIR=".nix-stamps"
|
||||
STAMP="$STAMP_DIR/npm-package-jsons"
|
||||
STAMP_VALUE=$(
|
||||
${pkgs.coreutils}/bin/sha256sum ${
|
||||
pkgs.lib.concatMapStringsSep " " (p: "\"$REPO_ROOT/${p}\"") packageJsonPaths
|
||||
} 2>/dev/null | ${pkgs.coreutils}/bin/sort | ${pkgs.coreutils}/bin/sha256sum | awk '{print $1}'
|
||||
)
|
||||
|
||||
PKG_CHANGED=false
|
||||
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
|
||||
PKG_CHANGED=true
|
||||
echo "npm: package.json changed, updating lockfile..."
|
||||
( cd "$REPO_ROOT" && ${pkgs.lib.getExe' nodejs "npm"} i --package-lock-only --silent --no-fund --no-audit 2>/dev/null )
|
||||
mkdir -p "$STAMP_DIR"
|
||||
echo "$STAMP_VALUE" > "$STAMP"
|
||||
fi
|
||||
|
||||
# Check if lockfile changed (either from the npm i above or from an
|
||||
# external edit). Runs npm ci + fix-lockfiles if so.
|
||||
LOCK_STAMP="$STAMP_DIR/root-lockfile"
|
||||
LOCK_STAMP_VALUE=$(sha256sum "$REPO_ROOT/package-lock.json" 2>/dev/null | awk '{print $1}')
|
||||
if [ ! -f "$LOCK_STAMP" ] || [ "$(cat "$LOCK_STAMP")" != "$LOCK_STAMP_VALUE" ]; then
|
||||
echo "npm: package-lock.json changed, running npm ci..."
|
||||
( cd "$REPO_ROOT" && CI=true ${pkgs.lib.getExe' nodejs "npm"} ci --silent --no-fund --no-audit 2>/dev/null )
|
||||
echo "npm: updating nix hash..."
|
||||
${fixLockfilesExe} || echo "npm: warning: fix-lockfiles failed, run it manually" >&2
|
||||
mkdir -p "$STAMP_DIR"
|
||||
echo "$LOCK_STAMP_VALUE" > "$LOCK_STAMP"
|
||||
fi
|
||||
'';
|
||||
|
||||
# Build `fix-lockfiles` bin that checks/updates the single npmDepsHash
|
||||
# fix-lockfiles --check # exit 1 if any hash is stale
|
||||
# fix-lockfiles --apply # rewrite stale hashes in place
|
||||
# fix-lockfiles # alias of --apply
|
||||
@@ -123,12 +173,8 @@
|
||||
# when set, so CI workflows can post a sticky PR comment directly.
|
||||
mkFixLockfiles =
|
||||
{
|
||||
packages, # list of packages with passthru.npmLockfile
|
||||
attr, # flake package attr for fallback verification build, e.g. "tui"
|
||||
}:
|
||||
let
|
||||
entries = map (p: p.passthru.npmLockfile) packages;
|
||||
entryArgs = pkgs.lib.concatMapStringsSep " " (e: "\"${e.attr}:${e.folder}:${e.nixFile}\"") entries;
|
||||
in
|
||||
pkgs.writeShellScriptBin "fix-lockfiles" ''
|
||||
set -uox pipefail
|
||||
MODE="''${1:---apply}"
|
||||
@@ -142,8 +188,6 @@
|
||||
exit 2 ;;
|
||||
esac
|
||||
|
||||
ENTRIES=(${entryArgs})
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
@@ -160,66 +204,76 @@
|
||||
FIXED=0
|
||||
REPORT=""
|
||||
|
||||
for entry in "''${ENTRIES[@]}"; do
|
||||
IFS=":" read -r ATTR FOLDER NIX_FILE <<< "$entry"
|
||||
echo "==> .#$ATTR ($FOLDER -> $NIX_FILE)"
|
||||
|
||||
# Compute the actual hash from the lockfile directly using
|
||||
# prefetch-npm-deps. This avoids false "ok" from nix build when
|
||||
# an old derivation is cached in a substituter (cachix/cache.nixos.org).
|
||||
LOCK_FILE="$FOLDER/package-lock.json"
|
||||
NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null)
|
||||
# All workspace packages share the root package-lock.json, so
|
||||
# we only need to check the hash once.
|
||||
LOCK_FILE="package-lock.json"
|
||||
LIB_FILE="nix/lib.nix"
|
||||
NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null)
|
||||
if [ -z "$NEW_HASH" ]; then
|
||||
echo "prefetch-npm-deps failed, falling back to nix build" >&2
|
||||
OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1)
|
||||
STATUS=$?
|
||||
if [ "$STATUS" -eq 0 ]; then
|
||||
echo "ok (via nix build)"
|
||||
exit 0
|
||||
fi
|
||||
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
|
||||
if [ -z "$NEW_HASH" ]; then
|
||||
echo " prefetch-npm-deps failed, falling back to nix build" >&2
|
||||
OUTPUT=$(nix build ".#$ATTR.npmDeps" --no-link --print-build-logs 2>&1)
|
||||
STATUS=$?
|
||||
if [ "$STATUS" -eq 0 ]; then
|
||||
echo " ok (via nix build)"
|
||||
continue
|
||||
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
|
||||
echo "skipped (transient cache failure — see primary nix build for real status)" >&2
|
||||
echo "$OUTPUT" | tail -8 >&2
|
||||
exit 0
|
||||
fi
|
||||
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
|
||||
if [ -z "$NEW_HASH" ]; then
|
||||
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
|
||||
echo " skipped (transient cache failure — see primary nix build for real status)" >&2
|
||||
echo "$OUTPUT" | tail -8 >&2
|
||||
continue
|
||||
echo "build failed with no hash mismatch:" >&2
|
||||
echo "$OUTPUT" | tail -40 >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
OLD_HASH=$(grep -oE 'npmDepsHash = "sha256-[^"]+"' "$LIB_FILE" | head -1 \
|
||||
| sed -E 's/npmDepsHash = "(.*)"/\1/')
|
||||
|
||||
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
|
||||
echo "ok"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
HASH_LINE=$(grep -n 'npmDepsHash = "sha256-' "$LIB_FILE" | head -1 | cut -d: -f1)
|
||||
echo "stale: $LIB_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH"
|
||||
STALE=1
|
||||
|
||||
if [ -n "$LINK_REPO" ] && [ -n "$LINK_SHA" ]; then
|
||||
LIB_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LIB_FILE#L$HASH_LINE"
|
||||
LOCK_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LOCK_FILE"
|
||||
REPORT="- [\`$LIB_FILE:$HASH_LINE\`]($LIB_URL): \`$OLD_HASH\` → \`$NEW_HASH\` — lockfile: [\`$LOCK_FILE\`]($LOCK_URL)"$'\\n'
|
||||
else
|
||||
REPORT="- \`$LIB_FILE:$HASH_LINE\`: \`$OLD_HASH\` → \`$NEW_HASH\`"$'\\n'
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "--apply" ]; then
|
||||
sed -i -E "s|npmDepsHash = \"sha256-[^\"]+\";|npmDepsHash = \"$NEW_HASH\";|" "$LIB_FILE"
|
||||
if ! nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>/dev/null; then
|
||||
# prefetch-npm-deps may disagree with fetchNpmDeps (it hashes
|
||||
# the lockfile contents, not the full source tree). Extract the
|
||||
# correct hash from the nix build error and retry.
|
||||
RETRY_OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1)
|
||||
CORRECT_HASH=$(echo "$RETRY_OUTPUT" | awk '/got:/ {print $2; exit}')
|
||||
if [ -n "$CORRECT_HASH" ]; then
|
||||
echo "prefetch-npm-deps gave $NEW_HASH but nix wants $CORRECT_HASH — retrying" >&2
|
||||
sed -i -E "s|npmDepsHash = \"sha256-[^\"]+\";|npmDepsHash = \"$CORRECT_HASH\";|" "$LIB_FILE"
|
||||
if ! nix build ".#${attr}.npmDeps" --no-link --print-build-logs; then
|
||||
echo "verification build failed after hash retry" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " build failed with no hash mismatch:" >&2
|
||||
echo "$OUTPUT" | tail -40 >&2
|
||||
NEW_HASH="$CORRECT_HASH"
|
||||
else
|
||||
echo "verification build failed after hash update" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
OLD_HASH=$(grep -oE 'hash = "sha256-[^"]+"' "$NIX_FILE" | head -1 \
|
||||
| sed -E 's/hash = "(.*)"/\1/')
|
||||
|
||||
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
|
||||
echo " ok"
|
||||
continue
|
||||
fi
|
||||
|
||||
HASH_LINE=$(grep -n 'hash = "sha256-' "$NIX_FILE" | head -1 | cut -d: -f1)
|
||||
echo " stale: $NIX_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH"
|
||||
STALE=1
|
||||
|
||||
if [ -n "$LINK_REPO" ] && [ -n "$LINK_SHA" ]; then
|
||||
NIX_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$NIX_FILE#L$HASH_LINE"
|
||||
LOCK_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LOCK_FILE"
|
||||
REPORT+="- [\`$NIX_FILE:$HASH_LINE\`]($NIX_URL) (\`.#$ATTR\`): \`$OLD_HASH\` → \`$NEW_HASH\` — lockfile: [\`$LOCK_FILE\`]($LOCK_URL)"$'\n'
|
||||
else
|
||||
REPORT+="- \`$NIX_FILE:$HASH_LINE\` (\`.#$ATTR\`): \`$OLD_HASH\` → \`$NEW_HASH\`"$'\n'
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "--apply" ]; then
|
||||
sed -i "s|hash = \"sha256-[^\"]*\";|hash = \"$NEW_HASH\";|" "$NIX_FILE"
|
||||
if ! nix build ".#$ATTR.npmDeps" --no-link --print-build-logs; then
|
||||
echo " verification build failed after hash update" >&2
|
||||
exit 1
|
||||
fi
|
||||
FIXED=1
|
||||
echo " fixed"
|
||||
fi
|
||||
done
|
||||
FIXED=1
|
||||
echo "fixed"
|
||||
fi
|
||||
|
||||
if [ -n "''${GITHUB_OUTPUT:-}" ]; then
|
||||
{
|
||||
@@ -235,7 +289,7 @@
|
||||
|
||||
if [ "$STALE" -eq 1 ] && [ "$MODE" = "--check" ]; then
|
||||
echo
|
||||
echo "Stale lockfile hashes detected. Run:"
|
||||
echo "Stale lockfile hash detected. Run:"
|
||||
echo " nix run .#fix-lockfiles"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+1
-3
@@ -51,9 +51,7 @@
|
||||
web = hermesAgent.hermesWeb;
|
||||
desktop = hermesAgent.hermesDesktop;
|
||||
|
||||
fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles {
|
||||
packages = [ hermesAgent.hermesTui hermesAgent.hermesWeb hermesAgent.hermesDesktop ];
|
||||
};
|
||||
fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles { attr = "tui"; };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+11
-13
@@ -1,34 +1,32 @@
|
||||
# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled
|
||||
{ pkgs, hermesNpmLib, ... }:
|
||||
let
|
||||
src = ../ui-tui;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-F6/MzZOWc0zhW9mIfnaY+PrllPvJcsA/OdFdEM+NpLY=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
||||
|
||||
packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json"));
|
||||
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/ui-tui/package.json"));
|
||||
version = packageJson.version;
|
||||
in
|
||||
pkgs.buildNpmPackage (npm // {
|
||||
pname = "hermes-tui";
|
||||
inherit src npmDeps version;
|
||||
inherit version;
|
||||
|
||||
doCheck = false;
|
||||
npmFlags = [ "--legacy-peer-deps" ];
|
||||
|
||||
buildPhase = ''
|
||||
# esbuild bundles everything — no need for tsc or vite.
|
||||
# Run from the workspace root where node_modules/ lives.
|
||||
node ui-tui/scripts/build.mjs
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/lib/hermes-tui
|
||||
|
||||
# Single self-contained bundle built by scripts/build.mjs (esbuild).
|
||||
cp -r dist $out/lib/hermes-tui/dist
|
||||
# esbuild writes to ui-tui/dist/ from the source root (no cd).
|
||||
cp -r ui-tui/dist $out/lib/hermes-tui/dist
|
||||
|
||||
# package.json kept for "type": "module" resolution on `node dist/entry.js`.
|
||||
cp package.json $out/lib/hermes-tui/
|
||||
cp ui-tui/package.json $out/lib/hermes-tui/
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
+14
-11
@@ -1,31 +1,34 @@
|
||||
# nix/web.nix — Hermes Web Dashboard (Vite/React) frontend build
|
||||
{ pkgs, hermesNpmLib, ... }:
|
||||
let
|
||||
src = ../web;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-HV0aISBVjwbGqDj8qQynSxGFrrZDzuYAW3D3lB/x3zo=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
|
||||
|
||||
packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json"));
|
||||
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/web/package.json"));
|
||||
version = packageJson.version;
|
||||
in
|
||||
pkgs.buildNpmPackage (npm // {
|
||||
pname = "hermes-web";
|
||||
inherit src npmDeps version;
|
||||
inherit version;
|
||||
|
||||
doCheck = false;
|
||||
|
||||
buildPhase = ''
|
||||
npx tsc -b
|
||||
npx vite build --outDir dist
|
||||
# Build from web/ so vite.config.ts and tsconfig resolve correctly.
|
||||
# The workspace root's node_modules/ is at ../node_modules/.
|
||||
cd web
|
||||
node ../node_modules/typescript/bin/tsc -b
|
||||
# outDir in vite.config.ts points to ../hermes_cli/web_dist for the
|
||||
# monorepo layout. Override with --outDir dist for the nix build.
|
||||
node ../node_modules/vite/bin/vite.js build --outDir dist
|
||||
|
||||
# Return to source root so installPhase paths are correct.
|
||||
cd ..
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
cp -r dist $out
|
||||
# vite writes to web/dist/ (we cd'd there, overrode outDir, then cd'd back).
|
||||
cp -r web/dist $out
|
||||
runHook postInstall
|
||||
'';
|
||||
})
|
||||
|
||||
Generated
+2275
-904
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -4,7 +4,10 @@
|
||||
"description": "An AI agent with advanced tool-calling capabilities, featuring a flexible toolsets system for organizing and managing tools.",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*"
|
||||
"apps/*",
|
||||
"ui-tui",
|
||||
"ui-tui/packages/*",
|
||||
"web"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "echo '✅ Browser tools ready. Run: python run_agent.py --help'"
|
||||
|
||||
+9
-15
@@ -48,22 +48,16 @@
|
||||
return tier ? "ha-tier-" + tier.toLowerCase() : "ha-tier-pending";
|
||||
};
|
||||
|
||||
async function api(path, options) {
|
||||
function api(path, options) {
|
||||
// Delegate to the host SDK's fetchJSON so auth is handled correctly in
|
||||
// BOTH dashboard modes: loopback (X-Hermes-Session-Token header) and
|
||||
// gated OAuth (hermes_session_at cookie via credentials:'include').
|
||||
// Hand-rolling fetch + reading window.__HERMES_SESSION_TOKEN__ directly
|
||||
// 401s in gated mode (the token isn't injected there). fetchJSON throws
|
||||
// Error("<status>: <body>") on non-2xx — the call sites' .catch() relies
|
||||
// on that to surface errors, so we let it propagate (don't swallow).
|
||||
const url = "/api/plugins/hermes-achievements" + path;
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const headers = { ...((options && options.headers) || {}) };
|
||||
if (token) headers["X-Hermes-Session-Token"] = token;
|
||||
const res = await fetch(url, { ...(options || {}), headers });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(function () { return res.statusText; });
|
||||
throw new Error(res.status + ": " + text);
|
||||
}
|
||||
const text = await res.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return SDK.fetchJSON(url, options);
|
||||
}
|
||||
|
||||
function AchievementIcon({ icon }) {
|
||||
|
||||
+58
-45
@@ -588,52 +588,62 @@
|
||||
wsClosedRef.current = false;
|
||||
function openWs() {
|
||||
if (wsClosedRef.current) return;
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qsParams = {
|
||||
since: String(cursorRef.current || 0),
|
||||
token: token,
|
||||
};
|
||||
// Build the WS URL via the host SDK so the correct auth param is used
|
||||
// in BOTH modes: single-use ?ticket= in gated OAuth mode, ?token= in
|
||||
// loopback. Reading window.__HERMES_SESSION_TOKEN__ directly (the old
|
||||
// path) sends an empty token and is rejected in gated mode. buildWsUrl
|
||||
// also applies the dashboard base-path prefix for reverse-proxied
|
||||
// deployments, which the old inline URL did not. It's async (gated
|
||||
// mode mints a fresh ticket per connect), so resolve then open.
|
||||
const wsParams = { since: String(cursorRef.current || 0) };
|
||||
// Pin the WS stream to the currently-selected board so events
|
||||
// from other boards don't bleed in. Includes "default" so the
|
||||
// dashboard's own board pin always wins over the server-side
|
||||
// ``current`` file — same rationale as ``withBoard()`` above.
|
||||
// Regression: #20879.
|
||||
if (board) qsParams.board = board;
|
||||
const qs = new URLSearchParams(qsParams);
|
||||
const url = `${proto}//${window.location.host}${API}/events?${qs}`;
|
||||
let ws;
|
||||
try { ws = new WebSocket(url); } catch (_e) { return; }
|
||||
wsRef.current = ws;
|
||||
ws.onopen = function () { wsBackoffRef.current = 1000; };
|
||||
ws.onmessage = function (ev) {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg && Array.isArray(msg.events) && msg.events.length > 0) {
|
||||
cursorRef.current = msg.cursor || cursorRef.current;
|
||||
// Stamp per-task signal so the TaskDrawer can reload itself.
|
||||
setTaskEventTick(function (prev) {
|
||||
const next = Object.assign({}, prev);
|
||||
for (const e of msg.events) {
|
||||
if (e && e.task_id) next[e.task_id] = (next[e.task_id] || 0) + 1;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
scheduleReload();
|
||||
if (board) wsParams.board = board;
|
||||
SDK.buildWsUrl(`${API}/events`, wsParams).then(function (url) {
|
||||
if (wsClosedRef.current) return;
|
||||
let ws;
|
||||
try { ws = new WebSocket(url); } catch (_e) { return; }
|
||||
wsRef.current = ws;
|
||||
ws.onopen = function () { wsBackoffRef.current = 1000; };
|
||||
ws.onmessage = function (ev) {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg && Array.isArray(msg.events) && msg.events.length > 0) {
|
||||
cursorRef.current = msg.cursor || cursorRef.current;
|
||||
// Stamp per-task signal so the TaskDrawer can reload itself.
|
||||
setTaskEventTick(function (prev) {
|
||||
const next = Object.assign({}, prev);
|
||||
for (const e of msg.events) {
|
||||
if (e && e.task_id) next[e.task_id] = (next[e.task_id] || 0) + 1;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
scheduleReload();
|
||||
}
|
||||
} catch (_e) { /* ignore */ }
|
||||
};
|
||||
ws.onclose = function (ev) {
|
||||
if (wsClosedRef.current) return;
|
||||
if (ev && ev.code === 1008) {
|
||||
setError(tx(t, "wsAuthFailed",
|
||||
"WebSocket auth failed — reload the page to refresh the session token."));
|
||||
return;
|
||||
}
|
||||
} catch (_e) { /* ignore */ }
|
||||
};
|
||||
ws.onclose = function (ev) {
|
||||
const delay = Math.min(wsBackoffRef.current, 30000);
|
||||
wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000);
|
||||
setTimeout(openWs, delay);
|
||||
};
|
||||
}).catch(function () {
|
||||
// Ticket mint / URL build failed (e.g. session expired). Back off
|
||||
// and retry; a hard auth failure surfaces via the 1008 close path.
|
||||
if (wsClosedRef.current) return;
|
||||
if (ev && ev.code === 1008) {
|
||||
setError(tx(t, "wsAuthFailed",
|
||||
"WebSocket auth failed — reload the page to refresh the session token."));
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(wsBackoffRef.current, 30000);
|
||||
wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000);
|
||||
setTimeout(openWs, delay);
|
||||
};
|
||||
});
|
||||
}
|
||||
openWs();
|
||||
return function () {
|
||||
@@ -2837,8 +2847,6 @@
|
||||
if (!files.length) return;
|
||||
setUploadBusy(true);
|
||||
setUploadErr(null);
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const headers = token ? { Authorization: "Bearer " + token } : {};
|
||||
const url = withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/attachments`, boardSlug);
|
||||
// Upload sequentially so a partial failure leaves a clear state.
|
||||
let chain = Promise.resolve();
|
||||
@@ -2846,7 +2854,11 @@
|
||||
chain = chain.then(function () {
|
||||
const fd = new FormData();
|
||||
fd.append("file", f, f.name);
|
||||
return fetch(url, { method: "POST", headers: headers, credentials: "same-origin", body: fd })
|
||||
// SDK.authedFetch handles auth in BOTH modes (loopback token header /
|
||||
// gated cookie) and applies the dashboard base-path prefix. The old
|
||||
// hand-rolled Authorization:Bearer + credentials:'same-origin' sent
|
||||
// an empty token and 401'd in gated mode.
|
||||
return SDK.authedFetch(url, { method: "POST", body: fd })
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
return resp.text().then(function (txt) {
|
||||
@@ -3073,15 +3085,16 @@
|
||||
const fileRef = useRef(null);
|
||||
const [dlErr, setDlErr] = useState(null);
|
||||
// Download via authenticated fetch → blob → synthetic anchor click.
|
||||
// A plain <a href> can't carry the session header/bearer the dashboard
|
||||
// auth middleware requires in loopback mode, so fetch with the token
|
||||
// and hand the browser a blob URL instead.
|
||||
// A plain <a href> can't carry the auth the dashboard middleware requires,
|
||||
// so fetch authenticated and hand the browser a blob URL instead.
|
||||
function downloadAttachment(a) {
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const headers = token ? { Authorization: "Bearer " + token } : {};
|
||||
// SDK.authedFetch handles auth in BOTH modes (loopback token header /
|
||||
// gated cookie) and applies the dashboard base-path prefix. The old
|
||||
// hand-rolled Authorization:Bearer + credentials:'same-origin' sent an
|
||||
// empty token and 401'd in gated mode.
|
||||
const url = withBoard(`${API}/attachments/${a.id}`, props.boardSlug);
|
||||
setDlErr(null);
|
||||
fetch(url, { headers: headers, credentials: "same-origin" })
|
||||
SDK.authedFetch(url)
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
return resp.text().then(function (txt) {
|
||||
|
||||
@@ -36,7 +36,6 @@ the port.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -63,15 +62,29 @@ router = APIRouter()
|
||||
# existing plugin-bypass; this is documented above).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _check_ws_token(provided: Optional[str]) -> bool:
|
||||
"""Constant-time compare against the dashboard session token.
|
||||
def _ws_upgrade_authorized(ws: "WebSocket") -> bool:
|
||||
"""Authorize a WebSocket upgrade by delegating to the dashboard's canonical
|
||||
WS auth gate (``hermes_cli.web_server._ws_auth_ok``).
|
||||
|
||||
Delegating (rather than re-implementing a ``_SESSION_TOKEN``-only check)
|
||||
means this endpoint transparently accepts whatever the core gate accepts
|
||||
in each mode:
|
||||
|
||||
* loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>``
|
||||
* gated OAuth: single-use ``?ticket=`` (the browser SDK's
|
||||
``buildWsUrl`` mints one per connect)
|
||||
* server-internal: the process-lifetime ``?internal=`` credential
|
||||
|
||||
The previous bespoke check only understood ``_SESSION_TOKEN``, so the
|
||||
kanban live-events WS was rejected on every OAuth-gated deployment even
|
||||
though the rest of the dashboard worked. Routing through the shared gate
|
||||
also means this can never drift from core auth again.
|
||||
|
||||
Imported lazily so the plugin still loads in test contexts where the
|
||||
dashboard web_server module isn't importable (e.g. the bare-FastAPI
|
||||
test harness).
|
||||
dashboard ``web_server`` module isn't importable (e.g. the bare-FastAPI
|
||||
test harness); there we accept so the tail loop stays testable, matching
|
||||
the prior behaviour.
|
||||
"""
|
||||
if not provided:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli import web_server as _ws
|
||||
except Exception:
|
||||
@@ -79,10 +92,7 @@ def _check_ws_token(provided: Optional[str]) -> bool:
|
||||
# testable; in production the dashboard module always imports
|
||||
# cleanly because it's the caller.
|
||||
return True
|
||||
expected = getattr(_ws, "_SESSION_TOKEN", None)
|
||||
if not expected:
|
||||
return True
|
||||
return hmac.compare_digest(str(provided), str(expected))
|
||||
return bool(_ws._ws_auth_ok(ws))
|
||||
|
||||
|
||||
def _resolve_board(board: Optional[str]) -> Optional[str]:
|
||||
@@ -2375,11 +2385,12 @@ def set_orchestration_settings(payload: OrchestrationSettingsBody):
|
||||
|
||||
@router.websocket("/events")
|
||||
async def stream_events(ws: WebSocket):
|
||||
# Enforce the dashboard session token as a query param — browsers can't
|
||||
# set Authorization on a WS upgrade. This matches how the PTY bridge
|
||||
# authenticates in hermes_cli/web_server.py.
|
||||
token = ws.query_params.get("token")
|
||||
if not _check_ws_token(token):
|
||||
# Authorize the upgrade via the dashboard's canonical WS gate so the
|
||||
# correct credential is accepted in every mode (loopback token / gated
|
||||
# single-use ticket / server-internal credential). Browsers can't set
|
||||
# Authorization on a WS upgrade, so the credential rides in the query
|
||||
# string — the browser SDK's buildWsUrl() assembles it.
|
||||
if not _ws_upgrade_authorized(ws):
|
||||
await ws.close(code=http_status.WS_1008_POLICY_VIOLATION)
|
||||
return
|
||||
await ws.accept()
|
||||
|
||||
+37
-78
@@ -289,78 +289,42 @@ function Install-AgentBrowser {
|
||||
# ============================================================================
|
||||
|
||||
function Install-Uv {
|
||||
Write-Info "Checking for uv package manager..."
|
||||
|
||||
# Check if uv is already available
|
||||
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
||||
$version = uv --version
|
||||
$script:UvCmd = "uv"
|
||||
Write-Success "uv found ($version)"
|
||||
# Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there —
|
||||
# no PATH probing, no conda guards, no multi-location resolution chains.
|
||||
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
|
||||
# place, so install.ps1 and `hermes update` stay in sync.
|
||||
$managedUv = Join-Path $HermesHome "bin\uv.exe"
|
||||
|
||||
if (Test-Path $managedUv) {
|
||||
$script:UvCmd = $managedUv
|
||||
$version = & $managedUv --version
|
||||
Write-Success "Managed uv found ($version)"
|
||||
return $true
|
||||
}
|
||||
|
||||
# Check common install locations
|
||||
$uvPaths = @(
|
||||
"$env:USERPROFILE\.local\bin\uv.exe",
|
||||
"$env:USERPROFILE\.cargo\bin\uv.exe"
|
||||
)
|
||||
foreach ($uvPath in $uvPaths) {
|
||||
if (Test-Path $uvPath) {
|
||||
$script:UvCmd = $uvPath
|
||||
$version = & $uvPath --version
|
||||
Write-Success "uv found at $uvPath ($version)"
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
# Install uv
|
||||
Write-Info "Installing uv (fast Python package manager)..."
|
||||
# Capture EAP outside the try block so the catch's restore call always
|
||||
# has a meaningful value -- if the assignment lived inside try and the
|
||||
# try body threw before reaching it, the catch would see $prevEAP
|
||||
# unset and leave EAP at whatever the previous protected call set.
|
||||
|
||||
Write-Info "Installing managed uv into $HermesHome\bin ..."
|
||||
New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null
|
||||
|
||||
# UV_INSTALL_DIR tells the astral installer to place the binary
|
||||
# directly into $HermesHome\bin instead of ~/.local/bin.
|
||||
$prevEAP = $ErrorActionPreference
|
||||
try {
|
||||
# Relax ErrorActionPreference around the nested astral installer.
|
||||
# The astral installer (a separate `powershell -c "irm ... | iex"`)
|
||||
# writes download progress to stderr. With $ErrorActionPreference
|
||||
# = "Stop" set at the top of this script, PowerShell wraps stderr
|
||||
# lines from native commands (which `powershell -c` is, from our
|
||||
# perspective) as ErrorRecord objects when captured via 2>&1, then
|
||||
# throws a terminating exception on the first one -- even though
|
||||
# uv installs successfully and the child exits 0. Same fix
|
||||
# pattern Test-Python uses for `uv python install`; verify success
|
||||
# via Test-Path on the expected binary afterwards, which is more
|
||||
# reliable than exit-code/stderr signal anyway.
|
||||
$ErrorActionPreference = "Continue"
|
||||
$env:UV_INSTALL_DIR = Join-Path $HermesHome "bin"
|
||||
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
|
||||
$ErrorActionPreference = $prevEAP
|
||||
|
||||
# Find the installed binary
|
||||
$uvExe = "$env:USERPROFILE\.local\bin\uv.exe"
|
||||
if (-not (Test-Path $uvExe)) {
|
||||
$uvExe = "$env:USERPROFILE\.cargo\bin\uv.exe"
|
||||
}
|
||||
if (-not (Test-Path $uvExe)) {
|
||||
# Refresh PATH and try again
|
||||
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
||||
$uvExe = (Get-Command uv).Source
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path $uvExe) {
|
||||
$script:UvCmd = $uvExe
|
||||
$version = & $uvExe --version
|
||||
Write-Success "uv installed ($version)"
|
||||
if (Test-Path $managedUv) {
|
||||
$script:UvCmd = $managedUv
|
||||
$version = & $managedUv --version
|
||||
Write-Success "Managed uv installed ($version)"
|
||||
return $true
|
||||
}
|
||||
|
||||
Write-Err "uv installed but not found on PATH"
|
||||
Write-Info "Try restarting your terminal and re-running"
|
||||
|
||||
Write-Err "uv installed but not found at $managedUv"
|
||||
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
|
||||
return $false
|
||||
} catch {
|
||||
# Restore EAP in case the try block threw before the assignment
|
||||
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
|
||||
Write-Err "Failed to install uv: $_"
|
||||
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
|
||||
@@ -385,11 +349,9 @@ function Sync-EnvPath {
|
||||
# in a fresh powershell process, so $script:UvCmd set by Install-Uv in a
|
||||
# prior process is not visible here. Later stages (Test-Python,
|
||||
# Install-Venv, Install-Dependencies, Install-PlatformSdks) call this
|
||||
# at the top to populate $script:UvCmd from PATH or known install paths.
|
||||
# Throws if uv is not findable -- the caller's stage then surfaces a
|
||||
# clean error via the stage-driver's try/catch. Fast path is a single
|
||||
# Get-Command call when uv is on PATH (the common case after Stage-Uv
|
||||
# ran path-modifying installs in a sibling process).
|
||||
# at the top to populate $script:UvCmd from the managed location.
|
||||
# Throws if uv is not findable — the caller's stage then surfaces a
|
||||
# clean error via the stage-driver's try/catch.
|
||||
function Resolve-UvCmd {
|
||||
# Already resolved (default invocation path: Install-Uv ran earlier
|
||||
# in the same process and set $script:UvCmd).
|
||||
@@ -404,9 +366,15 @@ function Resolve-UvCmd {
|
||||
# Stale; fall through to re-discover.
|
||||
}
|
||||
|
||||
# Try PATH first (covers `winget install astral.uv`, manual installs,
|
||||
# and the post-Install-Uv state where uv.exe lives in
|
||||
# %USERPROFILE%\.local\bin which the installer added to PATH).
|
||||
# Check the managed location first — this is where Install-Uv puts it.
|
||||
$managedUv = Join-Path $HermesHome "bin\uv.exe"
|
||||
if (Test-Path $managedUv) {
|
||||
$script:UvCmd = $managedUv
|
||||
return
|
||||
}
|
||||
|
||||
# Fall back to PATH (covers edge cases where the installer ran in a
|
||||
# sibling process and HERMES_HOME wasn't propagated).
|
||||
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
||||
$script:UvCmd = "uv"
|
||||
return
|
||||
@@ -420,16 +388,7 @@ function Resolve-UvCmd {
|
||||
return
|
||||
}
|
||||
|
||||
# Check the well-known install locations the astral.sh installer drops
|
||||
# uv into. Mirrors the probe order Install-Uv uses.
|
||||
foreach ($uvPath in @("$env:USERPROFILE\.local\bin\uv.exe", "$env:USERPROFILE\.cargo\bin\uv.exe")) {
|
||||
if (Test-Path $uvPath) {
|
||||
$script:UvCmd = $uvPath
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
throw "uv is not installed or not on PATH. Run install.ps1 -Stage uv first."
|
||||
throw "uv is not installed. Run install.ps1 -Stage uv first."
|
||||
}
|
||||
|
||||
function Test-Python {
|
||||
|
||||
+61
-150
@@ -475,39 +475,22 @@ install_uv() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Checking for uv package manager..."
|
||||
# Hermes owns its own uv at $HERMES_HOME/bin/uv. Always install there —
|
||||
# no PATH probing, no conda guards, no multi-location resolution chains.
|
||||
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
|
||||
# place, so install.sh and `hermes update` stay in sync.
|
||||
local _managed_uv="$HERMES_HOME/bin/uv"
|
||||
|
||||
# Check common locations for uv
|
||||
if command -v uv &> /dev/null; then
|
||||
UV_CMD="uv"
|
||||
if [ -x "$_managed_uv" ]; then
|
||||
UV_CMD="$_managed_uv"
|
||||
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
||||
log_success "uv found ($UV_VERSION)"
|
||||
log_success "Managed uv found ($UV_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check ~/.local/bin (default uv install location) even if not on PATH yet
|
||||
if [ -x "$HOME/.local/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.local/bin/uv"
|
||||
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
||||
log_success "uv found at ~/.local/bin ($UV_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
log_info "Installing managed uv into $HERMES_HOME/bin ..."
|
||||
mkdir -p "$HERMES_HOME/bin"
|
||||
|
||||
# Check ~/.cargo/bin (alternative uv install location)
|
||||
if [ -x "$HOME/.cargo/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.cargo/bin/uv"
|
||||
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
||||
log_success "uv found at ~/.cargo/bin ($UV_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Install uv
|
||||
log_info "Installing uv (fast Python package manager)..."
|
||||
# Capture installer output so a failure shows the user WHY (network,
|
||||
# glibc mismatch on old distros, missing curl, ~/.local/bin not
|
||||
# writable, disk full, corp proxy / TLS interception, etc.) instead
|
||||
# of the previous "✗ Failed to install uv" with zero diagnostic.
|
||||
#
|
||||
# Two-stage: download the installer, then run it. Piping
|
||||
# `curl | sh` masks curl failures (sh exits 0 on empty stdin)
|
||||
# and conflates network errors with installer errors.
|
||||
@@ -522,26 +505,22 @@ install_uv() {
|
||||
rm -f "$_uv_install_log" "$_uv_installer"
|
||||
exit 1
|
||||
fi
|
||||
if sh "$_uv_installer" >>"$_uv_install_log" 2>&1; then
|
||||
# UV_UNMANAGED_INSTALL tells the astral installer to place the binary
|
||||
# directly into $HERMES_HOME/bin instead of ~/.local/bin.
|
||||
if UV_UNMANAGED_INSTALL="$HERMES_HOME/bin" sh "$_uv_installer" >>"$_uv_install_log" 2>&1; then
|
||||
rm -f "$_uv_installer"
|
||||
# uv installs to ~/.local/bin by default
|
||||
if [ -x "$HOME/.local/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.local/bin/uv"
|
||||
elif [ -x "$HOME/.cargo/bin/uv" ]; then
|
||||
UV_CMD="$HOME/.cargo/bin/uv"
|
||||
elif command -v uv &> /dev/null; then
|
||||
UV_CMD="uv"
|
||||
if [ -x "$_managed_uv" ]; then
|
||||
UV_CMD="$_managed_uv"
|
||||
else
|
||||
log_error "uv installer reported success but binary not found on PATH"
|
||||
log_error "uv installer reported success but binary not found at $_managed_uv"
|
||||
log_info "Installer output:"
|
||||
sed 's/^/ /' "$_uv_install_log" >&2
|
||||
log_info "Try adding ~/.local/bin to your PATH and re-running"
|
||||
rm -f "$_uv_install_log"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$_uv_install_log"
|
||||
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
||||
log_success "uv installed ($UV_VERSION)"
|
||||
log_success "Managed uv installed ($UV_VERSION)"
|
||||
else
|
||||
log_error "Failed to install uv"
|
||||
log_info "Installer output:"
|
||||
@@ -579,7 +558,6 @@ check_python() {
|
||||
if PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION" 2>/dev/null)"; then
|
||||
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
|
||||
log_success "Python found: $PYTHON_FOUND_VERSION"
|
||||
ensure_fts5
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -589,7 +567,6 @@ check_python() {
|
||||
PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION")"
|
||||
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
|
||||
log_success "Python installed: $PYTHON_FOUND_VERSION"
|
||||
ensure_fts5
|
||||
else
|
||||
log_error "Failed to install Python $PYTHON_VERSION"
|
||||
log_info "Install Python $PYTHON_VERSION manually, then re-run this script"
|
||||
@@ -597,104 +574,6 @@ check_python() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Probe whether $1 (a python executable) links a SQLite with the FTS5
|
||||
# module compiled in. Hermes' session store (hermes_state.py) creates FTS5
|
||||
# virtual tables for full-text session search; a SQLite without FTS5 makes
|
||||
# the bundled-python path unusable for that feature. Returns 0 if FTS5 works.
|
||||
_python_has_fts5() {
|
||||
"$1" - <<'PY' 2>/dev/null
|
||||
import sqlite3, sys
|
||||
try:
|
||||
sqlite3.connect(":memory:").execute("CREATE VIRTUAL TABLE t USING fts5(x)")
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
# Reinstall $PYTHON_VERSION with the current uv and re-resolve PYTHON_PATH.
|
||||
# Returns 0 if the resulting interpreter ships FTS5.
|
||||
_reinstall_python_with_fts5() {
|
||||
local uv_bin="$1"
|
||||
"$uv_bin" python install "$PYTHON_VERSION" --reinstall >/dev/null 2>&1 || return 1
|
||||
PYTHON_PATH="$("$uv_bin" python find "$PYTHON_VERSION" 2>/dev/null)"
|
||||
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
|
||||
[ -n "${PYTHON_PATH:-}" ] && _python_has_fts5 "$PYTHON_PATH"
|
||||
}
|
||||
|
||||
_warn_no_fts5() {
|
||||
# Could not obtain an FTS5-capable interpreter (offline, pinned env, etc.).
|
||||
# Install proceeds — Hermes degrades gracefully and disables only full-text
|
||||
# session search — but warn so it isn't a silent gap.
|
||||
log_warn "Could not obtain an FTS5-capable Python. Hermes will run, but"
|
||||
log_warn "full-text session search will be disabled until FTS5 is present."
|
||||
}
|
||||
|
||||
# Guarantee the resolved uv-managed interpreter ships FTS5. uv's Python
|
||||
# distributions only gained FTS5 in mid-2025 (python-build-standalone #694),
|
||||
# but WHICH builds a given uv can install is baked into the uv binary's
|
||||
# download manifest — so a stale uv (e.g. `pip install uv==0.7.20`) only knows
|
||||
# about pre-FTS5 builds, and even `uv python install --reinstall` just pulls the
|
||||
# same FTS5-less interpreter. A plain reinstall with an old uv is therefore a
|
||||
# no-op for FTS5. To actually fix everyone's install, we escalate uv itself:
|
||||
#
|
||||
# 1. reinstall with the current $UV_CMD (handles a stale *interpreter* under
|
||||
# an already-current uv)
|
||||
# 2. if still no FTS5, bring uv up to date (`uv self update`) and reinstall —
|
||||
# this is what fixes a stale standalone uv
|
||||
# 3. if uv can't self-update (pip/apt/brew-managed uv refuses), install a
|
||||
# fresh standalone uv via the official installer into a temp dir and use
|
||||
# THAT to reinstall — this fixes package-manager-managed stale uv
|
||||
#
|
||||
# Pythons live in uv's shared store, so a fresh uv's --reinstall overwrites the
|
||||
# stale interpreter in place and the installer's later `uv python find` resolves
|
||||
# to it. Keeps session search working without bundling a second SQLite or asking
|
||||
# the user to do anything.
|
||||
ensure_fts5() {
|
||||
[ -n "${PYTHON_PATH:-}" ] || return 0
|
||||
if _python_has_fts5 "$PYTHON_PATH"; then
|
||||
return 0
|
||||
fi
|
||||
# Termux / non-uv installs have nothing to escalate.
|
||||
[ -n "${UV_CMD:-}" ] || { _warn_no_fts5; return 0; }
|
||||
|
||||
log_warn "Resolved Python's SQLite lacks the FTS5 module (session search needs it)."
|
||||
log_info "Reinstalling a current Python $PYTHON_VERSION with FTS5 via uv..."
|
||||
if _reinstall_python_with_fts5 "$UV_CMD"; then
|
||||
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Still no FTS5 — the uv binary itself is too old to know about FTS5-capable
|
||||
# Python builds. Try to update uv in place.
|
||||
log_info "uv is too old to provide an FTS5-capable Python — updating uv..."
|
||||
if "$UV_CMD" self update >/dev/null 2>&1; then
|
||||
if _reinstall_python_with_fts5 "$UV_CMD"; then
|
||||
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# `uv self update` is unavailable on externally-managed uv (pip/apt/brew),
|
||||
# which is exactly the case the user hit (`pip install uv==0.7.20`). Install
|
||||
# a fresh standalone uv into a temp dir and use it just for the reinstall.
|
||||
log_info "Installing an up-to-date standalone uv to obtain an FTS5 Python..."
|
||||
local _tmp_uv_dir _fresh_uv
|
||||
_tmp_uv_dir="$(mktemp -d 2>/dev/null || echo "/tmp/hermes-fresh-uv.$$")"
|
||||
mkdir -p "$_tmp_uv_dir"
|
||||
if curl -LsSf https://astral.sh/uv/install.sh 2>/dev/null \
|
||||
| env UV_INSTALL_DIR="$_tmp_uv_dir" UV_UNMANAGED_INSTALL="$_tmp_uv_dir" sh >/dev/null 2>&1; then
|
||||
_fresh_uv="$_tmp_uv_dir/uv"
|
||||
if [ -x "$_fresh_uv" ] && _reinstall_python_with_fts5 "$_fresh_uv"; then
|
||||
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
|
||||
rm -rf "$_tmp_uv_dir"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
rm -rf "$_tmp_uv_dir"
|
||||
|
||||
_warn_no_fts5
|
||||
}
|
||||
|
||||
# Best-effort automatic git provisioning, mirroring install.ps1's Install-Git
|
||||
# (which downloads PortableGit on Windows). git is required to clone the repo,
|
||||
# and a fresh "normie" machine with no developer tools won't have it. Returns 0
|
||||
@@ -2343,10 +2222,10 @@ postinstall_mode() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Build apps/desktop into a launchable Hermes.app. Mirrors install.ps1's
|
||||
# Build apps/desktop into a launchable native app. Mirrors install.ps1's
|
||||
# Install-Desktop: a root-level npm install so the apps/* workspace resolves
|
||||
# the desktop's own deps (Electron ~150MB), then `npm run pack`
|
||||
# (electron-builder --dir) which emits release/mac*/Hermes.app. Only invoked
|
||||
# (electron-builder --dir) which emits an unpacked app for the current OS. Only invoked
|
||||
# via the 'desktop' stage / --include-desktop, which the Electron app's own
|
||||
# first-launch bootstrap never requests (it must not rebuild itself).
|
||||
install_desktop() {
|
||||
@@ -2382,7 +2261,7 @@ install_desktop() {
|
||||
log_success "Desktop workspace dependencies installed"
|
||||
|
||||
# 2. Build. `npm run pack` = tsc + vite build + electron-builder --dir,
|
||||
# producing an unpacked release/mac*/Hermes.app. We disable signing
|
||||
# producing an unpacked app for the current OS. We disable signing
|
||||
# auto-discovery so electron-builder falls back to an ad-hoc signature
|
||||
# instead of grabbing an unrelated Developer ID from the keychain; a
|
||||
# real signed/notarized .dmg needs Apple credentials and is a separate
|
||||
@@ -2395,21 +2274,53 @@ install_desktop() {
|
||||
}
|
||||
|
||||
local app=""
|
||||
local cand
|
||||
for cand in \
|
||||
"$desktop_dir/release/mac-arm64/Hermes.app" \
|
||||
"$desktop_dir/release/mac/Hermes.app"; do
|
||||
if [ -d "$cand" ]; then
|
||||
app="$cand"
|
||||
break
|
||||
if [ "$OS" = "linux" ]; then
|
||||
if [ -x "$desktop_dir/release/linux-unpacked/Hermes" ]; then
|
||||
app="$desktop_dir/release/linux-unpacked/Hermes"
|
||||
elif [ -x "$desktop_dir/release/linux-unpacked/hermes" ]; then
|
||||
app="$desktop_dir/release/linux-unpacked/hermes"
|
||||
fi
|
||||
done
|
||||
else
|
||||
local cand
|
||||
for cand in \
|
||||
"$desktop_dir/release/mac-arm64/Hermes.app" \
|
||||
"$desktop_dir/release/mac/Hermes.app"; do
|
||||
if [ -d "$cand" ]; then
|
||||
app="$cand"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "$app" ]; then
|
||||
log_error "Desktop build completed but no Hermes.app was found under $desktop_dir/release/"
|
||||
log_error "Desktop build completed but no app was found under $desktop_dir/release/"
|
||||
return 1
|
||||
fi
|
||||
log_success "Desktop app built: $app"
|
||||
|
||||
# Linux: Electron's chrome-sandbox helper needs root:root 4755 or the
|
||||
# sandboxed renderer will abort on startup. Check the file is a regular
|
||||
# file (not a symlink) before chown/chmod so we don't follow an
|
||||
# attacker-controlled link to an arbitrary path.
|
||||
if [ "$OS" = "linux" ]; then
|
||||
local sandbox="$desktop_dir/release/linux-unpacked/chrome-sandbox"
|
||||
if [ -f "$sandbox" ] && [ ! -L "$sandbox" ]; then
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
chown root:root "$sandbox" && chmod 4755 "$sandbox" || {
|
||||
log_error "Cannot configure Electron sandbox helper: $sandbox"
|
||||
return 1
|
||||
}
|
||||
elif command -v sudo >/dev/null 2>&1; then
|
||||
sudo chown root:root "$sandbox" && sudo chmod 4755 "$sandbox" || {
|
||||
log_error "Cannot configure Electron sandbox helper (sudo failed): $sandbox"
|
||||
return 1
|
||||
}
|
||||
else
|
||||
log_error "Cannot configure Electron sandbox helper without sudo: $sandbox"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# macOS: make the locally-built (ad-hoc) app relaunchable after an in-place
|
||||
# self-update. An ad-hoc bundle has no stable Designated Requirement, so a
|
||||
# later in-place rebuild (new cdhash) plus the inherited quarantine flag
|
||||
|
||||
@@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"liliangjya@gmail.com": "truenorth-lj",
|
||||
"ben.bartholomew@vectorize.io": "benfrank241",
|
||||
"74339271+SaguaroDev@users.noreply.github.com": "SaguaroDev",
|
||||
"subw3@mail2.sysu.edu.cn": "Subway2023",
|
||||
@@ -373,6 +374,7 @@ AUTHOR_MAP = {
|
||||
"harish.kukreja@gmail.com": "counterposition",
|
||||
"nidhi2894@gmail.com": "nidhi-singh02",
|
||||
"35294173+Fearvox@users.noreply.github.com": "Fearvox",
|
||||
"fearvox1015@gmail.com": "Fearvox",
|
||||
"hypnus.yuan@gmail.com": "Hypnus-Yuan",
|
||||
"15558128926@qq.com": "xsfX20",
|
||||
"binhnt.ht.92@gmail.com": "binhnt92",
|
||||
@@ -1424,6 +1426,7 @@ AUTHOR_MAP = {
|
||||
"2663402852@qq.com": "x1am1", # PR #35098 (chown root-owned top-level HERMES_HOME state files)
|
||||
"nicsequenzy@gmail.com": "polnikale", # PR #35717 (discover Playwright headless_shell browser)
|
||||
"wasdhkzk@gmail.com": "whyhkzk", # PR #32407 (sandbox-mirror inner-container guard; commits authored as whyhkzk + zhukun)
|
||||
"leonard@sellem.me": "leonardsellem", # PR #37405 (desktop WS origin guard on remote/Tailscale binds)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ from agent.auxiliary_client import (
|
||||
_get_provider_chain,
|
||||
_is_payment_error,
|
||||
_is_rate_limit_error,
|
||||
_is_model_not_found_error,
|
||||
_refresh_nous_recommended_model,
|
||||
_normalize_aux_provider,
|
||||
_try_payment_fallback,
|
||||
_resolve_auto,
|
||||
@@ -1298,6 +1300,108 @@ class TestIsPaymentError:
|
||||
assert _is_payment_error(exc) is False
|
||||
|
||||
|
||||
class TestIsModelNotFoundError:
|
||||
"""_is_model_not_found_error detects stale/invalid model 404s, distinct
|
||||
from payment errors."""
|
||||
|
||||
def test_nous_openrouter_catalog_404(self):
|
||||
"""The exact incident error: a Portal-recommended model dropped from
|
||||
the Nous → OpenRouter catalog."""
|
||||
exc = Exception(
|
||||
"Model 'gpt-5.4-mini' not found. The requested model does not "
|
||||
"exist in our configuration or OpenRouter catalog."
|
||||
)
|
||||
exc.status_code = 404
|
||||
assert _is_model_not_found_error(exc) is True
|
||||
|
||||
def test_openai_style_model_does_not_exist(self):
|
||||
exc = Exception("The model `gpt-9-turbo` does not exist")
|
||||
exc.status_code = 404
|
||||
assert _is_model_not_found_error(exc) is True
|
||||
|
||||
def test_invalid_model_id_400(self):
|
||||
exc = Exception("openrouter/foo/bar is not a valid model ID")
|
||||
exc.status_code = 400
|
||||
assert _is_model_not_found_error(exc) is True
|
||||
|
||||
def test_no_such_model(self):
|
||||
exc = Exception("no such model: phantom-v1")
|
||||
exc.status_code = 400
|
||||
assert _is_model_not_found_error(exc) is True
|
||||
|
||||
def test_billing_404_is_not_model_not_found(self):
|
||||
"""Free-tier / credit 404s belong to _is_payment_error, not here —
|
||||
the two predicates must not overlap."""
|
||||
exc = Exception(
|
||||
"Model 'gpt-5' is not available on the free tier. Upgrade."
|
||||
)
|
||||
exc.status_code = 404
|
||||
assert _is_model_not_found_error(exc) is False
|
||||
assert _is_payment_error(exc) is True
|
||||
|
||||
def test_out_of_funds_404_is_not_model_not_found(self):
|
||||
exc = Exception(
|
||||
"Your API key is blocked or out of funds. model_not_found"
|
||||
)
|
||||
exc.status_code = 404
|
||||
# billing keyword wins — payment owns it
|
||||
assert _is_model_not_found_error(exc) is False
|
||||
|
||||
def test_rate_limit_is_not_model_not_found(self):
|
||||
exc = Exception("rate limit exceeded, retry after 5s")
|
||||
exc.status_code = 429
|
||||
assert _is_model_not_found_error(exc) is False
|
||||
|
||||
def test_500_is_not_model_not_found(self):
|
||||
exc = Exception("model does not exist") # right phrase, wrong status
|
||||
exc.status_code = 500
|
||||
assert _is_model_not_found_error(exc) is False
|
||||
|
||||
|
||||
class TestRefreshNousRecommendedModel:
|
||||
"""_refresh_nous_recommended_model picks a fresh model after a stale 404."""
|
||||
|
||||
def test_returns_fresh_portal_recommendation(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.get_nous_recommended_aux_model",
|
||||
lambda **kw: "stepfun/step-3.7-flash:free",
|
||||
)
|
||||
out = _refresh_nous_recommended_model(
|
||||
vision=True, stale_model="openai/gpt-5.4-mini")
|
||||
assert out == "stepfun/step-3.7-flash:free"
|
||||
|
||||
def test_falls_back_to_default_when_portal_matches_stale(self, monkeypatch):
|
||||
"""If the Portal still recommends the model that just 404'd, fall back
|
||||
to the known-good default."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.get_nous_recommended_aux_model",
|
||||
lambda **kw: "openai/gpt-5.4-mini",
|
||||
)
|
||||
out = _refresh_nous_recommended_model(
|
||||
vision=True, stale_model="openai/gpt-5.4-mini")
|
||||
assert out == "google/gemini-3-flash-preview"
|
||||
|
||||
def test_falls_back_to_default_when_portal_unavailable(self, monkeypatch):
|
||||
def _boom(**kw):
|
||||
raise RuntimeError("portal down")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.get_nous_recommended_aux_model", _boom)
|
||||
out = _refresh_nous_recommended_model(
|
||||
vision=False, stale_model="some/dead-model")
|
||||
assert out == "google/gemini-3-flash-preview"
|
||||
|
||||
def test_returns_none_when_no_distinct_alternative(self, monkeypatch):
|
||||
"""When the failed model IS the default and the Portal has nothing
|
||||
else, there's no usable alternative."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.get_nous_recommended_aux_model",
|
||||
lambda **kw: "google/gemini-3-flash-preview",
|
||||
)
|
||||
out = _refresh_nous_recommended_model(
|
||||
vision=False, stale_model="google/gemini-3-flash-preview")
|
||||
assert out is None
|
||||
|
||||
|
||||
class TestIsRateLimitError:
|
||||
"""_is_rate_limit_error detects 429 rate-limit errors warranting fallback."""
|
||||
|
||||
|
||||
@@ -128,11 +128,11 @@ class TestPriorityProcessingModels(unittest.TestCase):
|
||||
assert model_supports_fast_mode(model), f"{model} should support fast mode"
|
||||
|
||||
def test_all_anthropic_models_supported(self):
|
||||
"""Per Anthropic docs, fast mode is currently Opus 4.6 only.
|
||||
"""The speed=fast parameter is gated to Opus 4.6.
|
||||
|
||||
Sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
|
||||
Pre-fix this test asserted all Claude variants supported fast mode,
|
||||
which mirrored the bug rather than the API contract.
|
||||
(Opus 4.8's fast offering is a separate ``…-fast`` model id selected
|
||||
via the model field, not this parameter — see the adapter test.)
|
||||
"""
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
@@ -144,16 +144,15 @@ class TestPriorityProcessingModels(unittest.TestCase):
|
||||
for model in supported:
|
||||
assert model_supports_fast_mode(model), f"{model} should support fast mode"
|
||||
|
||||
# Unsupported per Anthropic API: Opus 4.7, Sonnet, Haiku
|
||||
# Unsupported per Anthropic API: Opus 4.7/4.8, Sonnet, Haiku
|
||||
unsupported = [
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-7", "claude-opus-4-8", "claude-opus-4.8",
|
||||
"claude-sonnet-4-6", "claude-sonnet-4.6", "claude-sonnet-4",
|
||||
"claude-haiku-4-5", "claude-3-5-haiku",
|
||||
]
|
||||
for model in unsupported:
|
||||
assert not model_supports_fast_mode(model), (
|
||||
f"{model} should NOT support fast mode — Anthropic restricts "
|
||||
f"speed=fast to Opus 4.6"
|
||||
f"{model} should NOT support the speed=fast parameter"
|
||||
)
|
||||
|
||||
def test_codex_models_excluded(self):
|
||||
@@ -275,10 +274,11 @@ class TestAnthropicFastMode(unittest.TestCase):
|
||||
assert model_supports_fast_mode("anthropic/claude-opus-4.6") is True
|
||||
|
||||
def test_anthropic_non_opus46_models_excluded(self):
|
||||
"""Anthropic restricts fast mode to Opus 4.6 — others must be excluded.
|
||||
"""The speed=fast parameter is gated to Opus 4.6 — others excluded.
|
||||
|
||||
Per https://platform.claude.com/docs/en/build-with-claude/fast-mode,
|
||||
sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
|
||||
Opus 4.8 uses a separate ``…-fast`` model id, not this parameter.
|
||||
"""
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
@@ -286,6 +286,7 @@ class TestAnthropicFastMode(unittest.TestCase):
|
||||
assert model_supports_fast_mode("claude-sonnet-4.6") is False
|
||||
assert model_supports_fast_mode("claude-haiku-4-5") is False
|
||||
assert model_supports_fast_mode("claude-opus-4-7") is False
|
||||
assert model_supports_fast_mode("claude-opus-4-8") is False
|
||||
assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is False
|
||||
assert model_supports_fast_mode("anthropic/claude-opus-4-7") is False
|
||||
|
||||
@@ -314,13 +315,15 @@ class TestAnthropicFastMode(unittest.TestCase):
|
||||
assert result == {"speed": "fast"}
|
||||
|
||||
def test_resolve_overrides_returns_none_for_unsupported_claude(self):
|
||||
"""Opus 4.7 and other Claude models don't support fast mode (API 400s).
|
||||
"""Opus 4.7/4.8 and other Claude models don't take the speed param.
|
||||
|
||||
Per Anthropic docs, fast mode is currently Opus 4.6 only.
|
||||
The speed=fast parameter is Opus 4.6 only (Opus 4.8 uses a separate
|
||||
``…-fast`` model id instead).
|
||||
"""
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
assert resolve_fast_mode_overrides("claude-opus-4-7") is None
|
||||
assert resolve_fast_mode_overrides("claude-opus-4-8") is None
|
||||
assert resolve_fast_mode_overrides("claude-sonnet-4-6") is None
|
||||
assert resolve_fast_mode_overrides("claude-haiku-4-5") is None
|
||||
|
||||
@@ -332,7 +335,7 @@ class TestAnthropicFastMode(unittest.TestCase):
|
||||
assert result == {"service_tier": "priority"}
|
||||
|
||||
def test_is_anthropic_fast_model(self):
|
||||
"""Fast mode is currently Opus 4.6 only — other Claude variants must be excluded."""
|
||||
"""The speed=fast parameter is Opus 4.6 only — other Claude excluded."""
|
||||
from hermes_cli.models import _is_anthropic_fast_model
|
||||
|
||||
# Supported: Opus 4.6 in any form
|
||||
@@ -341,8 +344,9 @@ class TestAnthropicFastMode(unittest.TestCase):
|
||||
assert _is_anthropic_fast_model("anthropic/claude-opus-4-6") is True
|
||||
assert _is_anthropic_fast_model("claude-opus-4.6:fast") is True
|
||||
|
||||
# Unsupported per Anthropic API contract — would 400 if we sent speed=fast
|
||||
# Unsupported — would 400 (4.7) or uses a separate model id (4.8)
|
||||
assert _is_anthropic_fast_model("claude-opus-4-7") is False
|
||||
assert _is_anthropic_fast_model("claude-opus-4-8") is False
|
||||
assert _is_anthropic_fast_model("claude-sonnet-4-6") is False
|
||||
assert _is_anthropic_fast_model("claude-haiku-4-5") is False
|
||||
|
||||
@@ -368,7 +372,7 @@ class TestAnthropicFastMode(unittest.TestCase):
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is False
|
||||
|
||||
def test_fast_command_hidden_for_anthropic_opus_47(self):
|
||||
"""Opus 4.7 doesn't support fast mode — /fast must be hidden."""
|
||||
"""Opus 4.7 doesn't take the speed=fast parameter — /fast must hide."""
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
provider="anthropic", requested_provider="anthropic",
|
||||
|
||||
@@ -41,6 +41,7 @@ def cron_env(tmp_path, monkeypatch):
|
||||
(hermes_home / "cron").mkdir()
|
||||
(hermes_home / "cron" / "output").mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("HERMES_BUNDLES_DIR", str(hermes_home / "skill-bundles"))
|
||||
|
||||
# Patch the module-level SKILLS_DIR snapshots that `skill_view()`
|
||||
# uses. Without this, the tool resolves against the real
|
||||
@@ -49,6 +50,11 @@ def cron_env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_skills_tool, "SKILLS_DIR", skills_dir)
|
||||
monkeypatch.setattr(_skills_tool, "HERMES_HOME", hermes_home)
|
||||
|
||||
# Reset bundle cache and make bundle discovery hit this test home.
|
||||
import agent.skill_bundles as _skill_bundles
|
||||
_skill_bundles._bundles_cache = {}
|
||||
_skill_bundles._bundles_cache_mtime = None
|
||||
|
||||
# Return both the home dir and the scheduler module so tests use the
|
||||
# CURRENT module object (post any reload that happened in fixtures of
|
||||
# previously-executed tests in the same worker).
|
||||
@@ -66,6 +72,20 @@ def _plant_skill(hermes_home: Path, name: str, body: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _plant_bundle(hermes_home: Path, name: str, skills: list[str], instruction: str = "") -> None:
|
||||
"""Drop a bundle YAML into ~/.hermes/skill-bundles/ and refresh cache."""
|
||||
bundles_dir = hermes_home / "skill-bundles"
|
||||
bundles_dir.mkdir(parents=True, exist_ok=True)
|
||||
lines = [f"name: {name}", "skills:"]
|
||||
lines.extend(f" - {skill}" for skill in skills)
|
||||
if instruction:
|
||||
lines.append("instruction: |")
|
||||
lines.extend(f" {line}" for line in instruction.splitlines())
|
||||
(bundles_dir / f"{name}.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
import agent.skill_bundles as _skill_bundles
|
||||
_skill_bundles.scan_bundles()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _scan_assembled_cron_prompt — isolated unit
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -255,3 +275,47 @@ class TestBuildJobPromptScansSkillContent:
|
||||
prompt = scheduler._build_job_prompt(job)
|
||||
assert prompt is not None
|
||||
assert "could not be found" in prompt
|
||||
|
||||
def test_skill_bundle_in_job_skills_loads_referenced_skills(self, cron_env):
|
||||
hermes_home, scheduler = cron_env
|
||||
_plant_skill(hermes_home, "alpha-skill", "Alpha guidance for the cron task.")
|
||||
_plant_skill(hermes_home, "beta-skill", "Beta guidance for the cron task.")
|
||||
_plant_bundle(
|
||||
hermes_home,
|
||||
"article-pipeline",
|
||||
["alpha-skill", "beta-skill"],
|
||||
instruction="Use the skills in order.",
|
||||
)
|
||||
|
||||
job = {
|
||||
"id": "job-bundle",
|
||||
"name": "bundle cron",
|
||||
"prompt": "write the report",
|
||||
"skills": ["article-pipeline"],
|
||||
}
|
||||
|
||||
prompt = scheduler._build_job_prompt(job)
|
||||
assert prompt is not None
|
||||
assert '"article-pipeline" skill bundle' in prompt
|
||||
assert "Alpha guidance for the cron task." in prompt
|
||||
assert "Beta guidance for the cron task." in prompt
|
||||
assert "Bundle instruction: Use the skills in order." in prompt
|
||||
assert "skill(s) were listed for this job but could not be found" not in prompt
|
||||
|
||||
def test_bundle_name_shadows_skill_name_for_cron_jobs(self, cron_env):
|
||||
hermes_home, scheduler = cron_env
|
||||
_plant_skill(hermes_home, "article-pipeline", "Standalone skill should not win.")
|
||||
_plant_skill(hermes_home, "bundle-member", "Bundle member should win.")
|
||||
_plant_bundle(hermes_home, "article-pipeline", ["bundle-member"])
|
||||
|
||||
job = {
|
||||
"id": "job-bundle-shadow",
|
||||
"name": "bundle shadows skill",
|
||||
"prompt": "run",
|
||||
"skills": ["article-pipeline"],
|
||||
}
|
||||
|
||||
prompt = scheduler._build_job_prompt(job)
|
||||
assert prompt is not None
|
||||
assert "Bundle member should win." in prompt
|
||||
assert "Standalone skill should not win." not in prompt
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Harness: the image ships a prebuilt TUI bundle, not a runtime npm install.
|
||||
|
||||
Regression guard for the hosted-chat failure where the embedded dashboard
|
||||
Chat tab died with a 502 / "[session ended]". Root cause: the image installs
|
||||
only a subset of the npm monorepo workspaces (root/web/ui-tui, never apps/*),
|
||||
so the actualized node_modules permanently disagrees with the canonical
|
||||
package-lock.json. Without HERMES_TUI_DIR set, ``_make_tui_argv`` falls
|
||||
through to ``_tui_need_npm_install`` (which returns True forever) and tries a
|
||||
runtime ``npm install`` that can never converge and races itself across
|
||||
concurrent /api/pty connections → ENOTEMPTY.
|
||||
|
||||
The fix is ``ENV HERMES_TUI_DIR=/opt/hermes/ui-tui`` in the Dockerfile, which
|
||||
makes the launcher take the prebuilt-bundle fast path (``node --expose-gc
|
||||
.../dist/entry.js``) and skip the install check entirely. These tests assert
|
||||
that invariant holds in the built image.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
|
||||
def _exec_py(image: str, py: str) -> str:
|
||||
"""Run a Python snippet inside the image as the hermes user, return stdout."""
|
||||
inner = (
|
||||
"source /opt/hermes/.venv/bin/activate && "
|
||||
"cd /opt/hermes && "
|
||||
f"python3 -c {shlex.quote(py)}"
|
||||
)
|
||||
# Drop to the hermes user (UID 10000) so we exercise the same path the
|
||||
# dashboard PTY child runs as — not root.
|
||||
cmd = [
|
||||
"docker", "run", "--rm", "--entrypoint", "su", image,
|
||||
"hermes", "-s", "/bin/bash", "-c", inner,
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
assert r.returncode == 0, f"in-container python failed:\n{r.stderr[-2000:]}"
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def test_hermes_tui_dir_env_is_set(built_image: str) -> None:
|
||||
"""HERMES_TUI_DIR must point at the prebuilt bundle dir in the image."""
|
||||
r = subprocess.run(
|
||||
["docker", "run", "--rm", "--entrypoint", "sh", built_image,
|
||||
"-c", 'printf "%s" "$HERMES_TUI_DIR"'],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert r.returncode == 0, r.stderr[-2000:]
|
||||
assert r.stdout.strip() == "/opt/hermes/ui-tui", (
|
||||
f"HERMES_TUI_DIR={r.stdout.strip()!r} (expected /opt/hermes/ui-tui)"
|
||||
)
|
||||
|
||||
|
||||
def test_prebuilt_bundle_present_and_no_runtime_install(built_image: str) -> None:
|
||||
"""The launcher must (a) find the prebuilt bundle and (b) NOT want an
|
||||
npm install — i.e. it takes the same path as a nix/packaged release."""
|
||||
py = (
|
||||
"import json\n"
|
||||
"from pathlib import Path\n"
|
||||
"from hermes_cli.main import _tui_need_npm_install, _find_bundled_tui, _make_tui_argv\n"
|
||||
"ui = Path('/opt/hermes/ui-tui')\n"
|
||||
"argv, cwd = _make_tui_argv(ui, tui_dev=False)\n"
|
||||
"out = {\n"
|
||||
" 'dist_entry_exists': (ui / 'dist' / 'entry.js').is_file(),\n"
|
||||
" 'need_npm_install': _tui_need_npm_install(ui),\n"
|
||||
" 'argv': argv,\n"
|
||||
" 'uses_prebuilt': ('dist/entry.js' in ' '.join(argv)) and ('npm' not in argv[0].lower()),\n"
|
||||
"}\n"
|
||||
"print(json.dumps(out))\n"
|
||||
)
|
||||
out = json.loads(_exec_py(built_image, py))
|
||||
assert out["dist_entry_exists"], "prebuilt ui-tui/dist/entry.js missing from image"
|
||||
# With HERMES_TUI_DIR set, _make_tui_argv returns the prebuilt path BEFORE
|
||||
# ever reaching the install check — so the resolved argv is what matters.
|
||||
assert out["uses_prebuilt"], f"launcher did not take prebuilt path: argv={out['argv']!r}"
|
||||
assert "npm" not in out["argv"][0].lower(), (
|
||||
f"launcher resolved to an npm invocation, not the prebuilt bundle: {out['argv']!r}"
|
||||
)
|
||||
@@ -267,6 +267,88 @@ class TestRunBackgroundTask:
|
||||
mock_agent_instance.shutdown_memory_provider.assert_called_once()
|
||||
mock_agent_instance.close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_files_routed_by_type(self, monkeypatch):
|
||||
"""Result media is routed to the type-specific sender, not send_document.
|
||||
|
||||
A TTS clip should arrive as a voice bubble, a video as a video, an
|
||||
image as a native image, and everything else as a document.
|
||||
"""
|
||||
from gateway import run as gateway_run
|
||||
|
||||
runner = _make_runner()
|
||||
runner._resolve_session_agent_runtime = MagicMock(
|
||||
return_value=("test-model", {"api_key": "test-key"})
|
||||
)
|
||||
runner._resolve_session_reasoning_config = MagicMock(return_value=None)
|
||||
runner._load_service_tier = MagicMock(return_value=None)
|
||||
runner._resolve_turn_agent_config = MagicMock(
|
||||
return_value={
|
||||
"model": "test-model",
|
||||
"runtime": {"api_key": "test-key"},
|
||||
"request_overrides": None,
|
||||
}
|
||||
)
|
||||
runner._run_in_executor_with_context = AsyncMock(
|
||||
return_value={"final_response": "see attached", "messages": []}
|
||||
)
|
||||
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
|
||||
|
||||
# Four real files so the media-delivery path validator accepts them
|
||||
# (default mode requires the file to exist as a regular file).
|
||||
import os as _os
|
||||
import tempfile as _tempfile
|
||||
_tmpdir = _tempfile.mkdtemp(prefix="bg_media_")
|
||||
_ogg = _os.path.join(_tmpdir, "clip.ogg")
|
||||
_mp4 = _os.path.join(_tmpdir, "render.mp4")
|
||||
_png = _os.path.join(_tmpdir, "chart.png")
|
||||
_pdf = _os.path.join(_tmpdir, "report.pdf")
|
||||
for _p in (_ogg, _mp4, _png, _pdf):
|
||||
with open(_p, "wb") as _fh:
|
||||
_fh.write(b"x")
|
||||
# ogg flagged as voice, mp4 video, png image, pdf doc.
|
||||
media = [
|
||||
(_ogg, True),
|
||||
(_mp4, False),
|
||||
(_png, False),
|
||||
(_pdf, False),
|
||||
]
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter.send = AsyncMock()
|
||||
mock_adapter.send_voice = AsyncMock()
|
||||
mock_adapter.send_video = AsyncMock()
|
||||
mock_adapter.send_image_file = AsyncMock()
|
||||
mock_adapter.send_document = AsyncMock()
|
||||
mock_adapter.send_image = AsyncMock()
|
||||
# No text, no markdown images — just the four media attachments.
|
||||
mock_adapter.extract_media = MagicMock(return_value=(media, ""))
|
||||
mock_adapter.extract_images = MagicMock(return_value=([], ""))
|
||||
# Non-telegram platform so every audio ext routes through send_voice.
|
||||
runner.adapters[Platform.DISCORD] = mock_adapter
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
user_id="12345",
|
||||
chat_id="67890",
|
||||
user_name="testuser",
|
||||
)
|
||||
|
||||
try:
|
||||
await runner._run_background_task("make stuff", source, "bg_test")
|
||||
|
||||
mock_adapter.send_voice.assert_called_once()
|
||||
assert mock_adapter.send_voice.call_args.kwargs["audio_path"] == _ogg
|
||||
mock_adapter.send_video.assert_called_once()
|
||||
assert mock_adapter.send_video.call_args.kwargs["video_path"] == _mp4
|
||||
mock_adapter.send_image_file.assert_called_once()
|
||||
assert mock_adapter.send_image_file.call_args.kwargs["image_path"] == _png
|
||||
mock_adapter.send_document.assert_called_once()
|
||||
assert mock_adapter.send_document.call_args.kwargs["file_path"] == _pdf
|
||||
finally:
|
||||
import shutil as _shutil
|
||||
_shutil.rmtree(_tmpdir, ignore_errors=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_dm_topic_completion_preserves_reply_anchor_metadata(self, monkeypatch):
|
||||
"""Background completion metadata must let Telegram send thread id plus reply id."""
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Regression tests for the gateway platform fd-leak fix (#37011).
|
||||
|
||||
Without an explicit ``disconnect()`` on adapters that fail to connect in
|
||||
the reconnect watcher, every retry leaks the resources the adapter
|
||||
opened in ``__init__`` — for ``APIServerAdapter`` that means 2 file
|
||||
descriptors per attempt (the SQLite ``response_store.db`` and its WAL
|
||||
sidecar). At the 300s backoff cap that's ~12 fds/hour; the default
|
||||
2560-fd ulimit is exhausted in ~12h of continuous failure, after which
|
||||
the gateway raises ``OSError: [Errno 24] Too many open files`` on
|
||||
every ``open()`` and becomes a zombie.
|
||||
|
||||
These tests pin all three failure paths in
|
||||
``_platform_reconnect_watcher`` (non-retryable error, retryable error,
|
||||
exception during connect) to call ``adapter.disconnect()`` on the
|
||||
unowned adapter, plus the path-level ``APIServerAdapter.disconnect()``
|
||||
behavior of also closing the ``ResponseStore``. The pre-fix
|
||||
implementation did not call ``disconnect()`` on any of these paths;
|
||||
this file would have caught the regression and now pins the fix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.api_server import APIServerAdapter, ResponseStore
|
||||
from gateway.platforms.base import BasePlatformAdapter, SendResult
|
||||
from gateway.run import GatewayRunner, _dispose_unused_adapter
|
||||
|
||||
|
||||
def _make_runner() -> GatewayRunner:
|
||||
"""Create a minimal GatewayRunner via object.__new__ to skip __init__.
|
||||
|
||||
Mirrors the helper in test_platform_reconnect.py so this file
|
||||
is drop-in compatible with the existing reconnect test suite.
|
||||
"""
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="test")}
|
||||
)
|
||||
runner._running = True
|
||||
runner._shutdown_event = asyncio.Event()
|
||||
runner._exit_reason = None
|
||||
runner._exit_with_failure = False
|
||||
runner._exit_cleanly = False
|
||||
runner._failed_platforms = {}
|
||||
runner.adapters = {}
|
||||
return runner
|
||||
|
||||
|
||||
async def _run_watcher_one_iteration(runner: GatewayRunner) -> None:
|
||||
"""Drive ``_platform_reconnect_watcher`` for exactly one retry pass.
|
||||
|
||||
Patches ``asyncio.sleep`` to advance the watcher's internal
|
||||
``await asyncio.sleep(10)`` initial delay and the 1-second inner
|
||||
sleeps without actually waiting. Mirrors the pattern used in
|
||||
``test_platform_reconnect.py::TestPlatformReconnectWatcher``.
|
||||
"""
|
||||
real_sleep = asyncio.sleep
|
||||
call_count = 0
|
||||
|
||||
async def fake_sleep(_n: float) -> None:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count > 2:
|
||||
# Two sleeps is enough to get past the initial 10s wait
|
||||
# and the first inner-tick check. After that, stop the
|
||||
# watcher so the test returns.
|
||||
runner._running = False
|
||||
await real_sleep(0)
|
||||
|
||||
with patch("asyncio.sleep", side_effect=fake_sleep):
|
||||
await runner._platform_reconnect_watcher()
|
||||
|
||||
|
||||
class _CountingAdapter(BasePlatformAdapter):
|
||||
"""Adapter that records every disconnect() call for fd-leak assertions.
|
||||
|
||||
The base ``BasePlatformAdapter.disconnect()`` is a no-op by default
|
||||
for stub adapters, which is why the pre-fix reconnect watcher
|
||||
silently leaked: the would-be dispose calls were happening on
|
||||
objects that did nothing on disconnect. This stub mimics the real
|
||||
``APIServerAdapter`` shape — every constructor call opens 2 fds
|
||||
(the SQLite db + WAL), and every disconnect() must close them.
|
||||
"""
|
||||
|
||||
def __init__(self, *, succeed: bool = False, fatal_error: str | None = None,
|
||||
fatal_retryable: bool = True, raise_during_connect: bool = False):
|
||||
super().__init__(PlatformConfig(enabled=True, token="t"), Platform.TELEGRAM)
|
||||
# 2 fds to track: the canonical "ResponseStore" pair. The
|
||||
# reconnect watcher should call disconnect() once per
|
||||
# construction; otherwise these stay open and contribute to
|
||||
# the gateway-wide fd count.
|
||||
self._open_fds = 2
|
||||
self._disconnect_calls = 0
|
||||
self._succeed = succeed
|
||||
self._fatal_error = fatal_error
|
||||
self._fatal_retryable = fatal_retryable
|
||||
self._raise_during_connect = raise_during_connect
|
||||
|
||||
async def connect(self) -> bool:
|
||||
if self._raise_during_connect:
|
||||
raise RuntimeError("simulated connect exception")
|
||||
if self._fatal_error:
|
||||
self._set_fatal_error(
|
||||
"test_code", self._fatal_error, retryable=self._fatal_retryable,
|
||||
)
|
||||
return False
|
||||
return self._succeed
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._disconnect_calls += 1
|
||||
self._open_fds = 0 # fd release on dispose
|
||||
|
||||
async def send(self, chat_id, content, reply_to=None, metadata=None):
|
||||
return SendResult(success=True, message_id="1")
|
||||
|
||||
async def send_typing(self, chat_id, metadata=None):
|
||||
return None
|
||||
|
||||
async def get_chat_info(self, chat_id):
|
||||
return {"id": chat_id}
|
||||
|
||||
|
||||
def _seed_runner_with_one_failure(runner: GatewayRunner) -> None:
|
||||
"""Queue a single platform for the reconnect watcher to pick up."""
|
||||
runner._failed_platforms[Platform.TELEGRAM] = {
|
||||
"config": PlatformConfig(enabled=True, token="t"),
|
||||
"attempts": 0,
|
||||
"next_retry": time.monotonic() - 1, # eligible immediately
|
||||
}
|
||||
|
||||
|
||||
class TestReconnectFDLeakRegression:
|
||||
"""All three reconnect failure paths must dispose the unowned adapter.
|
||||
|
||||
The pre-fix implementation constructed a fresh adapter on every
|
||||
retry and dropped it on the floor when connect() failed. That leaks
|
||||
2 fds per retry (for ``APIServerAdapter``) at the 300s backoff cap,
|
||||
exhausting the 2560-fd ulimit in ~12h of continuous failure (#37011).
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonretryable_failure_disposes_unowned_adapter(self):
|
||||
"""A fatal error (bad auth, etc.) must call disconnect() exactly once.
|
||||
|
||||
The adapter failed to connect and is being removed from the
|
||||
retry queue. Nothing else owns it, so the watcher is the only
|
||||
code with a chance to call disconnect() — and disconnect() is
|
||||
the only place the SQLite fds get closed. One retry, one
|
||||
dispose, no leak.
|
||||
"""
|
||||
runner = _make_runner()
|
||||
_seed_runner_with_one_failure(runner)
|
||||
adapter = _CountingAdapter(
|
||||
succeed=False, fatal_error="bad token", fatal_retryable=False,
|
||||
)
|
||||
with patch.object(runner, "_create_adapter", return_value=adapter), \
|
||||
patch.object(runner, "_connect_adapter_with_timeout",
|
||||
new=AsyncMock(return_value=False)):
|
||||
await _run_watcher_one_iteration(runner)
|
||||
|
||||
# The intent of this test is "the watcher calls disconnect()
|
||||
# exactly once on the unowned adapter" — not "at least once".
|
||||
# An accidental double-dispose would be a new bug to catch
|
||||
# (e.g. the watcher's two failure paths both calling dispose
|
||||
# for the same adapter instance). Tighten to == 1.
|
||||
assert adapter._disconnect_calls == 1, (
|
||||
f"non-retryable reconnect failure must call adapter.disconnect() "
|
||||
f"exactly once; got {adapter._disconnect_calls} calls. "
|
||||
"Without it, 2 fds leak per retry at the 300s backoff cap "
|
||||
"(#37011). More than one call would also be a bug — the "
|
||||
"adapter has already been disposed once, a second call is "
|
||||
"wasted work and may itself raise."
|
||||
)
|
||||
assert adapter._open_fds == 0, (
|
||||
f"adapter fds not released after disconnect(); "
|
||||
f"{adapter._open_fds} still open. This is the fd leak #37011."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retryable_failure_disposes_unowned_adapter(self):
|
||||
"""A retryable failure (network blip) must also call disconnect().
|
||||
|
||||
This is the path that fires most often in production: a
|
||||
transient DNS resolution failure or upstream outage, which
|
||||
back-offs to 300s and retries indefinitely. The watcher
|
||||
tracks ``info["attempts"]`` and reschedules, but the failed
|
||||
adapter is still dropped on the floor without dispose.
|
||||
"""
|
||||
runner = _make_runner()
|
||||
_seed_runner_with_one_failure(runner)
|
||||
adapter = _CountingAdapter(
|
||||
succeed=False, fatal_error="dns timeout", fatal_retryable=True,
|
||||
)
|
||||
with patch.object(runner, "_create_adapter", return_value=adapter), \
|
||||
patch.object(runner, "_connect_adapter_with_timeout",
|
||||
new=AsyncMock(return_value=False)):
|
||||
await _run_watcher_one_iteration(runner)
|
||||
|
||||
assert adapter._disconnect_calls >= 1, (
|
||||
f"retryable reconnect failure must call adapter.disconnect(); "
|
||||
f"got {adapter._disconnect_calls} calls. This is the hot path "
|
||||
"for the fd leak in #37011."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_during_connect_disposes_unowned_adapter(self):
|
||||
"""An exception escaping connect() (aiohttp start crash, etc.) disposes.
|
||||
|
||||
The ``except Exception`` arm in the watcher used to skip the
|
||||
dispose call entirely. Pre-fix, this leaked the same 2 fds
|
||||
per retry as the other two branches.
|
||||
"""
|
||||
runner = _make_runner()
|
||||
_seed_runner_with_one_failure(runner)
|
||||
adapter = _CountingAdapter(raise_during_connect=True)
|
||||
with patch.object(runner, "_create_adapter", return_value=adapter), \
|
||||
patch.object(runner, "_connect_adapter_with_timeout",
|
||||
new=AsyncMock(side_effect=RuntimeError("boom"))):
|
||||
await _run_watcher_one_iteration(runner)
|
||||
|
||||
assert adapter._disconnect_calls >= 1, (
|
||||
f"exception-during-connect must call adapter.disconnect(); "
|
||||
f"got {adapter._disconnect_calls} calls. The except-arm of the "
|
||||
"reconnect watcher is one of the three leak paths in #37011."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispose_helper_handles_none(self):
|
||||
"""``_dispose_unused_adapter(None)`` is a no-op (defensive)."""
|
||||
await _dispose_unused_adapter(None) # must not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispose_helper_swallows_disconnect_exception(self):
|
||||
"""A disconnect() that itself raises must not abort the watcher loop.
|
||||
|
||||
Half-constructed adapters can raise from disconnect() because
|
||||
some of their __init__ state is missing. The watcher loop
|
||||
would then die and stop retrying, masking the original
|
||||
configuration error as a hard crash.
|
||||
"""
|
||||
disconnect_calls = 0
|
||||
|
||||
class _RaisingAdapter(BasePlatformAdapter):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
PlatformConfig(enabled=True, token="t"),
|
||||
Platform.TELEGRAM,
|
||||
)
|
||||
|
||||
async def connect(self) -> bool:
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
nonlocal disconnect_calls
|
||||
disconnect_calls += 1
|
||||
raise RuntimeError("half-constructed; aiohttp app never started")
|
||||
|
||||
async def send(self, chat_id, content, reply_to=None, metadata=None):
|
||||
return SendResult(success=True, message_id="1")
|
||||
|
||||
async def send_typing(self, chat_id, metadata=None):
|
||||
return None
|
||||
|
||||
async def get_chat_info(self, chat_id):
|
||||
return {"id": chat_id}
|
||||
|
||||
await _dispose_unused_adapter(_RaisingAdapter()) # must not raise
|
||||
assert disconnect_calls == 1
|
||||
|
||||
|
||||
class TestAPIServerDisconnectClosesResponseStore:
|
||||
"""The platform-level fix: ``APIServerAdapter.disconnect()`` must close its ResponseStore.
|
||||
|
||||
Without this, the reconnect watcher's dispose call (see the
|
||||
test class above) is a no-op for ``APIServerAdapter`` — the
|
||||
aiohttp web server stops, but the SQLite ``ResponseStore``
|
||||
connection stays open. The DB file plus its WAL sidecar = 2 fds,
|
||||
which is the headline leak in #37011.
|
||||
"""
|
||||
|
||||
def _build_adapter_with_store(self, store: ResponseStore) -> APIServerAdapter:
|
||||
"""Build an APIServerAdapter with the required internal state.
|
||||
|
||||
We bypass ``__init__`` (which would try to start aiohttp
|
||||
immediately) and set just the fields ``disconnect()`` reads.
|
||||
"""
|
||||
adapter = APIServerAdapter.__new__(APIServerAdapter)
|
||||
adapter._mark_disconnected = lambda: None # type: ignore[method-assign]
|
||||
adapter._site = None
|
||||
adapter._runner = None
|
||||
adapter._app = None
|
||||
adapter._response_store = store
|
||||
adapter.platform = Platform.API_SERVER
|
||||
return adapter
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_closes_response_store(self, tmp_path):
|
||||
"""Closing the adapter's ResponseStore releases its SQLite connection.
|
||||
|
||||
We point the ``ResponseStore`` at a tmp db so we can verify
|
||||
its ``close()`` is called by ``APIServerAdapter.disconnect()``.
|
||||
The real ``ResponseStore.__init__`` opens a SQLite connection
|
||||
to ``~/.hermes/response_store.db`` (or :memory: as a fallback),
|
||||
which is exactly the resource that was leaking pre-fix.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
store = ResponseStore(max_size=10, db_path=str(tmp_path / "rs.db"))
|
||||
adapter = self._build_adapter_with_store(store)
|
||||
|
||||
await adapter.disconnect()
|
||||
|
||||
# Post-disconnect, the underlying sqlite3 conn should be closed.
|
||||
# sqlite3 raises ``ProgrammingError: Cannot operate on a closed
|
||||
# database`` for any further operation. We assert on the
|
||||
# specific exception type (not bare ``Exception``) so the test
|
||||
# only passes when the close actually took effect — a generic
|
||||
# ``Exception`` catcher would mask unrelated failures (env
|
||||
# issues, AttributeError, etc.).
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
store._conn.execute("SELECT 1").fetchone()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_swallows_response_store_close_exception(self, tmp_path):
|
||||
"""A misbehaving ResponseStore.close() must not abort adapter shutdown.
|
||||
|
||||
Real-world failure mode: the SQLite file was unlinked out
|
||||
from under us (operator rm'd ``response_store.db`` during a
|
||||
disk pressure event). ``close()`` raises. The watcher must
|
||||
continue with the aiohttp shutdown, not bail.
|
||||
"""
|
||||
store = ResponseStore(max_size=10, db_path=str(tmp_path / "rs.db"))
|
||||
|
||||
def _boom() -> None:
|
||||
raise RuntimeError("sqlite file vanished")
|
||||
|
||||
store.close = _boom # type: ignore[method-assign]
|
||||
adapter = self._build_adapter_with_store(store)
|
||||
|
||||
# Must not raise — disconnect() swallows the close error and
|
||||
# continues to the aiohttp teardown (no-op here since we
|
||||
# bypassed __init__).
|
||||
await adapter.disconnect()
|
||||
@@ -39,6 +39,42 @@ def mock_args():
|
||||
return SimpleNamespace()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Managed-uv compatibility for tests that patch shutil.which
|
||||
# ---------------------------------------------------------------------------
|
||||
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
|
||||
# instead of ``shutil.which("uv")``. Many tests in this file patch
|
||||
# ``shutil.which`` to control whether uv is "available" — these autouse
|
||||
# fixtures make the managed_uv functions delegate to the patched
|
||||
# ``shutil.which`` so the existing test setup keeps working without
|
||||
# per-test changes.
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_managed_uv(request):
|
||||
"""Make managed_uv helpers follow shutil.which mocking in tests."""
|
||||
import shutil
|
||||
|
||||
# resolve_uv delegates to shutil.which("uv") so that test patches
|
||||
# on shutil.which flow through naturally.
|
||||
def _fake_resolve_uv():
|
||||
return shutil.which("uv")
|
||||
|
||||
def _fake_ensure_uv():
|
||||
path = shutil.which("uv")
|
||||
return (path, False) # never freshly bootstrapped in tests
|
||||
|
||||
def _fake_update_managed_uv():
|
||||
return None # never actually self-update in tests
|
||||
|
||||
def _fake_rebuild_venv(*args, **kwargs):
|
||||
return True # no-op in tests
|
||||
|
||||
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
|
||||
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
|
||||
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \
|
||||
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
|
||||
yield
|
||||
|
||||
|
||||
class TestCmdUpdatePip:
|
||||
"""Regression tests for pip-install update flows."""
|
||||
|
||||
@@ -198,36 +234,50 @@ class TestCmdUpdateBranchFallback:
|
||||
if call.args and call.args[0][0] == "/usr/bin/npm"
|
||||
]
|
||||
|
||||
# cmd_update runs npm commands in four locations:
|
||||
# 1. repo root — slash-command / TUI bridge deps (subprocess.run)
|
||||
# 2. ui-tui/ — Ink TUI deps (subprocess.run)
|
||||
# 3. web/ — npm install (subprocess.run)
|
||||
# 4. web/ — npm run build (_run_with_idle_timeout)
|
||||
# cmd_update runs npm commands in these locations:
|
||||
# 1. repo root — root-only install (--workspaces=false)
|
||||
# 2. repo root — workspace install (--workspace ui-tui --workspace web)
|
||||
# 3. web/ — npm ci --silent (if lockfile not at root)
|
||||
# via _build_web_ui (subprocess.run)
|
||||
# 4. web/ — npm run build (_run_with_idle_timeout)
|
||||
#
|
||||
# Repo-root and ui-tui installs intentionally omit `--silent` and run
|
||||
# without `capture_output` so optional postinstall scripts (e.g.
|
||||
# With a single workspace lockfile at the repo root, the root
|
||||
# install covers all workspaces. The web/ ci call runs from the
|
||||
# workspace root too (parent of web_dir) when the root lockfile
|
||||
# exists.
|
||||
#
|
||||
# The root install omits `--silent` and runs without
|
||||
# `capture_output` so optional postinstall scripts (e.g.
|
||||
# `@askjo/camofox-browser`'s browser-binary fetch) print progress —
|
||||
# otherwise long downloads look like a hang (#18840). The web/ install
|
||||
# keeps `--silent` because its build step is short and noisy.
|
||||
update_flags = [
|
||||
# otherwise long downloads look like a hang (#18840).
|
||||
root_flags = [
|
||||
"/usr/bin/npm",
|
||||
"ci",
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
"--progress=false",
|
||||
"--workspaces=false",
|
||||
]
|
||||
ws_flags = [
|
||||
"/usr/bin/npm",
|
||||
"ci",
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
"--progress=false",
|
||||
"--workspace",
|
||||
"ui-tui",
|
||||
"--workspace",
|
||||
"web",
|
||||
]
|
||||
# Repo root additionally passes --workspaces=false so npm does not
|
||||
# recursively install every apps/* workspace (desktop, shared).
|
||||
repo_flags = [*update_flags, "--workspaces=false"]
|
||||
assert npm_calls[:2] == [
|
||||
(repo_flags, PROJECT_ROOT),
|
||||
(update_flags, PROJECT_ROOT / "ui-tui"),
|
||||
(root_flags, PROJECT_ROOT),
|
||||
(ws_flags, PROJECT_ROOT),
|
||||
]
|
||||
if len(npm_calls) > 2:
|
||||
# Only the web/ install is left in subprocess.run; the build moved
|
||||
# to _run_with_idle_timeout to make Vite progress visible (#33788).
|
||||
# The web/ install runs from the workspace root when the root
|
||||
# lockfile exists (npm workspaces hoist node_modules upward).
|
||||
assert npm_calls[2:] == [
|
||||
(["/usr/bin/npm", "ci", "--silent"], PROJECT_ROOT / "web"),
|
||||
(["/usr/bin/npm", "ci", "--silent"], PROJECT_ROOT),
|
||||
]
|
||||
|
||||
# The web UI build itself went through the streaming helper.
|
||||
@@ -236,21 +286,23 @@ class TestCmdUpdateBranchFallback:
|
||||
assert idle_args[0] == ["/usr/bin/npm", "run", "build"]
|
||||
assert idle_kwargs["cwd"] == PROJECT_ROOT / "web"
|
||||
|
||||
# Regression for #18840: repo root + ui-tui installs must stream
|
||||
# output (capture_output=False) so postinstall progress is visible
|
||||
# to the user.
|
||||
repo_and_tui_calls = [
|
||||
# Regression for #18840: root npm installs must stream output
|
||||
# (capture_output=False) so postinstall progress is visible
|
||||
# to the user. The _build_web_ui install uses --silent and
|
||||
# capture_output=True, so exclude it.
|
||||
root_install_calls = [
|
||||
call
|
||||
for call in mock_run.call_args_list
|
||||
if call.args
|
||||
and call.args[0][0] == "/usr/bin/npm"
|
||||
and call.args[0][1] == "ci"
|
||||
and call.kwargs.get("cwd") in {PROJECT_ROOT, PROJECT_ROOT / "ui-tui"}
|
||||
and call.kwargs.get("cwd") == PROJECT_ROOT
|
||||
and "--silent" not in call.args[0]
|
||||
]
|
||||
assert len(repo_and_tui_calls) == 2
|
||||
for call in repo_and_tui_calls:
|
||||
assert len(root_install_calls) == 2 # root-only + workspace install
|
||||
for call in root_install_calls:
|
||||
assert call.kwargs.get("capture_output") is False, (
|
||||
"repo-root / ui-tui npm install must stream output "
|
||||
"repo-root npm install must stream output "
|
||||
"(no capture_output) so postinstall progress is visible"
|
||||
)
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ from hermes_cli import web_server
|
||||
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
||||
from hermes_cli.dashboard_auth.ws_tickets import (
|
||||
_reset_for_tests,
|
||||
consume_ticket,
|
||||
consume_internal_credential,
|
||||
internal_ws_credential,
|
||||
mint_ticket,
|
||||
)
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
@@ -279,10 +280,33 @@ class TestWsAuthOkGated:
|
||||
content = log_file.read_text()
|
||||
assert "ws_ticket_rejected" in content
|
||||
|
||||
def test_internal_credential_accepted(self, gated_app):
|
||||
"""Server-spawned children present the process-lifetime internal
|
||||
credential via ?internal= and are accepted in gated mode."""
|
||||
cred = internal_ws_credential()
|
||||
ws = _fake_ws(query={"internal": cred})
|
||||
assert web_server._ws_auth_ok(ws) is True
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_sidecar_url — gated mode mints a server-internal ticket
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_internal_credential_is_multi_use(self, gated_app):
|
||||
"""Unlike single-use tickets, the internal credential survives
|
||||
repeated use so the child can reconnect."""
|
||||
cred = internal_ws_credential()
|
||||
for _ in range(3):
|
||||
ws = _fake_ws(query={"internal": cred})
|
||||
assert web_server._ws_auth_ok(ws) is True
|
||||
|
||||
def test_wrong_internal_credential_rejected(self, gated_app):
|
||||
# Mint the real one so the store is non-empty, then present a bogus value.
|
||||
internal_ws_credential()
|
||||
ws = _fake_ws(query={"internal": "not-the-internal-credential"})
|
||||
assert web_server._ws_auth_ok(ws) is False
|
||||
|
||||
def test_internal_credential_not_accepted_in_loopback(self, loopback_app):
|
||||
"""Outside gated mode, ?internal= is meaningless — only ?token= works.
|
||||
A naked internal credential must not authenticate."""
|
||||
cred = internal_ws_credential()
|
||||
ws = _fake_ws(query={"internal": cred})
|
||||
assert web_server._ws_auth_ok(ws) is False
|
||||
|
||||
|
||||
class TestWsRequestIsAllowedGated:
|
||||
@@ -379,10 +403,16 @@ class TestWsHostOriginGuardOrigins:
|
||||
"""The WS Origin guard must let the packaged desktop shell connect.
|
||||
|
||||
Electron loads the packaged renderer over ``file://``, so its WebSocket
|
||||
handshake carries ``Origin: file://`` (or the opaque ``null``). The
|
||||
DNS-rebinding guard only needs to block cross-site http(s) origins. On a
|
||||
loopback bind these non-web origins are trusted because the session token
|
||||
is the real gate. Public/gated binds keep rejecting them.
|
||||
handshake carries ``Origin: file://`` (or the opaque ``null``, or a custom
|
||||
``app://`` scheme). The DNS-rebinding guard only needs to block cross-site
|
||||
http(s) origins — a malicious web page can never forge a non-web origin.
|
||||
|
||||
This guard runs only AFTER ``_ws_auth_ok`` has validated the WS credential
|
||||
(session token on loopback / ``--insecure`` binds, single-use ``?ticket=``
|
||||
on OAuth-gated binds), so a non-web origin is trusted in every mode: the
|
||||
credential is the real gate, and a ``file://`` / ``null`` origin cannot
|
||||
originate a DNS-rebinding browser attack. ``http(s)`` origins are still
|
||||
match-checked against the bound host.
|
||||
"""
|
||||
|
||||
def _ws(self, *, origin, host):
|
||||
@@ -413,11 +443,56 @@ class TestWsHostOriginGuardOrigins:
|
||||
ws = self._ws(origin="http://evil.test", host="127.0.0.1:8080")
|
||||
assert web_server._ws_host_origin_is_allowed(ws) is False
|
||||
|
||||
def test_gated_file_origin_rejected(self, gated_app):
|
||||
# A public/gated bind has no legitimate file:// client.
|
||||
ws = self._ws(origin="file://", host="fly-app.fly.dev")
|
||||
def test_explicit_non_loopback_file_origin_allowed(self, insecure_explicit_host_app):
|
||||
"""Packaged Hermes Desktop also uses file:// when connecting to a
|
||||
Tailscale/LAN dashboard bind.
|
||||
|
||||
The WebSocket route calls _ws_auth_ok before this guard, so in
|
||||
non-gated mode the legacy session token remains the auth boundary.
|
||||
"""
|
||||
ws = self._ws(origin="file://", host="100.64.0.10:9119")
|
||||
assert web_server._ws_host_origin_is_allowed(ws) is True
|
||||
|
||||
def test_explicit_non_loopback_null_origin_allowed(self, insecure_explicit_host_app):
|
||||
ws = self._ws(origin="null", host="100.64.0.10:9119")
|
||||
assert web_server._ws_host_origin_is_allowed(ws) is True
|
||||
|
||||
def test_explicit_non_loopback_cross_site_http_origin_rejected(
|
||||
self, insecure_explicit_host_app
|
||||
):
|
||||
ws = self._ws(origin="http://localhost:9119", host="100.64.0.10:9119")
|
||||
assert web_server._ws_host_origin_is_allowed(ws) is False
|
||||
|
||||
def test_gated_file_origin_allowed(self, gated_app):
|
||||
# The packaged desktop app drives a remote OAuth-GATED gateway over a
|
||||
# file:// renderer origin. The WS route validates the single-use
|
||||
# ?ticket= in _ws_auth_ok before this guard runs, and a file:// origin
|
||||
# can't be a DNS-rebinding browser attack, so the Origin guard must let
|
||||
# it through. This is the regression that broke desktop → hosted
|
||||
# gateway connections — every WS upgrade got HTTP 403 even with a valid
|
||||
# ticket.
|
||||
ws = self._ws(origin="file://", host="fly-app.fly.dev")
|
||||
assert web_server._ws_host_origin_is_allowed(ws) is True
|
||||
|
||||
def test_gated_null_origin_allowed(self, gated_app):
|
||||
ws = self._ws(origin="null", host="fly-app.fly.dev")
|
||||
assert web_server._ws_host_origin_is_allowed(ws) is True
|
||||
|
||||
def test_gated_app_scheme_origin_allowed(self, gated_app):
|
||||
ws = self._ws(origin="app://.", host="fly-app.fly.dev")
|
||||
assert web_server._ws_host_origin_is_allowed(ws) is True
|
||||
|
||||
def test_gated_cross_site_http_origin_still_host_checked(self, gated_app):
|
||||
# An http(s) origin is still subjected to the same-host check even on a
|
||||
# gated bind: a cross-site http origin whose netloc doesn't match the
|
||||
# bound host is rejected. Real browser DNS-rebinding defence unchanged.
|
||||
ws = self._ws(origin="https://evil.test", host="fly-app.fly.dev")
|
||||
assert web_server._ws_host_origin_is_allowed(ws) is False
|
||||
|
||||
def test_gated_same_host_https_origin_allowed(self, gated_app):
|
||||
ws = self._ws(origin="https://fly-app.fly.dev", host="fly-app.fly.dev")
|
||||
assert web_server._ws_host_origin_is_allowed(ws) is True
|
||||
|
||||
|
||||
class TestSidecarUrl:
|
||||
def test_loopback_uses_session_token(self, loopback_app):
|
||||
@@ -426,18 +501,20 @@ class TestSidecarUrl:
|
||||
assert f"token={web_server._SESSION_TOKEN}" in url
|
||||
assert "ticket=" not in url
|
||||
|
||||
def test_gated_uses_ticket(self, gated_app):
|
||||
def test_gated_uses_internal_credential(self, gated_app):
|
||||
url = web_server._build_sidecar_url("ch-1")
|
||||
assert url is not None
|
||||
assert "token=" not in url
|
||||
assert "ticket=" in url
|
||||
# And the ticket should be live.
|
||||
ticket = url.split("ticket=")[1].split("&")[0]
|
||||
info = consume_ticket(ticket)
|
||||
# Sidecar tickets are bound to the pseudo-user so audit logs can
|
||||
# distinguish them from real browser tickets.
|
||||
assert info["user_id"] == "pty-sidecar"
|
||||
assert "ticket=" not in url
|
||||
assert "internal=" in url
|
||||
# The value should be the live process-lifetime internal credential,
|
||||
# multi-use so the child can reconnect /api/pub.
|
||||
cred = url.split("internal=")[1].split("&")[0]
|
||||
info = consume_internal_credential(cred)
|
||||
assert info["user_id"] == "server-internal"
|
||||
assert info["provider"] == "server-internal"
|
||||
# Multi-use: a second consume still succeeds (unlike a ticket).
|
||||
assert consume_internal_credential(cred)["provider"] == "server-internal"
|
||||
|
||||
def test_no_bound_host_returns_none(self, gated_app):
|
||||
web_server.app.state.bound_host = None
|
||||
@@ -445,3 +522,48 @@ class TestSidecarUrl:
|
||||
assert web_server._build_sidecar_url("ch") is None
|
||||
finally:
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_gateway_ws_url — the TUI child's primary JSON-RPC backend WS.
|
||||
# Loopback uses ?token=; gated mode uses the multi-use internal credential
|
||||
# (NOT a single-use ticket — the child reuses this URL across reconnects).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGatewayWsUrl:
|
||||
def test_loopback_uses_session_token(self, loopback_app):
|
||||
url = web_server._build_gateway_ws_url()
|
||||
assert url is not None
|
||||
assert "/api/ws?" in url
|
||||
assert f"token={web_server._SESSION_TOKEN}" in url
|
||||
assert "internal=" not in url
|
||||
|
||||
def test_gated_uses_internal_credential(self, gated_app):
|
||||
url = web_server._build_gateway_ws_url()
|
||||
assert url is not None
|
||||
assert "/api/ws?" in url
|
||||
assert "token=" not in url
|
||||
assert "ticket=" not in url
|
||||
assert "internal=" in url
|
||||
cred = url.split("internal=")[1].split("&")[0]
|
||||
# The credential authenticates against _ws_auth_ok in gated mode.
|
||||
ws = _fake_ws(query={"internal": cred})
|
||||
assert web_server._ws_auth_ok(ws) is True
|
||||
|
||||
def test_gated_credential_matches_sidecar(self, gated_app):
|
||||
"""Both server-internal builders share one process credential, so a
|
||||
single value authenticates /api/ws and /api/pub alike."""
|
||||
gw = web_server._build_gateway_ws_url()
|
||||
sc = web_server._build_sidecar_url("ch-1")
|
||||
assert gw is not None and sc is not None
|
||||
gw_cred = gw.split("internal=")[1].split("&")[0]
|
||||
sc_cred = sc.split("internal=")[1].split("&")[0]
|
||||
assert gw_cred == sc_cred
|
||||
|
||||
def test_no_bound_host_returns_none(self, gated_app):
|
||||
web_server.app.state.bound_host = None
|
||||
try:
|
||||
assert web_server._build_gateway_ws_url() is None
|
||||
finally:
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
|
||||
@@ -159,3 +159,73 @@ class TestConcurrency:
|
||||
assert len(results) == 20
|
||||
# Every consume returns a distinct user_id (no cross-thread bleed).
|
||||
assert {r["user_id"] for r in results} == {f"u{i}" for i in range(20)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process-lifetime internal credential (server-spawned PTY child auth).
|
||||
# Direct unit coverage for internal_ws_credential / consume_internal_credential
|
||||
# — _ws_auth_ok exercises these indirectly, but the mint-once, unminted, and
|
||||
# empty-value branches are only reachable via direct calls.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInternalCredential:
|
||||
def test_minted_once_is_stable(self):
|
||||
"""Successive calls return the same process-lifetime value."""
|
||||
first = ws_tickets.internal_ws_credential()
|
||||
second = ws_tickets.internal_ws_credential()
|
||||
assert first == second
|
||||
assert len(first) >= 32 # token_urlsafe(32)
|
||||
|
||||
def test_round_trip_identity(self):
|
||||
cred = ws_tickets.internal_ws_credential()
|
||||
info = ws_tickets.consume_internal_credential(cred)
|
||||
assert info["user_id"] == ws_tickets.INTERNAL_USER_ID
|
||||
assert info["provider"] == ws_tickets.INTERNAL_PROVIDER
|
||||
|
||||
def test_multi_use(self):
|
||||
"""Unlike a single-use ticket, the credential survives repeated consume."""
|
||||
cred = ws_tickets.internal_ws_credential()
|
||||
for _ in range(5):
|
||||
assert (
|
||||
ws_tickets.consume_internal_credential(cred)["provider"]
|
||||
== ws_tickets.INTERNAL_PROVIDER
|
||||
)
|
||||
|
||||
def test_rejected_before_mint(self):
|
||||
"""With nothing minted yet, any value is rejected (expected is None)."""
|
||||
# autouse _reset leaves _internal_credential == None at test start.
|
||||
with pytest.raises(TicketInvalid):
|
||||
ws_tickets.consume_internal_credential("anything")
|
||||
|
||||
def test_empty_value_rejected(self):
|
||||
ws_tickets.internal_ws_credential() # mint so expected is non-None
|
||||
with pytest.raises(TicketInvalid):
|
||||
ws_tickets.consume_internal_credential("")
|
||||
|
||||
def test_wrong_value_rejected(self):
|
||||
ws_tickets.internal_ws_credential()
|
||||
with pytest.raises(TicketInvalid):
|
||||
ws_tickets.consume_internal_credential("not-the-credential")
|
||||
|
||||
def test_reset_clears_and_remints(self):
|
||||
first = ws_tickets.internal_ws_credential()
|
||||
_reset_for_tests()
|
||||
# The old value no longer validates after reset.
|
||||
with pytest.raises(TicketInvalid):
|
||||
ws_tickets.consume_internal_credential(first)
|
||||
# A fresh mint produces a different value.
|
||||
second = ws_tickets.internal_ws_credential()
|
||||
assert second != first
|
||||
assert ws_tickets.consume_internal_credential(second)["user_id"] == (
|
||||
ws_tickets.INTERNAL_USER_ID
|
||||
)
|
||||
|
||||
def test_independent_of_ticket_store(self):
|
||||
"""The internal credential is not a ticket — minting tickets doesn't
|
||||
touch it, and consuming the credential doesn't consume tickets."""
|
||||
cred = ws_tickets.internal_ws_credential()
|
||||
ticket = mint_ticket(user_id="u1", provider="nous")
|
||||
# Consuming the internal credential leaves the ticket intact.
|
||||
ws_tickets.consume_internal_credential(cred)
|
||||
assert consume_ticket(ticket)["user_id"] == "u1"
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Tests for the configurable default interface (cli vs tui).
|
||||
|
||||
`hermes` launches the classic prompt_toolkit REPL by default, but users can
|
||||
flip ``display.interface: tui`` in config.yaml to make the modern Ink TUI the
|
||||
default for bare ``hermes`` / ``hermes chat``. Explicit flags always win:
|
||||
|
||||
--cli forces the classic REPL (highest precedence)
|
||||
--tui / HERMES_TUI=1 forces the TUI
|
||||
display.interface the configured default
|
||||
(unset) classic REPL
|
||||
|
||||
These tests pin that precedence at every layer that makes the decision:
|
||||
|
||||
* ``_resolve_use_tui(args)`` — the canonical args-aware resolver used by
|
||||
``cmd_chat`` and the Termux fast-TUI path.
|
||||
* ``_wants_tui_early(argv)`` — the dependency-free early resolver used by
|
||||
mouse-residue suppression and the Termux fast paths, before argparse and
|
||||
``hermes_cli.config`` are importable.
|
||||
* the argument parser — both ``--cli`` and ``--tui`` parse at the top
|
||||
level and under the ``chat`` subcommand and are relaunch-inherited.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import main as m
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_early_cache(monkeypatch):
|
||||
# The early resolver memoizes the config read; clear it so each test sees
|
||||
# a fresh value, and make sure no stray HERMES_TUI leaks in.
|
||||
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
|
||||
monkeypatch.delenv("HERMES_TUI", raising=False)
|
||||
yield
|
||||
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
|
||||
|
||||
|
||||
def _args(**kw):
|
||||
kw.setdefault("cli", False)
|
||||
kw.setdefault("tui", False)
|
||||
return SimpleNamespace(**kw)
|
||||
|
||||
|
||||
def _patch_config(monkeypatch, interface):
|
||||
import hermes_cli.config as cfg
|
||||
|
||||
monkeypatch.setattr(
|
||||
cfg, "load_config", lambda: {"display": {"interface": interface}}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_use_tui — args-aware resolver
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestResolveUseTui:
|
||||
def test_cli_flag_beats_config_tui(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "tui")
|
||||
assert m._resolve_use_tui(_args(cli=True)) is False
|
||||
|
||||
def test_cli_flag_beats_tui_flag_and_env(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "tui")
|
||||
monkeypatch.setenv("HERMES_TUI", "1")
|
||||
assert m._resolve_use_tui(_args(cli=True, tui=True)) is False
|
||||
|
||||
def test_tui_flag_beats_config_cli(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "cli")
|
||||
assert m._resolve_use_tui(_args(tui=True)) is True
|
||||
|
||||
def test_env_beats_config_cli(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "cli")
|
||||
monkeypatch.setenv("HERMES_TUI", "1")
|
||||
assert m._resolve_use_tui(_args()) is True
|
||||
|
||||
def test_config_tui_with_no_flags(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "tui")
|
||||
assert m._resolve_use_tui(_args()) is True
|
||||
|
||||
def test_config_cli_is_default(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "cli")
|
||||
assert m._resolve_use_tui(_args()) is False
|
||||
|
||||
def test_interface_value_is_case_insensitive(self, monkeypatch):
|
||||
_patch_config(monkeypatch, "TUI")
|
||||
assert m._resolve_use_tui(_args()) is True
|
||||
|
||||
def test_load_config_failure_falls_back_to_cli(self, monkeypatch):
|
||||
import hermes_cli.config as cfg
|
||||
|
||||
def boom():
|
||||
raise RuntimeError("config unreadable")
|
||||
|
||||
monkeypatch.setattr(cfg, "load_config", boom)
|
||||
assert m._resolve_use_tui(_args()) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _wants_tui_early — dependency-free early resolver
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestWantsTuiEarly:
|
||||
@pytest.fixture
|
||||
def home_with_interface(self, tmp_path, monkeypatch):
|
||||
def _make(interface):
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
f"display:\n interface: {interface}\n"
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
|
||||
|
||||
return _make
|
||||
|
||||
def test_config_tui_bare_argv(self, home_with_interface):
|
||||
home_with_interface("tui")
|
||||
assert m._wants_tui_early([]) is True
|
||||
|
||||
def test_cli_flag_overrides_config_tui(self, home_with_interface):
|
||||
home_with_interface("tui")
|
||||
assert m._wants_tui_early(["--cli"]) is False
|
||||
|
||||
def test_tui_flag_with_config_cli(self, home_with_interface):
|
||||
home_with_interface("cli")
|
||||
assert m._wants_tui_early(["--tui"]) is True
|
||||
|
||||
def test_env_with_config_cli(self, home_with_interface, monkeypatch):
|
||||
home_with_interface("cli")
|
||||
monkeypatch.setenv("HERMES_TUI", "1")
|
||||
assert m._wants_tui_early([]) is True
|
||||
|
||||
def test_config_cli_bare_argv(self, home_with_interface):
|
||||
home_with_interface("cli")
|
||||
assert m._wants_tui_early([]) is False
|
||||
|
||||
def test_missing_config_defaults_to_cli(self, tmp_path, monkeypatch):
|
||||
# HERMES_HOME points at an empty dir — no config.yaml.
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
|
||||
assert m._wants_tui_early([]) is False
|
||||
|
||||
def test_unreadable_config_defaults_to_cli(self, tmp_path, monkeypatch):
|
||||
# Garbage YAML must not crash the hot path; falls back to cli.
|
||||
(tmp_path / "config.yaml").write_text("this: : : not valid yaml\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
|
||||
assert m._wants_tui_early([]) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argument parser — flags exist at both levels and are relaunch-inherited
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestParserFlags:
|
||||
def _parser(self):
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, _chat = build_top_level_parser()
|
||||
return parser
|
||||
|
||||
def test_top_level_cli_flag(self):
|
||||
args = self._parser().parse_args(["--cli"])
|
||||
assert args.cli is True and args.tui is False
|
||||
|
||||
def test_top_level_tui_flag(self):
|
||||
args = self._parser().parse_args(["--tui"])
|
||||
assert args.tui is True and args.cli is False
|
||||
|
||||
def test_chat_subcommand_cli_flag(self):
|
||||
args = self._parser().parse_args(["chat", "--cli"])
|
||||
assert args.cli is True
|
||||
|
||||
def test_chat_subcommand_tui_flag(self):
|
||||
args = self._parser().parse_args(["chat", "--tui"])
|
||||
assert args.tui is True
|
||||
|
||||
def test_cli_and_tui_are_relaunch_inherited(self):
|
||||
from hermes_cli.relaunch import _INHERITED_FLAGS_TABLE
|
||||
|
||||
inherited = {flag for flag, _takes_value in _INHERITED_FLAGS_TABLE}
|
||||
assert "--cli" in inherited
|
||||
assert "--tui" in inherited
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# config default — shipped default preserves classic behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_default_config_interface_is_cli():
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
assert DEFAULT_CONFIG["display"]["interface"] == "cli"
|
||||
@@ -153,6 +153,75 @@ def test_gui_skip_build_launches_existing_packaged_app_without_npm(tmp_path, mon
|
||||
assert mock_run.call_args.args[0] == [str(packaged_exe)]
|
||||
|
||||
|
||||
def test_gui_linux_configures_sandbox_before_launch(tmp_path, monkeypatch):
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")
|
||||
sandbox = packaged_exe.parent / "chrome-sandbox"
|
||||
sandbox.write_text("", encoding="utf-8")
|
||||
sandbox.chmod(0o755)
|
||||
ok = subprocess.CompletedProcess([], 0)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/sudo"), \
|
||||
patch("hermes_cli.main.subprocess.run", return_value=ok) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns(skip_build=True))
|
||||
|
||||
assert exc.value.code == 0
|
||||
assert mock_run.call_args_list[0].args[0] == ["/usr/bin/sudo", "chown", "root:root", str(sandbox)]
|
||||
assert mock_run.call_args_list[1].args[0] == ["/usr/bin/sudo", "chmod", "4755", str(sandbox)]
|
||||
assert mock_run.call_args_list[2].args[0] == [str(packaged_exe)]
|
||||
|
||||
|
||||
def test_gui_linux_rejects_symlink_sandbox(tmp_path, monkeypatch):
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")
|
||||
# Point chrome-sandbox at an unrelated file via symlink
|
||||
target = tmp_path / "dangerous"
|
||||
target.write_text("pwned", encoding="utf-8")
|
||||
sandbox = packaged_exe.parent / "chrome-sandbox"
|
||||
sandbox.symlink_to(target)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/sudo"), \
|
||||
patch("hermes_cli.main.subprocess.run") as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns(skip_build=True))
|
||||
|
||||
assert exc.value.code == 1
|
||||
# Must NOT have called sudo chown/chmod on the symlink target
|
||||
for call in mock_run.call_args_list:
|
||||
assert "chown" not in call.args[0]
|
||||
assert "chmod" not in call.args[0]
|
||||
|
||||
|
||||
def test_gui_linux_skips_fixup_when_already_configured(tmp_path, monkeypatch):
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
||||
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")
|
||||
sandbox = packaged_exe.parent / "chrome-sandbox"
|
||||
sandbox.write_text("", encoding="utf-8")
|
||||
# Simulate root-owned 4755 — lstat().st_uid==0 and mode==0o4755
|
||||
# We can't actually chown to root in tests, so mock lstat to return
|
||||
# the expected values directly.
|
||||
import stat as stat_mod
|
||||
fake_stat = type("s", (), {"st_uid": 0, "st_mode": 0o4755 | stat_mod.S_IFREG})()
|
||||
sandbox_lstat_orig = type(sandbox).lstat
|
||||
monkeypatch.setattr(type(sandbox), "lstat", lambda self: fake_stat)
|
||||
|
||||
launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0)
|
||||
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/sudo"), \
|
||||
patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \
|
||||
pytest.raises(SystemExit) as exc:
|
||||
cli_main.cmd_gui(_ns(skip_build=True))
|
||||
|
||||
assert exc.value.code == 0
|
||||
# Only the launch call — no sudo chown/chmod
|
||||
mock_run.assert_called_once()
|
||||
assert mock_run.call_args.args[0] == [str(packaged_exe)]
|
||||
|
||||
|
||||
def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch):
|
||||
root = _make_desktop_tree(tmp_path)
|
||||
desktop_dir = root / "apps" / "desktop"
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Tests for hermes_cli.managed_uv — one path, no guessing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_executable(path: Path) -> None:
|
||||
"""Create a minimal fake uv binary at *path*."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("#!/bin/sh\necho uv 0.1.2\n")
|
||||
path.chmod(path.stat().st_mode | stat.S_IEXEC)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# managed_uv_path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestManagedUvPath:
|
||||
def test_posix(self, tmp_path):
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
||||
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"):
|
||||
from hermes_cli.managed_uv import managed_uv_path
|
||||
assert managed_uv_path() == tmp_path / "bin" / "uv"
|
||||
|
||||
def test_windows(self, tmp_path):
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
||||
patch("hermes_cli.managed_uv.platform.system", return_value="Windows"):
|
||||
from hermes_cli.managed_uv import managed_uv_path
|
||||
assert managed_uv_path() == tmp_path / "bin" / "uv.exe"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_uv
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveUv:
|
||||
def test_missing_returns_none(self, tmp_path):
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
|
||||
from hermes_cli.managed_uv import resolve_uv
|
||||
assert resolve_uv() is None
|
||||
|
||||
def test_existing_executable(self, tmp_path):
|
||||
_make_executable(tmp_path / "bin" / "uv")
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
|
||||
from hermes_cli.managed_uv import resolve_uv
|
||||
result = resolve_uv()
|
||||
assert result == str(tmp_path / "bin" / "uv")
|
||||
|
||||
def test_non_executable_file_returns_none(self, tmp_path):
|
||||
uv = tmp_path / "bin" / "uv"
|
||||
uv.parent.mkdir(parents=True)
|
||||
uv.write_text("not a binary")
|
||||
# Ensure no execute bit
|
||||
uv.chmod(0o644)
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
|
||||
from hermes_cli.managed_uv import resolve_uv
|
||||
assert resolve_uv() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ensure_uv
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEnsureUv:
|
||||
def test_already_installed_no_bootstrap(self, tmp_path):
|
||||
_make_executable(tmp_path / "bin" / "uv")
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
path, fresh = ensure_uv()
|
||||
assert path == str(tmp_path / "bin" / "uv")
|
||||
assert fresh is False
|
||||
|
||||
def test_installs_if_missing_sets_bootstrap_flag(self, tmp_path):
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
||||
patch("hermes_cli.managed_uv._install_uv") as mock_install:
|
||||
# Simulate the installer creating the binary
|
||||
def fake_install(target):
|
||||
_make_executable(target)
|
||||
mock_install.side_effect = fake_install
|
||||
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
path, fresh = ensure_uv()
|
||||
assert path == str(tmp_path / "bin" / "uv")
|
||||
assert fresh is True
|
||||
mock_install.assert_called_once()
|
||||
|
||||
def test_install_failure_returns_none_false(self, tmp_path):
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
||||
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
path, fresh = ensure_uv()
|
||||
assert path is None
|
||||
assert fresh is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rebuild_venv
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRebuildVenv:
|
||||
def test_removes_old_venv_and_creates_new(self, tmp_path):
|
||||
venv_dir = tmp_path / "venv"
|
||||
venv_dir.mkdir()
|
||||
(venv_dir / "old_file").write_text("stale")
|
||||
|
||||
uv_bin = str(tmp_path / "bin" / "uv")
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
m = MagicMock(returncode=0)
|
||||
if cmd[1] == "venv":
|
||||
# Simulate uv creating the venv dir
|
||||
venv_dir.mkdir(exist_ok=True)
|
||||
bin_dir = venv_dir / "bin"
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
(bin_dir / "python").write_text("#!/bin/sh\necho Python 3.11.0")
|
||||
elif "--version" in cmd:
|
||||
m.stdout = "Python 3.11.0"
|
||||
return m
|
||||
|
||||
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
|
||||
patch("hermes_cli.managed_uv.shutil.rmtree") as mock_rmtree:
|
||||
from hermes_cli.managed_uv import rebuild_venv
|
||||
result = rebuild_venv(uv_bin, venv_dir)
|
||||
assert result is True
|
||||
mock_rmtree.assert_called_once_with(venv_dir, ignore_errors=True)
|
||||
|
||||
def test_rebuild_failure_returns_false(self, tmp_path):
|
||||
venv_dir = tmp_path / "venv"
|
||||
uv_bin = str(tmp_path / "bin" / "uv")
|
||||
|
||||
with patch("hermes_cli.managed_uv.subprocess.run") as mock_run, \
|
||||
patch("hermes_cli.managed_uv.shutil.rmtree"):
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="nope")
|
||||
from hermes_cli.managed_uv import rebuild_venv
|
||||
result = rebuild_venv(uv_bin, venv_dir)
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_managed_uv
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpdateManagedUv:
|
||||
def test_no_uv_returns_none(self, tmp_path):
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
|
||||
from hermes_cli.managed_uv import update_managed_uv
|
||||
assert update_managed_uv() is None
|
||||
|
||||
def test_self_update_success(self, tmp_path):
|
||||
_make_executable(tmp_path / "bin" / "uv")
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
||||
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
|
||||
# uv self update succeeds
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="uv 0.2.0")
|
||||
from hermes_cli.managed_uv import update_managed_uv
|
||||
result = update_managed_uv()
|
||||
assert result == str(tmp_path / "bin" / "uv")
|
||||
# First call is self update, second is --version
|
||||
assert mock_run.call_count == 2
|
||||
assert mock_run.call_args_list[0][0][0] == [str(tmp_path / "bin" / "uv"), "self", "update"]
|
||||
|
||||
def test_self_update_failure_non_fatal(self, tmp_path):
|
||||
_make_executable(tmp_path / "bin" / "uv")
|
||||
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
|
||||
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="nope")
|
||||
from hermes_cli.managed_uv import update_managed_uv
|
||||
result = update_managed_uv()
|
||||
# Still returns the path — failure is non-fatal
|
||||
assert result == str(tmp_path / "bin" / "uv")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _install_uv internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInstallUvInternals:
|
||||
def test_posix_sets_uv_unmanaged_install(self, tmp_path):
|
||||
target = tmp_path / "bin" / "uv"
|
||||
with patch("hermes_cli.managed_uv._install_uv_posix") as mock_posix:
|
||||
from hermes_cli.managed_uv import _install_uv
|
||||
_install_uv(target)
|
||||
mock_posix.assert_called_once()
|
||||
call_env = mock_posix.call_args[0][0]
|
||||
assert call_env["UV_UNMANAGED_INSTALL"] == str(tmp_path / "bin")
|
||||
|
||||
def test_windows_sets_uv_install_dir(self, tmp_path):
|
||||
target = tmp_path / "bin" / "uv.exe"
|
||||
with patch("hermes_cli.managed_uv.platform.system", return_value="Windows"), \
|
||||
patch("hermes_cli.managed_uv._install_uv_windows") as mock_windows:
|
||||
from hermes_cli.managed_uv import _install_uv
|
||||
_install_uv(target)
|
||||
mock_windows.assert_called_once()
|
||||
call_env = mock_windows.call_args[0][0]
|
||||
assert call_env["UV_INSTALL_DIR"] == str(tmp_path / "bin")
|
||||
@@ -609,7 +609,12 @@ def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch)
|
||||
|
||||
providers = _visible_providers(TOOL_CATEGORIES["browser"], config)
|
||||
|
||||
assert providers[0]["name"].startswith("Nous Subscription")
|
||||
# The managed Nous row is listed (not necessarily first — "Local Browser"
|
||||
# sorts first so a fresh-install Enter lands on the free local backend).
|
||||
assert any(p["name"].startswith("Nous Subscription") for p in providers)
|
||||
# "Local Browser" must be the index-0 default so pressing Enter never
|
||||
# walks a user into a paid Nous Portal login.
|
||||
assert providers[0]["name"] == "Local Browser"
|
||||
|
||||
|
||||
def test_visible_providers_show_nous_subscription_when_logged_out(monkeypatch):
|
||||
@@ -685,7 +690,9 @@ def test_visible_providers_force_fresh_shows_nous_subscription_after_upgrade(mon
|
||||
force_fresh=True,
|
||||
)
|
||||
|
||||
assert providers[0]["name"].startswith("Nous Subscription")
|
||||
# The managed Nous row reappears after the entitlement upgrade. It is no
|
||||
# longer asserted to be first — "Local Browser" sorts first by design.
|
||||
assert any(p["name"].startswith("Nous Subscription") for p in providers)
|
||||
assert ("features", True) in calls
|
||||
|
||||
|
||||
@@ -702,6 +709,33 @@ def test_local_browser_provider_is_saved_explicitly(monkeypatch):
|
||||
assert config["browser"]["cloud_provider"] == "local"
|
||||
|
||||
|
||||
def test_fresh_install_browser_default_is_free_local_not_paid_nous():
|
||||
"""On a fresh install the browser picker must default to the free local
|
||||
backend, never the paid Nous Subscription gateway.
|
||||
|
||||
Regression: the Nous row used to sort first, so the menu cursor defaulted
|
||||
to index 0 (Nous) and pressing Enter walked users straight into a Nous
|
||||
Portal login for a paid offering (Javier's bug, June 2026).
|
||||
"""
|
||||
from hermes_cli.tools_config import _detect_active_provider_index
|
||||
|
||||
providers = TOOL_CATEGORIES["browser"]["providers"]
|
||||
assert providers[0]["name"] == "Local Browser"
|
||||
assert providers[0]["browser_provider"] == "local"
|
||||
# Nothing active/configured → cursor defaults to index 0 (the free local row).
|
||||
assert _detect_active_provider_index(providers, {}) == 0
|
||||
|
||||
|
||||
def test_fresh_install_tts_default_is_free_edge_not_paid_nous():
|
||||
"""TTS picker defaults to the free Edge backend on a fresh install."""
|
||||
from hermes_cli.tools_config import _detect_active_provider_index
|
||||
|
||||
providers = TOOL_CATEGORIES["tts"]["providers"]
|
||||
assert providers[0]["name"] == "Microsoft Edge TTS"
|
||||
assert providers[0]["tts_provider"] == "edge"
|
||||
assert _detect_active_provider_index(providers, {}) == 0
|
||||
|
||||
|
||||
def test_reconfigure_lists_enabled_web_without_existing_provider_config(monkeypatch):
|
||||
config = {"platform_toolsets": {"cli": ["web"]}}
|
||||
seen = {}
|
||||
|
||||
@@ -193,3 +193,109 @@ def test_make_tui_argv_keeps_desktop_always_build_behaviour(
|
||||
|
||||
assert calls
|
||||
assert calls[0][0][0] == ["/bin/npm", "run", "build"]
|
||||
|
||||
|
||||
# ── _workspace_root helper ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_workspace_root_returns_parent_when_subpackage(tmp_path: Path, main_mod) -> None:
|
||||
"""Sub-package has package.json, no lockfile; parent has lockfile → parent."""
|
||||
sub = tmp_path / "ui-tui"
|
||||
sub.mkdir()
|
||||
(sub / "package.json").write_text("{}")
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
assert main_mod._workspace_root(sub) == tmp_path
|
||||
|
||||
|
||||
def test_workspace_root_returns_dir_when_standalone(tmp_path: Path, main_mod) -> None:
|
||||
"""No package.json → not a sub-package, return dir itself."""
|
||||
assert main_mod._workspace_root(tmp_path) == tmp_path
|
||||
|
||||
|
||||
def test_workspace_root_returns_dir_when_own_lockfile(tmp_path: Path, main_mod) -> None:
|
||||
"""Has package.json AND its own lockfile → standalone, return dir."""
|
||||
(tmp_path / "package.json").write_text("{}")
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
(tmp_path.parent / "package-lock.json").write_text("{}")
|
||||
assert main_mod._workspace_root(tmp_path) == tmp_path
|
||||
|
||||
|
||||
def test_workspace_root_returns_dir_when_no_parent_lockfile(
|
||||
tmp_path: Path, main_mod
|
||||
) -> None:
|
||||
"""Has package.json, no own lockfile, but parent also has no lockfile → standalone."""
|
||||
sub = tmp_path / "ui-tui"
|
||||
sub.mkdir()
|
||||
(sub / "package.json").write_text("{}")
|
||||
# tmp_path has no package-lock.json either
|
||||
assert main_mod._workspace_root(sub) == sub
|
||||
|
||||
|
||||
def test_workspace_root_consistent_with_need_npm_install(
|
||||
tmp_path: Path, main_mod
|
||||
) -> None:
|
||||
"""Divergence regression: if someone creates ui-tui/package-lock.json
|
||||
by accident, _workspace_root (used by both _tui_need_npm_install AND
|
||||
the npm install cwd) returns ui-tui/ for both, so they never disagree.
|
||||
|
||||
Before the shared helper, _tui_need_npm_install used a 3-condition
|
||||
check (falling back to ui-tui/ when its own lockfile exists) while
|
||||
the npm install cwd used a simpler check (still going to the parent
|
||||
because the parent lockfile still exists). The shared helper
|
||||
eliminates the split.
|
||||
"""
|
||||
sub = tmp_path / "ui-tui"
|
||||
sub.mkdir()
|
||||
(sub / "package.json").write_text("{}")
|
||||
# Both sub and parent have lockfiles — accidental state
|
||||
(sub / "package-lock.json").write_text("{}")
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
|
||||
ws = main_mod._workspace_root(sub)
|
||||
# _workspace_root sees sub has its own lockfile → treats it as standalone
|
||||
assert ws == sub
|
||||
|
||||
# _tui_need_npm_install also uses _workspace_root, so both agree
|
||||
assert main_mod._tui_need_npm_install.__code__.co_names
|
||||
# (Smoke test: just confirm _tui_need_npm_install doesn't crash)
|
||||
# It won't need install because the lockfile exists and there's no
|
||||
# hidden lockfile to compare against, and ink is missing → True.
|
||||
# But the key invariant is: ws_root for the need-check == ws_root
|
||||
# for the install cwd — both use _workspace_root(sub).
|
||||
|
||||
|
||||
def test_no_stray_lockfiles_in_workspace_subdirs(main_mod) -> None:
|
||||
"""Workspace sub-directories must not contain their own package-lock.json.
|
||||
|
||||
With a single workspace root lockfile, per-directory lockfiles are
|
||||
always accidental (typically from running ``npm install`` inside the
|
||||
wrong directory). They cause ``_workspace_root`` to treat the
|
||||
sub-package as standalone, which breaks hoisted ``node_modules``
|
||||
resolution and can silently diverge the install cwd from the
|
||||
lockfile-check root.
|
||||
|
||||
This is an invariant, not a change-detector: the workspace structure
|
||||
is not expected to gain per-dir lockfiles.
|
||||
"""
|
||||
root = main_mod.PROJECT_ROOT
|
||||
# Workspace members that live one level below the root and should
|
||||
# NOT have their own lockfile. (ui-tui/packages/* members are
|
||||
# two levels deep and even less likely to get accidental lockfiles,
|
||||
# but we check them too for completeness.)
|
||||
subdirs = [
|
||||
root / "ui-tui",
|
||||
root / "web",
|
||||
root / "apps" / "desktop",
|
||||
root / "apps" / "shared",
|
||||
]
|
||||
# Also sweep ui-tui/packages/* (hermes-ink etc.)
|
||||
tui_pkgs = root / "ui-tui" / "packages"
|
||||
if tui_pkgs.is_dir():
|
||||
subdirs.extend(d for d in tui_pkgs.iterdir() if d.is_dir())
|
||||
|
||||
stray = [d for d in subdirs if (d / "package-lock.json").is_file()]
|
||||
assert not stray, (
|
||||
"stray package-lock.json found in workspace sub-directory(es); "
|
||||
"delete them and run `npm install` from the repo root instead: "
|
||||
+ ", ".join(str(d / "package-lock.json") for d in stray)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from pathlib import Path
|
||||
from subprocess import CalledProcessError
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -8,6 +9,41 @@ from hermes_cli import config as hermes_config
|
||||
from hermes_cli import main as hermes_main
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Managed-uv compatibility for tests that patch shutil.which
|
||||
# ---------------------------------------------------------------------------
|
||||
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
|
||||
# instead of ``shutil.which("uv")``. Many tests in this file patch
|
||||
# ``shutil.which`` to control whether uv is "available" — these autouse
|
||||
# fixtures make the managed_uv functions delegate to the patched
|
||||
# ``shutil.which`` so the existing test setup keeps working without
|
||||
# per-test changes.
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_managed_uv(request):
|
||||
"""Make managed_uv helpers follow shutil.which mocking in tests."""
|
||||
import shutil
|
||||
|
||||
# resolve_uv delegates to shutil.which("uv") so that test patches
|
||||
# on shutil.which flow through naturally.
|
||||
def _fake_resolve_uv():
|
||||
return shutil.which("uv")
|
||||
|
||||
def _fake_ensure_uv():
|
||||
path = shutil.which("uv")
|
||||
return (path, False) # never freshly bootstrapped in tests
|
||||
|
||||
def _fake_update_managed_uv():
|
||||
return None # never actually self-update in tests
|
||||
|
||||
def _fake_rebuild_venv(*args, **kwargs):
|
||||
return True # no-op in tests
|
||||
|
||||
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
|
||||
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
|
||||
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \
|
||||
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
|
||||
yield
|
||||
|
||||
def test_stash_local_changes_if_needed_returns_none_when_tree_clean(monkeypatch, tmp_path):
|
||||
calls = []
|
||||
|
||||
|
||||
@@ -20,6 +20,42 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Managed-uv compatibility for tests that patch shutil.which
|
||||
# ---------------------------------------------------------------------------
|
||||
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
|
||||
# instead of ``shutil.which("uv")``. Many tests in this file patch
|
||||
# ``shutil.which`` to control whether uv is "available" — these autouse
|
||||
# fixtures make the managed_uv functions delegate to the patched
|
||||
# ``shutil.which`` so the existing test setup keeps working without
|
||||
# per-test changes.
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_managed_uv(request):
|
||||
"""Make managed_uv helpers follow shutil.which mocking in tests."""
|
||||
import shutil
|
||||
|
||||
# resolve_uv delegates to shutil.which("uv") so that test patches
|
||||
# on shutil.which flow through naturally.
|
||||
def _fake_resolve_uv():
|
||||
return shutil.which("uv")
|
||||
|
||||
def _fake_ensure_uv():
|
||||
path = shutil.which("uv")
|
||||
return (path, False) # never freshly bootstrapped in tests
|
||||
|
||||
def _fake_update_managed_uv():
|
||||
return None # never actually self-update in tests
|
||||
|
||||
def _fake_rebuild_venv(*args, **kwargs):
|
||||
return True # no-op in tests
|
||||
|
||||
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
|
||||
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
|
||||
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \
|
||||
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
|
||||
yield
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_uv_tool_install
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -327,6 +327,258 @@ def test_anthropic_pkce_branch_still_works():
|
||||
assert "claude.ai" in body["auth_url"]
|
||||
|
||||
|
||||
def test_xai_oauth_listed_as_loopback_flow():
|
||||
"""xAI Grok OAuth must surface in the catalog as a first-class loopback flow."""
|
||||
resp = client.get("/api/providers/oauth", headers=HEADERS)
|
||||
assert resp.status_code == 200, resp.text
|
||||
providers = {p["id"]: p for p in resp.json()["providers"]}
|
||||
assert "xai-oauth" in providers
|
||||
assert providers["xai-oauth"]["flow"] == "loopback"
|
||||
assert "grok" in providers["xai-oauth"]["name"].lower()
|
||||
|
||||
|
||||
def test_xai_loopback_start_returns_authorize_url(monkeypatch):
|
||||
"""Start MUST bind the loopback listener and hand back an xAI authorize URL."""
|
||||
from hermes_cli import auth as auth_mod
|
||||
from hermes_cli import web_server as ws
|
||||
|
||||
class _FakeServer:
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def server_close(self):
|
||||
pass
|
||||
|
||||
class _FakeThread:
|
||||
def join(self, timeout=None):
|
||||
pass
|
||||
|
||||
redirect_uri = (
|
||||
f"http://{auth_mod.XAI_OAUTH_REDIRECT_HOST}:{auth_mod.XAI_OAUTH_REDIRECT_PORT}"
|
||||
f"{auth_mod.XAI_OAUTH_REDIRECT_PATH}"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_mod,
|
||||
"_xai_oauth_discovery",
|
||||
lambda *a, **k: {
|
||||
"authorization_endpoint": "https://auth.x.ai/oauth2/auth",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth_mod,
|
||||
"_xai_start_callback_server",
|
||||
lambda *a, **k: (_FakeServer(), _FakeThread(), {"code": None, "error": None}, redirect_uri),
|
||||
)
|
||||
# Don't let the background worker run a real callback wait/exchange.
|
||||
monkeypatch.setattr(ws, "_xai_loopback_worker", lambda sid: None)
|
||||
|
||||
resp = client.post("/api/providers/oauth/xai-oauth/start", headers=HEADERS)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
try:
|
||||
assert body["flow"] == "loopback"
|
||||
assert "user_code" not in body # loopback has nothing to paste/show
|
||||
assert body["auth_url"].startswith("https://auth.x.ai/oauth2/auth?")
|
||||
assert "code_challenge" in body["auth_url"]
|
||||
sess = ws._oauth_sessions[body["session_id"]]
|
||||
assert sess["provider"] == "xai-oauth"
|
||||
assert sess["flow"] == "loopback"
|
||||
finally:
|
||||
ws._oauth_sessions.pop(body["session_id"], None)
|
||||
|
||||
|
||||
def test_xai_loopback_worker_persists_tokens_on_success(monkeypatch):
|
||||
"""The worker exchanges the callback code and marks the session approved."""
|
||||
from hermes_cli import auth as auth_mod
|
||||
from hermes_cli import web_server as ws
|
||||
|
||||
saved = {}
|
||||
session_id = "xai-loopback-success-test"
|
||||
ws._oauth_sessions[session_id] = {
|
||||
"session_id": session_id,
|
||||
"provider": "xai-oauth",
|
||||
"flow": "loopback",
|
||||
"created_at": time.time(),
|
||||
"status": "pending",
|
||||
"error_message": None,
|
||||
"server": object(),
|
||||
"thread": object(),
|
||||
"callback_result": {"code": "auth-code", "state": "st"},
|
||||
"redirect_uri": "http://127.0.0.1:56121/callback",
|
||||
"verifier": "verifier",
|
||||
"challenge": "challenge",
|
||||
"state": "st",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
"discovery": {"token_endpoint": "https://auth.x.ai/oauth2/token"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_mod,
|
||||
"_xai_wait_for_callback",
|
||||
lambda *a, **k: {"code": "auth-code", "state": "st"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth_mod,
|
||||
"_xai_oauth_exchange_code_for_tokens",
|
||||
lambda **k: {
|
||||
"access_token": "xai-access",
|
||||
"refresh_token": "xai-refresh",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth_mod,
|
||||
"_save_xai_oauth_tokens",
|
||||
lambda tokens, **k: saved.update(tokens),
|
||||
)
|
||||
monkeypatch.setattr(ws, "_add_xai_oauth_pool_entry", lambda *a, **k: None)
|
||||
|
||||
try:
|
||||
ws._xai_loopback_worker(session_id)
|
||||
assert ws._oauth_sessions[session_id]["status"] == "approved"
|
||||
assert saved["access_token"] == "xai-access"
|
||||
assert saved["refresh_token"] == "xai-refresh"
|
||||
finally:
|
||||
ws._oauth_sessions.pop(session_id, None)
|
||||
|
||||
|
||||
def test_xai_loopback_worker_fails_on_state_mismatch(monkeypatch):
|
||||
"""A mismatched OAuth state must fail the session, not persist tokens."""
|
||||
from hermes_cli import auth as auth_mod
|
||||
from hermes_cli import web_server as ws
|
||||
|
||||
session_id = "xai-loopback-state-test"
|
||||
ws._oauth_sessions[session_id] = {
|
||||
"session_id": session_id,
|
||||
"provider": "xai-oauth",
|
||||
"flow": "loopback",
|
||||
"created_at": time.time(),
|
||||
"status": "pending",
|
||||
"error_message": None,
|
||||
"server": object(),
|
||||
"thread": object(),
|
||||
"callback_result": {},
|
||||
"redirect_uri": "http://127.0.0.1:56121/callback",
|
||||
"verifier": "verifier",
|
||||
"challenge": "challenge",
|
||||
"state": "expected-state",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
"discovery": {},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_mod,
|
||||
"_xai_wait_for_callback",
|
||||
lambda *a, **k: {"code": "auth-code", "state": "ATTACKER-state"},
|
||||
)
|
||||
|
||||
def _boom(**kwargs):
|
||||
raise AssertionError("token exchange must not run on state mismatch")
|
||||
|
||||
monkeypatch.setattr(auth_mod, "_xai_oauth_exchange_code_for_tokens", _boom)
|
||||
|
||||
try:
|
||||
ws._xai_loopback_worker(session_id)
|
||||
sess = ws._oauth_sessions[session_id]
|
||||
assert sess["status"] == "error"
|
||||
assert "state mismatch" in sess["error_message"].lower()
|
||||
finally:
|
||||
ws._oauth_sessions.pop(session_id, None)
|
||||
|
||||
|
||||
def test_xai_loopback_worker_skips_persist_when_cancelled(monkeypatch):
|
||||
"""If the session is cancelled while waiting, the worker must not persist."""
|
||||
from hermes_cli import auth as auth_mod
|
||||
from hermes_cli import web_server as ws
|
||||
|
||||
session_id = "xai-loopback-cancel-test"
|
||||
ws._oauth_sessions[session_id] = {
|
||||
"session_id": session_id,
|
||||
"provider": "xai-oauth",
|
||||
"flow": "loopback",
|
||||
"created_at": time.time(),
|
||||
"status": "pending",
|
||||
"error_message": None,
|
||||
"server": object(),
|
||||
"thread": object(),
|
||||
"callback_result": {},
|
||||
"redirect_uri": "http://127.0.0.1:56121/callback",
|
||||
"verifier": "verifier",
|
||||
"challenge": "challenge",
|
||||
"state": "st",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
"discovery": {},
|
||||
}
|
||||
|
||||
def _wait_then_cancel(*args, **kwargs):
|
||||
# Simulate the user cancelling (DELETE /sessions/{id}) while we were
|
||||
# blocked on the callback: the session vanishes, then a valid code
|
||||
# arrives. The worker must notice and bail before persisting.
|
||||
ws._oauth_sessions.pop(session_id, None)
|
||||
return {"code": "auth-code", "state": "st"}
|
||||
|
||||
monkeypatch.setattr(auth_mod, "_xai_wait_for_callback", _wait_then_cancel)
|
||||
|
||||
def _must_not_persist(*args, **kwargs):
|
||||
raise AssertionError("tokens must not be persisted for a cancelled session")
|
||||
|
||||
monkeypatch.setattr(auth_mod, "_save_xai_oauth_tokens", _must_not_persist)
|
||||
monkeypatch.setattr(ws, "_add_xai_oauth_pool_entry", _must_not_persist)
|
||||
|
||||
# Should return cleanly without raising and without persisting.
|
||||
ws._xai_loopback_worker(session_id)
|
||||
assert session_id not in ws._oauth_sessions
|
||||
|
||||
|
||||
def test_cancel_loopback_session_shuts_down_callback_server():
|
||||
"""Cancelling a loopback session must free the bound callback port now."""
|
||||
from hermes_cli import web_server as ws
|
||||
|
||||
shutdown_calls = {"shutdown": 0, "close": 0, "join": 0}
|
||||
|
||||
class _FakeServer:
|
||||
def shutdown(self):
|
||||
shutdown_calls["shutdown"] += 1
|
||||
|
||||
def server_close(self):
|
||||
shutdown_calls["close"] += 1
|
||||
|
||||
class _FakeThread:
|
||||
def join(self, timeout=None):
|
||||
shutdown_calls["join"] += 1
|
||||
|
||||
# callback_result is the dict the worker's _xai_wait_for_callback polls.
|
||||
callback_result = {"code": None, "error": None}
|
||||
session_id = "xai-loopback-cancel-shutdown-test"
|
||||
ws._oauth_sessions[session_id] = {
|
||||
"session_id": session_id,
|
||||
"provider": "xai-oauth",
|
||||
"flow": "loopback",
|
||||
"created_at": time.time(),
|
||||
"status": "pending",
|
||||
"server": _FakeServer(),
|
||||
"thread": _FakeThread(),
|
||||
"callback_result": callback_result,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = client.delete(
|
||||
f"/api/providers/oauth/sessions/{session_id}", headers=HEADERS
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["ok"] is True
|
||||
assert shutdown_calls == {"shutdown": 1, "close": 1, "join": 1}
|
||||
# The waiting worker must be signalled so it returns promptly instead
|
||||
# of spinning until the timeout.
|
||||
assert callback_result["error"] == "cancelled"
|
||||
assert session_id not in ws._oauth_sessions
|
||||
finally:
|
||||
ws._oauth_sessions.pop(session_id, None)
|
||||
|
||||
|
||||
def test_unknown_pkce_provider_rejected_cleanly():
|
||||
"""A future PKCE provider without an explicit branch must NOT silently route to Anthropic.
|
||||
|
||||
|
||||
@@ -3415,7 +3415,7 @@ class TestPtyWebSocket:
|
||||
# subscriber registration and the message is dropped.
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
if ws_mod._event_channels.get("broadcast-test"):
|
||||
if ws_mod.app.state.event_channels.get("broadcast-test"):
|
||||
break
|
||||
time.sleep(0.01)
|
||||
else:
|
||||
|
||||
@@ -69,7 +69,9 @@ class TestWebUIBuildNeeded:
|
||||
def test_returns_true_when_package_lock_newer_than_dist(self, tmp_path):
|
||||
web_dir, dist_dir = _make_web_dir(tmp_path)
|
||||
_touch(dist_dir / ".vite" / "manifest.json", offset=-10)
|
||||
_touch(web_dir / "package-lock.json")
|
||||
# With a single workspace root lockfile, the lockfile lives at the
|
||||
# project root (tmp_path), not inside web_dir.
|
||||
_touch(tmp_path / "package-lock.json")
|
||||
assert _web_ui_build_needed(web_dir) is True
|
||||
|
||||
def test_returns_true_when_vite_config_newer_than_dist(self, tmp_path):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for plugins/memory/honcho/session.py — HonchoSession and helpers."""
|
||||
|
||||
import time
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
@@ -1538,8 +1540,27 @@ class TestDialecticLifecycleSmoke:
|
||||
return provider, mock_manager, cfg
|
||||
|
||||
def _await_thread(self, provider):
|
||||
if provider._prefetch_thread:
|
||||
provider._prefetch_thread.join(timeout=3.0)
|
||||
"""Block until the in-flight prefetch/prewarm thread has fully finished.
|
||||
|
||||
The earlier version did a single ``join(timeout=3.0)`` and then
|
||||
proceeded regardless of whether the thread had actually finished. On a
|
||||
loaded CI runner (6 parallel test slices), the background dialectic
|
||||
thread's completion can slip past that 3s window, so the join times out
|
||||
silently and the test reads ``_prefetch_result`` before the worker wrote
|
||||
it — a flaky ``session-start prewarm must land`` failure. We instead join
|
||||
in a loop up to a generous ceiling and assert the thread is dead, so a
|
||||
genuine hang surfaces as a clear, non-flaky failure instead of a race.
|
||||
"""
|
||||
thread = provider._prefetch_thread
|
||||
if thread is None:
|
||||
return
|
||||
deadline = time.monotonic() + 30.0
|
||||
while thread.is_alive() and time.monotonic() < deadline:
|
||||
thread.join(timeout=1.0)
|
||||
assert not thread.is_alive(), (
|
||||
"prefetch/prewarm thread did not finish within 30s — "
|
||||
"this is a real hang, not a timing flake"
|
||||
)
|
||||
|
||||
def test_full_multi_turn_session(self):
|
||||
"""Walks init → turns 1..8 → session end. Asserts at every step that
|
||||
|
||||
@@ -735,18 +735,29 @@ def test_board_auto_initializes_missing_db(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
|
||||
"""When _SESSION_TOKEN is set (normal dashboard context), a missing or
|
||||
wrong ?token= query param must be rejected with policy-violation."""
|
||||
"""Loopback mode: a missing or wrong ?token= must be rejected with
|
||||
policy-violation; the correct token is accepted. The kanban WS now
|
||||
delegates to web_server._ws_auth_ok, so we stub that with the real
|
||||
loopback-token semantics (auth_required False → constant-time token
|
||||
compare)."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
|
||||
# Stub web_server so _check_ws_token has a token to compare against.
|
||||
# Stub web_server with a loopback-mode _ws_auth_ok (auth_required False →
|
||||
# accept only the correct ?token=). Mirrors the real gate's loopback path.
|
||||
import hermes_cli
|
||||
import types
|
||||
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
|
||||
|
||||
def _fake_ws_auth_ok(ws):
|
||||
return ws.query_params.get("token", "") == "secret-xyz"
|
||||
|
||||
stub = types.SimpleNamespace(
|
||||
_SESSION_TOKEN="secret-xyz",
|
||||
_ws_auth_ok=_fake_ws_auth_ok,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
|
||||
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
||||
|
||||
@@ -774,6 +785,51 @@ def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
|
||||
assert ws is not None # handshake succeeded
|
||||
|
||||
|
||||
def test_ws_events_accepts_gated_ticket(tmp_path, monkeypatch):
|
||||
"""Gated OAuth mode: the WS must accept a single-use ?ticket= (and reject
|
||||
a bare ?token=, even one matching _SESSION_TOKEN). This is the regression
|
||||
for the hosted-dashboard bug where the kanban live-events WS 1008'd on
|
||||
every gated deployment because its bespoke check only knew _SESSION_TOKEN.
|
||||
We stub _ws_auth_ok with the real gated semantics (ticket-only)."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
|
||||
import hermes_cli
|
||||
import types
|
||||
|
||||
def _fake_ws_auth_ok(ws):
|
||||
# Gated mode: only a known ticket is accepted; token path rejected.
|
||||
return ws.query_params.get("ticket", "") == "good-ticket"
|
||||
|
||||
stub = types.SimpleNamespace(
|
||||
_SESSION_TOKEN="secret-xyz",
|
||||
_ws_auth_ok=_fake_ws_auth_ok,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
|
||||
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
||||
c = TestClient(app)
|
||||
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
# Legacy token is rejected in gated mode, even if it's the real one.
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with c.websocket_connect("/api/plugins/kanban/events?token=secret-xyz"):
|
||||
pass
|
||||
assert exc.value.code == 1008
|
||||
|
||||
# A valid ticket is accepted.
|
||||
with c.websocket_connect(
|
||||
"/api/plugins/kanban/events?ticket=good-ticket"
|
||||
) as ws:
|
||||
assert ws is not None
|
||||
|
||||
|
||||
def test_ws_events_board_query_param_default_overrides_current_board_pointer(tmp_path, monkeypatch):
|
||||
"""The event stream must honor ``board=default`` even when the global
|
||||
current-board pointer targets a different board.
|
||||
@@ -806,7 +862,10 @@ def test_ws_events_board_query_param_default_overrides_current_board_pointer(tmp
|
||||
import hermes_cli
|
||||
import types
|
||||
|
||||
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
|
||||
stub = types.SimpleNamespace(
|
||||
_SESSION_TOKEN="secret-xyz",
|
||||
_ws_auth_ok=lambda ws: ws.query_params.get("token", "") == "secret-xyz",
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
|
||||
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
||||
|
||||
@@ -842,10 +901,10 @@ def test_ws_events_swallows_cancellation_on_shutdown(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
kb.init_db()
|
||||
|
||||
# Short-circuit the token check — this test is about the cancellation
|
||||
# Short-circuit the auth check — this test is about the cancellation
|
||||
# path, not auth.
|
||||
import plugins.kanban.dashboard.plugin_api as pa
|
||||
monkeypatch.setattr(pa, "_check_ws_token", lambda t: True)
|
||||
monkeypatch.setattr(pa, "_ws_upgrade_authorized", lambda ws: True)
|
||||
|
||||
class _FakeWS:
|
||||
def __init__(self):
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Guardrail: dashboard plugins must NOT read the session token directly.
|
||||
|
||||
The dashboard host exposes a sanctioned, gated-mode-aware auth surface on the
|
||||
plugin SDK (``window.__HERMES_PLUGIN_SDK__``): ``fetchJSON`` (JSON REST),
|
||||
``authedFetch`` (uploads / blob downloads), and ``buildWsUrl`` /
|
||||
``buildWsAuthParam`` (WebSockets). These handle BOTH dashboard auth modes —
|
||||
loopback (``X-Hermes-Session-Token`` header) and gated OAuth
|
||||
(``hermes_session_at`` cookie / single-use ``?ticket=``).
|
||||
|
||||
Plugins that hand-roll ``fetch`` / ``WebSocket`` and read
|
||||
``window.__HERMES_SESSION_TOKEN__`` directly send an empty token in gated mode
|
||||
and 401/1008. That bug shipped in the kanban and achievements plugins and was
|
||||
invisible until the dashboard ran gated on hosted Fly agents.
|
||||
|
||||
This test fails if any bundled plugin's frontend reads the token global
|
||||
directly, forcing new/edited plugins through the SDK surface instead. It is
|
||||
the enforcement half of the "single sanctioned auth surface" design — the SDK
|
||||
helpers are the carrot, this test is the stick.
|
||||
|
||||
If you have a legitimate reason to reference the token name (e.g. a comment
|
||||
explaining why NOT to use it), add the file to ``_ALLOWED_FILES`` with a note.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Repo root: tests/plugins/<this file> → ../../
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_PLUGINS_DIR = _REPO_ROOT / "plugins"
|
||||
|
||||
# The forbidden global. Reading it directly bypasses the gated-mode auth path.
|
||||
_FORBIDDEN = "__HERMES_SESSION_TOKEN__"
|
||||
|
||||
# Files explicitly allowed to mention the token (none today). Map path →
|
||||
# reason so the allowance is self-documenting if one is ever needed.
|
||||
_ALLOWED_FILES: dict[str, str] = {}
|
||||
|
||||
|
||||
def _plugin_frontend_bundles() -> list[Path]:
|
||||
"""Every plugin-shipped JS bundle the dashboard loads into the browser."""
|
||||
if not _PLUGINS_DIR.is_dir():
|
||||
return []
|
||||
# Plugin dashboards live at plugins/<name>/dashboard/dist/*.js
|
||||
return sorted(_PLUGINS_DIR.glob("*/dashboard/dist/*.js"))
|
||||
|
||||
|
||||
def test_there_are_plugin_bundles_to_check() -> None:
|
||||
"""Sanity: the glob actually finds the bundles, so a future layout change
|
||||
doesn't silently turn this guard into a no-op."""
|
||||
bundles = _plugin_frontend_bundles()
|
||||
names = {b.parent.parent.parent.name for b in bundles}
|
||||
# kanban + hermes-achievements are bundled today; assert at least one is
|
||||
# found so the guard can't pass vacuously.
|
||||
assert bundles, "no plugin dashboard bundles found — glob/layout drift?"
|
||||
assert names, "could not resolve plugin names from bundle paths"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bundle",
|
||||
_plugin_frontend_bundles(),
|
||||
ids=lambda p: str(p.relative_to(_REPO_ROOT)),
|
||||
)
|
||||
def test_plugin_bundle_does_not_read_session_token(bundle: Path) -> None:
|
||||
rel = str(bundle.relative_to(_REPO_ROOT))
|
||||
text = bundle.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
if rel in _ALLOWED_FILES:
|
||||
return # explicitly allowed (with a documented reason)
|
||||
|
||||
# Only flag CODE reads of the token global, not mentions in ``//`` comments
|
||||
# (e.g. a comment explaining why the SDK helper is used instead). A line is
|
||||
# a code read if it contains the global and the global appears before any
|
||||
# ``//`` comment marker on that line.
|
||||
offending: list[str] = []
|
||||
for i, line in enumerate(text.splitlines(), start=1):
|
||||
idx = line.find(_FORBIDDEN)
|
||||
if idx == -1:
|
||||
continue
|
||||
comment_idx = line.find("//")
|
||||
in_comment = comment_idx != -1 and comment_idx < idx
|
||||
if not in_comment:
|
||||
offending.append(f" {i}: {line.strip()}")
|
||||
|
||||
if not offending:
|
||||
return
|
||||
|
||||
pytest.fail(
|
||||
f"{rel} reads {_FORBIDDEN} directly — this bypasses gated-mode auth "
|
||||
f"and 401/1008s on OAuth-gated dashboards. Use the plugin SDK instead: "
|
||||
f"SDK.fetchJSON (JSON), SDK.authedFetch (uploads/downloads), or "
|
||||
f"SDK.buildWsUrl (WebSockets). Offending lines:\n" + "\n".join(offending)
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Regression for #37718: macOS microphone entitlement must be inherited.
|
||||
|
||||
Hermes Desktop signs with ``hardenedRuntime: true`` and points electron-builder
|
||||
at two entitlement files (see ``apps/desktop/package.json``):
|
||||
|
||||
* ``entitlements`` → ``electron/entitlements.mac.plist`` (the main app), and
|
||||
* ``entitlementsInherit`` → ``electron/entitlements.mac.inherit.plist`` (the
|
||||
Electron Helper / Setup processes).
|
||||
|
||||
Under the hardened runtime, the process that actually opens the microphone is a
|
||||
Helper, which inherits the *inherit* plist. ``com.apple.security.device.audio-input``
|
||||
lived only in the main plist, so macOS' TCC layer refused the microphone with::
|
||||
|
||||
Prompting policy for hardened runtime; service: kTCCServiceMicrophone
|
||||
requires entitlement com.apple.security.device.audio-input but it is missing
|
||||
|
||||
and never showed the permission prompt. These tests pin that every device
|
||||
entitlement granted to the main app is also granted to the inherited helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import plistlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
ELECTRON_DIR = REPO_ROOT / "apps" / "desktop" / "electron"
|
||||
MAIN_PLIST = ELECTRON_DIR / "entitlements.mac.plist"
|
||||
INHERIT_PLIST = ELECTRON_DIR / "entitlements.mac.inherit.plist"
|
||||
|
||||
DEVICE_PREFIX = "com.apple.security.device."
|
||||
|
||||
|
||||
def _load(plist: Path) -> dict:
|
||||
assert plist.is_file(), f"missing entitlements file: {plist}"
|
||||
with plist.open("rb") as fh:
|
||||
return plistlib.load(fh)
|
||||
|
||||
|
||||
def test_inherit_plist_grants_microphone() -> None:
|
||||
"""The helper-inherited plist must grant audio-input (regression #37718)."""
|
||||
inherit = _load(INHERIT_PLIST)
|
||||
assert inherit.get("com.apple.security.device.audio-input") is True, (
|
||||
"entitlements.mac.inherit.plist must grant "
|
||||
"`com.apple.security.device.audio-input`; without it the hardened-runtime "
|
||||
"Helper process is denied the microphone and no TCC prompt appears (#37718)."
|
||||
)
|
||||
|
||||
|
||||
def test_device_entitlements_are_inherited() -> None:
|
||||
"""Every device.* entitlement on the main app must also be inherited."""
|
||||
main = _load(MAIN_PLIST)
|
||||
inherit = _load(INHERIT_PLIST)
|
||||
|
||||
main_device = {
|
||||
key: val
|
||||
for key, val in main.items()
|
||||
if key.startswith(DEVICE_PREFIX) and val is True
|
||||
}
|
||||
missing = [key for key in main_device if inherit.get(key) is not True]
|
||||
assert not missing, (
|
||||
"Device entitlements present in entitlements.mac.plist but missing from "
|
||||
f"entitlements.mac.inherit.plist: {missing}. Helper/Setup processes inherit "
|
||||
"the latter under hardenedRuntime, so any device access the app needs must "
|
||||
"be listed in both (#37718)."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("plist", [MAIN_PLIST, INHERIT_PLIST])
|
||||
def test_entitlement_files_are_valid_plists(plist: Path) -> None:
|
||||
"""Both entitlement files must remain well-formed plist dictionaries."""
|
||||
data = _load(plist)
|
||||
assert isinstance(data, dict) and data, f"{plist.name} should be a non-empty dict"
|
||||
@@ -147,11 +147,13 @@ def test_dockerfile_installs_tui_dependencies(dockerfile_text):
|
||||
# because it's referenced as a ``file:`` workspace dependency from
|
||||
# ``ui-tui/package.json`` — copying the tree avoids npm stopping at a
|
||||
# bare ``package.json`` shell.
|
||||
# With a single workspace root lockfile, only the root package-lock.json
|
||||
# is copied; per-workspace lockfiles no longer exist.
|
||||
assert "ui-tui/package.json" in dockerfile_text
|
||||
assert "ui-tui/package-lock.json" in dockerfile_text
|
||||
assert "ui-tui/packages/hermes-ink/" in dockerfile_text
|
||||
assert "package-lock.json" in dockerfile_text
|
||||
assert any(
|
||||
"ui-tui" in step and "npm" in step and (" install" in step or " ci" in step)
|
||||
"npm" in step and (" install" in step or " ci" in step)
|
||||
for step in _run_steps(dockerfile_text)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Contract test: the s6-overlay stage2 hook seeds gateway_state.json from
|
||||
HERMES_GATEWAY_BOOTSTRAP_STATE on first boot, so a freshly-provisioned
|
||||
container can come up with the gateway already running.
|
||||
|
||||
Background. On a blank volume there is no gateway_state.json, so the boot
|
||||
reconciler (cont-init.d/02-reconcile-profiles ->
|
||||
container_boot.reconcile_profile_gateways) registers the gateway-default s6
|
||||
slot but leaves it DOWN — it only auto-starts when the last recorded state was
|
||||
"running". A container provisioned on a fresh volume therefore comes up with
|
||||
the gateway down until something starts it.
|
||||
|
||||
An orchestrator that wants the gateway running from first boot sets
|
||||
HERMES_GATEWAY_BOOTSTRAP_STATE=running; stage2-hook.sh (installed as
|
||||
/etc/cont-init.d/01-hermes-setup, which runs lexicographically BEFORE
|
||||
02-reconcile-profiles) seeds the state file so the reconciler sees
|
||||
prior_state=running and brings the slot up on the very first boot.
|
||||
|
||||
This mirrors the existing HERMES_AUTH_JSON_BOOTSTRAP env-seed pattern: it seeds
|
||||
the SAME gateway_state.json the reconciler already consults, guarded by
|
||||
``[ ! -f ]`` so persisted runtime state always wins on subsequent boots (a
|
||||
deliberately-stopped gateway must stay stopped across restarts).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def stage2_text() -> str:
|
||||
if not STAGE2_HOOK.exists():
|
||||
pytest.skip("docker/stage2-hook.sh not present in this checkout")
|
||||
return STAGE2_HOOK.read_text()
|
||||
|
||||
|
||||
def _seed_block(text: str) -> str:
|
||||
"""Extract the ``if [ ! -f "$HERMES_HOME/gateway_state.json" ] && … fi``
|
||||
block that seeds the gateway state file from the bootstrap env var."""
|
||||
m = re.search(
|
||||
r'(if \[ ! -f "\$HERMES_HOME/gateway_state\.json" \] && \\\n'
|
||||
r"(?:.*\n)*?fi)",
|
||||
text,
|
||||
)
|
||||
assert m, (
|
||||
"stage2-hook.sh must contain the gateway_state.json bootstrap-seed block "
|
||||
"guarded on HERMES_GATEWAY_BOOTSTRAP_STATE"
|
||||
)
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_seed_block_present_and_guarded(stage2_text: str) -> None:
|
||||
block = _seed_block(stage2_text)
|
||||
# Must be a first-boot-only seed (the [ ! -f ] guard) keyed on the env var.
|
||||
assert '[ ! -f "$HERMES_HOME/gateway_state.json" ]' in block, (
|
||||
"seed must be guarded by [ ! -f ] so persisted state wins on restart"
|
||||
)
|
||||
assert "HERMES_GATEWAY_BOOTSTRAP_STATE" in block
|
||||
assert "gateway_state" in block
|
||||
|
||||
|
||||
def _run_seed(
|
||||
text: str, *, env_value: str | None, preexisting: str | None
|
||||
) -> str | None:
|
||||
"""Run the extracted seed block in a sandbox $HERMES_HOME.
|
||||
|
||||
``env_value`` is the HERMES_GATEWAY_BOOTSTRAP_STATE value (None = unset).
|
||||
``preexisting`` is the contents of a gateway_state.json placed before the
|
||||
block runs (None = no file). Returns the file's contents afterwards, or
|
||||
None if it doesn't exist. ``chown``/``chmod`` are stubbed so the block
|
||||
runs without real root.
|
||||
"""
|
||||
bash = shutil.which("bash")
|
||||
if bash is None:
|
||||
pytest.skip("bash not available")
|
||||
block = _seed_block(text)
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
dpath = Path(d)
|
||||
home = dpath / "home"
|
||||
home.mkdir()
|
||||
state_file = home / "gateway_state.json"
|
||||
if preexisting is not None:
|
||||
state_file.write_text(preexisting)
|
||||
|
||||
env_line = (
|
||||
f'export HERMES_GATEWAY_BOOTSTRAP_STATE="{env_value}"\n'
|
||||
if env_value is not None
|
||||
else "unset HERMES_GATEWAY_BOOTSTRAP_STATE\n"
|
||||
)
|
||||
script = (
|
||||
"set -e\n"
|
||||
f'HERMES_HOME="{home}"\n'
|
||||
# Stub privilege ops — the sandbox isn't root.
|
||||
"chown() { :; }\n"
|
||||
"chmod() { :; }\n"
|
||||
+ env_line
|
||||
+ block
|
||||
)
|
||||
script_path = dpath / "harness.sh"
|
||||
script_path.write_text(script)
|
||||
|
||||
proc = subprocess.run(
|
||||
[bash, str(script_path)], capture_output=True, text=True
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
|
||||
if not state_file.exists():
|
||||
return None
|
||||
return state_file.read_text()
|
||||
|
||||
|
||||
def test_seeds_running_state_on_blank_volume(stage2_text: str) -> None:
|
||||
"""env=running + no pre-existing file -> writes a valid running state."""
|
||||
out = _run_seed(stage2_text, env_value="running", preexisting=None)
|
||||
assert out is not None, "seed must create gateway_state.json"
|
||||
assert json.loads(out).get("gateway_state") == "running"
|
||||
|
||||
|
||||
def test_does_not_clobber_existing_state(stage2_text: str) -> None:
|
||||
"""The [ ! -f ] guard: an existing state file is never overwritten, even
|
||||
when the bootstrap env var says running. A deliberately-stopped gateway
|
||||
must stay stopped across restarts."""
|
||||
existing = json.dumps({"gateway_state": "stopped", "pid": 123})
|
||||
out = _run_seed(stage2_text, env_value="running", preexisting=existing)
|
||||
assert out == existing, "seed must not clobber a persisted state file"
|
||||
|
||||
|
||||
def test_no_seed_when_env_unset(stage2_text: str) -> None:
|
||||
"""No env var -> no file written (preserves the default down-on-first-boot
|
||||
behaviour for orchestrators that don't opt in)."""
|
||||
out = _run_seed(stage2_text, env_value=None, preexisting=None)
|
||||
assert out is None, "seed must not run when HERMES_GATEWAY_BOOTSTRAP_STATE is unset"
|
||||
|
||||
|
||||
def test_non_running_value_ignored(stage2_text: str) -> None:
|
||||
"""Only a literal "running" is honoured; any other value is ignored so a
|
||||
typo can't write a bogus state. (The reconciler's _AUTOSTART_STATES is
|
||||
exactly {"running"}.)"""
|
||||
for bogus in ("stopped", "Running", "1", "true", "starting"):
|
||||
out = _run_seed(stage2_text, env_value=bogus, preexisting=None)
|
||||
assert out is None, (
|
||||
f"only 'running' should seed a state file, not {bogus!r}"
|
||||
)
|
||||
@@ -6659,6 +6659,7 @@ def _(rid, params: dict) -> dict:
|
||||
picker_hints=True,
|
||||
canonical_order=True,
|
||||
pricing=True,
|
||||
capabilities=True,
|
||||
max_models=50,
|
||||
)
|
||||
return _ok(rid, payload)
|
||||
|
||||
Generated
-7449
File diff suppressed because it is too large
Load Diff
-1289
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { handleMouseEvent } from './components/App.js'
|
||||
import { createSelectionState, startSelection, updateSelection } from './selection.js'
|
||||
import { createSelectionState, hasSelection, startSelection, updateSelection } from './selection.js'
|
||||
|
||||
const makeApp = () => {
|
||||
const selection = createSelectionState()
|
||||
@@ -39,6 +39,39 @@ describe('handleMouseEvent right-click selection behavior', () => {
|
||||
expect(app.clickCount).toBe(0)
|
||||
})
|
||||
|
||||
it('clears the highlight after a successful right-click copy', async () => {
|
||||
const app = makeApp()
|
||||
|
||||
startSelection(app.props.selection, 0, 0)
|
||||
updateSelection(app.props.selection, 4, 0)
|
||||
expect(hasSelection(app.props.selection)).toBe(true)
|
||||
|
||||
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1, sequence: '' })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// Deliberate copy clears the selection (visual confirmation + a follow-up
|
||||
// right-click on empty space pastes rather than re-copying a stale range).
|
||||
expect(hasSelection(app.props.selection)).toBe(false)
|
||||
expect(app.props.onSelectionChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the highlight when right-click copy fails (no clipboard path)', async () => {
|
||||
const app = makeApp()
|
||||
app.props.onCopySelectionNoClear.mockResolvedValue('')
|
||||
|
||||
startSelection(app.props.selection, 0, 0)
|
||||
updateSelection(app.props.selection, 4, 0)
|
||||
|
||||
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1, sequence: '' })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// Copy didn't land, so the highlight must survive (and we fall back to the
|
||||
// right-click paste handler instead).
|
||||
expect(hasSelection(app.props.selection)).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to right-click handlers when selection copy has no clipboard path', async () => {
|
||||
const app = makeApp()
|
||||
app.props.onCopySelectionNoClear.mockResolvedValue('')
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
parseMultipleKeypresses
|
||||
} from '../parse-keypress.js'
|
||||
import reconciler from '../reconciler.js'
|
||||
import { finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js'
|
||||
import { clearSelection, finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js'
|
||||
import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js'
|
||||
import { TerminalQuerier, xtversion } from '../terminal-querier.js'
|
||||
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js'
|
||||
@@ -648,7 +648,15 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
|
||||
void app.props
|
||||
.onCopySelectionNoClear()
|
||||
.then(text => {
|
||||
if (!text) {
|
||||
if (text) {
|
||||
// Right-click copy is a deliberate action (unlike copy-on-select
|
||||
// during a drag, which keeps the highlight so the drag can
|
||||
// continue). Clear the highlight so the user gets visual
|
||||
// confirmation the copy landed and a follow-up right-click on
|
||||
// empty space pastes instead of re-copying the stale range.
|
||||
clearSelection(sel)
|
||||
app.props.onSelectionChange()
|
||||
} else {
|
||||
app.props.onMouseDownAt(col, row, baseButton)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { blockRenders, hasLeadGap, messageGroup, prevRenderedMsg } from '../domain/blockLayout.js'
|
||||
import type { Msg } from '../types.js'
|
||||
|
||||
const m = (over: Partial<Msg>): Msg => ({ role: 'assistant', text: '', ...over })
|
||||
|
||||
describe('messageGroup', () => {
|
||||
it('classifies each block kind into its visual band', () => {
|
||||
expect(messageGroup(m({ role: 'assistant' }))).toBe('model')
|
||||
expect(messageGroup(m({ role: 'assistant', kind: 'diff' }))).toBe('diff')
|
||||
expect(messageGroup(m({ role: 'system', kind: 'trail' }))).toBe('trail')
|
||||
expect(messageGroup(m({ role: 'system' }))).toBe('note')
|
||||
expect(messageGroup(m({ role: 'user' }))).toBe('user')
|
||||
expect(messageGroup(m({ role: 'user', kind: 'slash' }))).toBe('slash')
|
||||
expect(messageGroup(m({ role: 'system', kind: 'intro' }))).toBe('intro')
|
||||
expect(messageGroup(m({ role: 'system', kind: 'panel' }))).toBe('intro')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasLeadGap', () => {
|
||||
const trail = m({ role: 'system', kind: 'trail' })
|
||||
const model = m({ role: 'assistant' })
|
||||
const note = m({ role: 'system' })
|
||||
const user = m({ role: 'user' })
|
||||
const diff = m({ role: 'assistant', kind: 'diff' })
|
||||
const slash = m({ role: 'user', kind: 'slash' })
|
||||
|
||||
it('opens a gap only at a boundary between working-area groups', () => {
|
||||
expect(hasLeadGap(trail, model)).toBe(true)
|
||||
expect(hasLeadGap(model, trail)).toBe(true)
|
||||
expect(hasLeadGap(model, note)).toBe(true)
|
||||
expect(hasLeadGap(note, model)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps same-group neighbours flush (the grouping)', () => {
|
||||
expect(hasLeadGap(trail, trail)).toBe(false)
|
||||
expect(hasLeadGap(model, model)).toBe(false)
|
||||
expect(hasLeadGap(note, note)).toBe(false)
|
||||
})
|
||||
|
||||
it('never gaps the first block (no predecessor)', () => {
|
||||
expect(hasLeadGap(undefined, model)).toBe(false)
|
||||
expect(hasLeadGap(undefined, trail)).toBe(false)
|
||||
})
|
||||
|
||||
it('suppresses the gap after blocks that already paint a trailing line', () => {
|
||||
// user and diff carry their own marginBottom — the following block must
|
||||
// not add a second blank line on top of it.
|
||||
expect(hasLeadGap(user, trail)).toBe(false)
|
||||
expect(hasLeadGap(user, model)).toBe(false)
|
||||
expect(hasLeadGap(diff, model)).toBe(false)
|
||||
})
|
||||
|
||||
it('still gaps after a slash echo (it has no trailing margin)', () => {
|
||||
expect(hasLeadGap(slash, model)).toBe(true)
|
||||
expect(hasLeadGap(slash, trail)).toBe(true)
|
||||
})
|
||||
|
||||
it('lets user / slash / diff own their spacing (never managed here)', () => {
|
||||
expect(hasLeadGap(model, user)).toBe(false)
|
||||
expect(hasLeadGap(model, slash)).toBe(false)
|
||||
expect(hasLeadGap(model, diff)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blockRenders', () => {
|
||||
const trail: Msg = { role: 'system', kind: 'trail', text: '', tools: ['Edit foo.ts'] }
|
||||
const model: Msg = { role: 'assistant', text: 'hi' }
|
||||
const todos: Msg = { role: 'system', kind: 'trail', text: '', todos: [{ content: 'a', id: '1', status: 'pending' }] }
|
||||
|
||||
it('always renders non-trail blocks', () => {
|
||||
expect(blockRenders(model, { detailsMode: 'hidden', commandOverride: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('renders a content-bearing trail unless every section is hidden', () => {
|
||||
expect(blockRenders(trail, { detailsMode: 'collapsed' })).toBe(true)
|
||||
expect(blockRenders(trail, { detailsMode: 'expanded' })).toBe(true)
|
||||
// /details hidden routes through commandOverride, which hides every section.
|
||||
expect(blockRenders(trail, { detailsMode: 'hidden', commandOverride: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render a content-less trail (e.g. finalDetails with only a token tally)', () => {
|
||||
const tally: Msg = { role: 'system', kind: 'trail', text: '', toolTokens: 40 }
|
||||
|
||||
expect(blockRenders(tally, { detailsMode: 'expanded' })).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps todo trails visible even when details are hidden', () => {
|
||||
expect(blockRenders(todos, { detailsMode: 'hidden', commandOverride: true })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prevRenderedMsg', () => {
|
||||
const hiddenCtx = { commandOverride: true, detailsMode: 'hidden' as const }
|
||||
const shownCtx = { detailsMode: 'collapsed' as const }
|
||||
|
||||
const rows: Msg[] = [
|
||||
{ role: 'user', text: 'q' }, // 0
|
||||
{ role: 'system', kind: 'trail', text: '', tools: ['Edit foo.ts'] }, // 1
|
||||
{ role: 'assistant', text: 'first' }, // 2
|
||||
{ role: 'system', kind: 'trail', text: '', tools: ['Edit bar.ts'] }, // 3
|
||||
{ role: 'assistant', text: 'second' } // 4
|
||||
]
|
||||
const at = (i: number) => rows[i]
|
||||
|
||||
it('returns the literal predecessor when everything renders', () => {
|
||||
expect(prevRenderedMsg(at, 2, shownCtx)).toBe(rows[1])
|
||||
expect(prevRenderedMsg(at, 4, shownCtx)).toBe(rows[3])
|
||||
})
|
||||
|
||||
it('skips hidden trails so grouping sees the nearest visible block', () => {
|
||||
// With trails hidden, the prose at index 2 groups against the user (not the
|
||||
// invisible trail) and the prose at index 4 groups against the prose at 2.
|
||||
expect(prevRenderedMsg(at, 2, hiddenCtx)).toBe(rows[0])
|
||||
expect(prevRenderedMsg(at, 4, hiddenCtx)).toBe(rows[2])
|
||||
})
|
||||
|
||||
it('returns undefined at the top of the transcript', () => {
|
||||
expect(prevRenderedMsg(at, 0, shownCtx)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,14 @@ describe('virtual height estimates', () => {
|
||||
expect(estimatedMsgHeight(msg, 26, { compact: false, details: false, userPrompt: 'Ψ >' })).toBe(4)
|
||||
})
|
||||
|
||||
it('adds one row for a group-boundary lead gap', () => {
|
||||
const msg: Msg = { role: 'assistant', text: 'reply' }
|
||||
|
||||
expect(estimatedMsgHeight(msg, 80, { compact: false, details: false, leadGap: true })).toBe(
|
||||
estimatedMsgHeight(msg, 80, { compact: false, details: false, leadGap: false }) + 1
|
||||
)
|
||||
})
|
||||
|
||||
it('includes detail sections when visible', () => {
|
||||
const msg: Msg = { role: 'assistant', text: 'ok', thinking: 'line 1\nline 2', tools: ['Tool A', 'Tool B'] }
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user