From 712734436ee8db6b05293ea02e4442d06bd13d1a Mon Sep 17 00:00:00 2001 From: ethernet Date: Tue, 8 Sep 2026 00:24:51 -0400 Subject: [PATCH] fix(pm): make bootstrap and bundle ownership explicit Finish bootstrap uv before PM replaces its store entry. Keep failure receipts stdlib-only and align the cryptography requirement and override with the locked version. Let bundle builders declare launch paths and update ownership. Remove payload discovery, Store probing, and the unused develop command. Derive Nix Python from the PM lock and share its provenance stamp. Document setup, activation, optional dependencies, and distribution ownership. Targeted Windows tests, relocated runtime launches, Electron bundling, and bilingual docs builds pass. Native Nix and signed-package acceptance remain CI gates. --- CONTRIBUTING.md | 177 +++++----- README.es.md | 29 +- README.md | 39 +-- README.ur-pk.md | 39 +-- README.zh-CN.md | 24 +- apps/desktop/BUILDING.md | 289 ++++++++------- apps/desktop/README.md | 43 +-- apps/desktop/electron-builder.config.cjs | 7 +- apps/desktop/electron/app-updater.ts | 5 +- apps/desktop/electron/bundle-swap.test.ts | 31 +- apps/desktop/electron/bundle-swap.ts | 23 +- .../electron/first-run-setup-gate.test.ts | 42 +-- apps/desktop/electron/first-run-setup-gate.ts | 7 +- .../first-run-setup-main-process.test.ts | 105 ------ apps/desktop/electron/install-stamp.test.ts | 8 +- apps/desktop/electron/install-stamp.ts | 26 +- apps/desktop/electron/main.ts | 233 ++----------- apps/desktop/electron/payload-backend.test.ts | 202 ++--------- apps/desktop/electron/payload-backend.ts | 163 ++------- .../electron/update-root-policy.test.ts | 15 +- apps/desktop/electron/update-root-policy.ts | 6 +- .../updater/checkout-ownership.test.ts | 3 +- apps/desktop/electron/updater/external.ts | 8 +- apps/desktop/electron/updater/index.ts | 23 +- apps/desktop/electron/updater/updater.test.ts | 43 ++- apps/desktop/package.json | 2 +- apps/desktop/scripts/before-build.mjs | 20 +- apps/desktop/scripts/write-build-stamp.mjs | 91 ++--- .../scripts/write-build-stamp.test.mjs | 50 +-- .../desktop-install-local-card.test.ts | 13 - .../components/desktop-install-local-card.ts | 39 +-- .../desktop-install-overlay.test.tsx | 35 -- .../components/desktop-install-overlay.tsx | 21 +- apps/desktop/src/global.d.ts | 2 +- apps/desktop/src/i18n/en.ts | 4 - apps/desktop/src/i18n/types.ts | 3 - apps/desktop/src/i18n/zh.ts | 4 - docs/macos-bundle-updates.md | 15 +- docs/pm-audit-status.md | 42 ++- docs/shared-bundle-builds.md | 43 ++- hermes_cli/steward.py | 1 + hermes_cli/update_channel.py | 20 +- hermes_cli/venv_sync.py | 6 +- hermes_cli/version_info.py | 5 +- nix/checks.nix | 43 ++- nix/desktop.nix | 4 +- nix/hermes-agent.nix | 40 ++- nix/moduleCommon.nix | 7 +- nix/python.nix | 27 +- nix/pythonLock.nix | 40 +++ .../creative/comfyui/tests/README.md | 6 +- plugins/memory/hindsight/README.md | 23 +- pm/cli.py | 118 ------- pm/receipt.py | 15 +- pyproject.toml | 6 +- scripts/bundles/desktop.py | 4 +- scripts/bundles/payload.py | 11 + scripts/install.ps1 | 11 +- scripts/install.sh | 16 +- scripts/write_install_stamp.py | 9 +- setup-hermes.ps1 | 14 +- setup-hermes.sh | 13 +- .../references/contributor-guide.md | 6 +- .../python-debugpy/SKILL.md | 2 +- tests-js/macos-bundled-helpers.test.mjs | 3 +- tests/hermes_cli/test_update_channel.py | 12 +- tests/install/README.md | 42 ++- .../e2e-assets/mac-bundled-manifest.cjs | 3 - tests/pm/test_activation_runtime.py | 17 +- tests/pm/test_develop_env.py | 108 ------ tests/pm/test_receipt.py | 43 ++- tests/scripts/test_bundle_payload.py | 34 +- tests/scripts/test_write_install_stamp.py | 16 +- ...t_install_ps1_managed_python_provenance.py | 55 ++- tests/test_project_metadata.py | 22 +- tools/wakewords/README.md | 15 +- ui-tui/README.md | 5 +- uv.lock | 4 +- website/docs/developer-guide/contributing.md | 169 ++++----- .../developer-guide/memory-provider-plugin.md | 4 +- website/docs/developer-guide/plugins/index.md | 48 ++- .../web-search-provider-plugin.md | 9 +- website/docs/getting-started/installation.md | 139 +++++--- website/docs/getting-started/nix-setup.md | 49 ++- .../docs/getting-started/platform-support.md | 26 +- website/docs/getting-started/quickstart.md | 15 +- website/docs/getting-started/termux.md | 30 +- website/docs/getting-started/updating.md | 206 +++++------ website/docs/index.mdx | 4 +- website/docs/reference/cli-commands.md | 35 +- .../docs/reference/environment-variables.md | 9 +- website/docs/reference/faq.md | 20 +- website/docs/reference/package-management.md | 328 ++++++++++++++++++ website/docs/user-guide/desktop.md | 118 +++++-- website/docs/user-guide/docker.md | 68 +++- .../user-guide/features/code-execution.md | 11 + .../user-guide/features/memory-providers.md | 17 +- website/docs/user-guide/features/plugins.md | 62 ++-- .../docs/user-guide/features/voice-mode.md | 40 +-- website/docs/user-guide/features/wake-word.md | 53 +-- website/docs/user-guide/local-models.md | 11 + website/docs/user-guide/messaging/matrix.md | 5 +- website/docs/user-guide/messaging/photon.md | 3 +- website/docs/user-guide/security.md | 75 ++-- .../software-development-python-debugpy.md | 2 +- .../docs/user-guide/switching-to-source.md | 213 +++++++----- website/docs/user-guide/windows-native.md | 210 ++++++----- .../current/developer-guide/contributing.md | 123 +++---- .../current/developer-guide/plugins/index.md | 65 ++-- .../web-search-provider-plugin.md | 10 +- .../current/getting-started/installation.md | 122 ++----- .../current/getting-started/nix-setup.md | 38 +- .../current/getting-started/quickstart.md | 8 +- .../current/getting-started/termux.md | 99 ++++++ .../current/reference/cli-commands.md | 17 +- .../reference/environment-variables.md | 17 +- .../current/reference/faq.md | 26 +- .../current/user-guide/docker.md | 74 ++-- .../user-guide/features/memory-providers.md | 11 +- .../current/user-guide/features/plugins.md | 41 ++- .../current/user-guide/features/voice-mode.md | 40 +-- .../current/user-guide/messaging/matrix.md | 2 +- .../current/user-guide/security.md | 52 ++- .../current/user-guide/windows-native.md | 152 ++++---- website/package-lock.json | 20 ++ website/package.json | 1 + website/sidebars.ts | 1 + .../AutomationBlueprintsCatalog/index.tsx | 6 +- .../components/UserStoriesCollage/index.tsx | 2 +- website/tsconfig.json | 7 +- 130 files changed, 2997 insertions(+), 2716 deletions(-) create mode 100644 nix/pythonLock.nix delete mode 100644 tests/pm/test_develop_env.py create mode 100644 website/docs/reference/package-management.md create mode 100644 website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/termux.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 452e935b36..070b4955cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,107 +109,112 @@ A well-built third-party-product plugin can clear automated review and still be | Requirement | Notes | |-------------|-------| | **Git** | With the `git-lfs` extension installed | -| **Python 3.11–3.13** | uv will install it if missing | +| **Python 3.14** | The project requires `>=3.14,<3.15`; PM provides the pinned interpreter | | **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) | -| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) | +| **Node.js** | Use the PM pin, or a version accepted by root `package.json`: `^22.22.0`, `^24.11.0`, or `>=26.0.0` | -### Install with the standard installer +### PM developer environment -For most contributors, the best development bootstrap is the same path users -take: run the standard installer, then work inside the repository it cloned. -The installer creates the Hermes venv, wires the `hermes` command, stamps the -install method for `hermes update`, and clones the full git project into -`$HERMES_HOME/hermes-agent` (usually `~/.hermes/hermes-agent`). That keeps your -development environment on the same layout the CLI, updater, lazy dependency -installer, gateway, and docs assume. +Use the [PM developer workflow](website/docs/reference/package-management.md#developer-workflow) for preparation, activation, everyday commands, +dependency changes, and test environments. Select your development +home before setup so experimental code does not migrate production data. + +After successful setup, activate from the repository root in each new shell. + +Bash: ```bash -curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent" - -# Add dev/test extras on top of the standard install. -uv pip install -e ".[all,dev]" - -# Optional: docs site + workspace dependencies. -npm install +source ./activate +python hermes --version ``` -After that, create branches and run tests from that checkout: +PowerShell: + +```powershell +. .\activate.ps1 +python hermes --version +``` + +Run `python hermes` for this checkout, not a global `hermes` alias. PM activation +adds installed tools and the selected dependency tree. It does not install +packages or JS workspaces. `deactivate` restores the prior shell environment. + +### Manual development and test environment + +Use Python 3.14 (`>=3.14,<3.15`). Keep a development environment outside the +source tree if an agent will operate on that checkout. Leave PM activation +before this sequence. Keep the same development `HERMES_HOME` when running it. + +This environment is for tests and editor tools. Its own interpreter includes +pytest without relying on PM's `PYTHONPATH`. On Windows, initialize the native +C++ build environment for your architecture before building source dependencies. + +POSIX: ```bash -git checkout -b fix/description -scripts/run_tests.sh +uv venv "$HOME/.hermes/venvs/hermes-dev" --python 3.14 +export UV_PROJECT_ENVIRONMENT="$HOME/.hermes/venvs/hermes-dev" +uv sync --locked --extra all --extra dev +export HERMES_PYTHON="$UV_PROJECT_ENVIRONMENT/bin/python" +"$HERMES_PYTHON" hermes --version ``` -### Manual clone fallback +PowerShell: -Use this only if you intentionally do not want Hermes' managed install layout -(for example, a throwaway clone inside a container or CI job). If you install -this way, make sure you run the `hermes` entrypoint from this venv; running the -system `python3 -m hermes_cli.main` can pick up unrelated system Python -packages. +```powershell +$devEnv = Join-Path $env:LOCALAPPDATA 'hermes-dev-env' +uv venv $devEnv --python 3.14 +$env:UV_PROJECT_ENVIRONMENT = $devEnv +uv sync --locked --extra all --extra dev +$env:HERMES_PYTHON = Join-Path $devEnv 'Scripts/python.exe' +& $env:HERMES_PYTHON hermes --version +``` -Create the venv **outside** the cloned source tree. A venv that lives inside -the directory the agent operates from can be wiped by a relative-path command -the agent runs against its own checkout (`rm -rf venv`, `uv venv venv`, etc.), -which silently destroys the running runtime mid-session. Keeping it outside the -tree means no relative path from the workspace resolves to it. +This environment is for source development and tests. It does not replace +PM's tool store or a packaged app's dependency selection. +Run `uv pip check --python` with this environment's interpreter to check its dependencies. +Do not install into an MSIX payload or point a bundled app at this environment. + +For an isolated development instance, select a disposable `HERMES_HOME` before +starting the source command. Use `python hermes setup` to configure it rather +than copying production credentials into the checkout. + +### JavaScript workspaces and website + +From the repository root, run `npm ci` for the desktop, TUI, dashboard, and +shared JS workspaces. The website is separate: ```bash -git clone https://github.com/NousResearch/hermes-agent.git -cd hermes-agent - -# Create venv with Python 3.11, OUTSIDE the source tree -uv venv ~/.hermes/venvs/hermes-dev --python 3.11 -export VIRTUAL_ENV="$HOME/.hermes/venvs/hermes-dev" -export PATH="$VIRTUAL_ENV/bin:$PATH" - -# Install with all extras (messaging, cron, CLI menus, dev tools) -uv pip install -e ".[all,dev]" - -# Optional: workspace / docs dependencies -npm install +npm ci --prefix website +npm run build:fast --prefix website ``` -### Configure for development +Use a Node/npm version accepted by the corresponding `package.json` engines. +Native desktop dependencies can also require the platform build toolchain. -```bash -mkdir -p ~/.hermes/{cron,sessions,logs,memories,skills} -cp cli-config.yaml.example ~/.hermes/config.yaml -touch ~/.hermes/.env - -# Add at minimum an LLM provider key: -echo "OPENROUTER_API_KEY=***" >> ~/.hermes/.env -``` - -### Run - -```bash -# The standard installer already put `hermes` on PATH. -hermes doctor -hermes chat -q "Hello" -``` - -If you used the manual clone fallback, run `./hermes` from the checkout or -symlink this clone's venv explicitly: - -```bash -mkdir -p ~/.local/bin -ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes -``` +Logos and icons are generated from `assets/nous-girl-*.svg` and +`assets/backgrounds/`. `node scripts/generate-icons.mjs` uses the locked, +isolated `icon-build` dependency group. Do not commit generated PNG/ICO/ICNS +outputs or add icon renderers to production dependencies. ### Run tests -```bash -# Preferred — matches CI (hermetic `env -i`; per-file subprocess -# isolation on POSIX, pytest-xdist --dist loadfile on Windows); see AGENTS.md -scripts/run_tests.sh +Use the canonical runner on every host: -# Alternative (activate the venv first). The wrapper is still recommended -# for parity with GitHub Actions before you open a PR: -pytest tests/ -v +```bash +scripts/run_tests.sh +scripts/run_tests.sh tests/agent/ -v ``` +On Windows, run the script through Bash. When no local `.venv` or `venv` +contains pytest, the runner accepts the explicit `HERMES_PYTHON` above. It +clears credentials, isolates `HERMES_HOME`, and runs each test file in a separate +subprocess through `scripts/run_tests_parallel.py`. It does not use xdist. + +Run the relevant JS workspace checks for JS changes. Native install/update +E2E runs on disposable CI hosts, never against the developer's live app. +See [Package management](website/docs/reference/package-management.md) for PM commands and runtime ownership. + --- ## Project Structure @@ -842,15 +847,19 @@ that touches the OS, assume *any* platform can hit your code path. ### Testing cross-platform -Tests that excercise behavior on specific platforms must run on their target platforms. +Tests of host-specific behavior must run on that host. Apply one `platforms` +marker to each test, rather than changing `sys.platform`: ```python -@pytest.mark.platforms("linux") -@pytest.mark.platforms("macos") -@pytest.mark.platforms("windows") +@pytest.mark.platforms("windows", arch="arm64") +def test_native_windows_arm64_behavior(): + ... ``` -Avoid monkeypatching `sys.platform` unless absolutely needed, but if you do, also patch `platform.system()` / `platform.release()` / `platform.mac_ver()`. -Symlinks, 0o600 permissions, SIGALRM, os.setsid/fork are all unix-only. + +For several supported hosts, use one marker with multiple arguments, such as +`@pytest.mark.platforms("linux", "macos")`. Do not stack host markers. +Tests of pure functions that accept a platform as data need no host marker. +See [AGENTS.md](AGENTS.md#dont-fake-the-host-os) for the complete contract. --- @@ -937,7 +946,7 @@ refactor/description # Code restructuring ### Before submitting -1. **Run tests**: `scripts/run_tests.sh` (recommended; same as CI) or `pytest tests/ -v` with the project venv activated +1. **Run tests**: use `scripts/run_tests.sh` for the same environment and per-file isolation as CI. 2. **Test manually**: Run `hermes` and exercise the code path you changed 3. **Check cross-platform impact**: If you touch file I/O, process management, or terminal handling, consider macOS, Linux, and WSL2 4. **Keep PRs focused**: One logical change per PR. Don't mix a bug fix with a refactor with a new feature. diff --git a/README.es.md b/README.es.md index 037be2b3cc..0fe2c374c9 100644 --- a/README.es.md +++ b/README.es.md @@ -50,11 +50,12 @@ Ejecuta esto en PowerShell: iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` -El instalador se encarga de todo: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **y un Git Bash portátil** (MinGit, descomprimido en `%LOCALAPPDATA%\hermes\git` — no requiere administrador, completamente aislado de cualquier instalación de Git del sistema). Hermes usa este Git Bash incluido para ejecutar comandos de shell. +El instalador de código fuente usa PM para Python 3.14, Node.js, npm, +ripgrep, FFmpeg y las dependencias de Python. Si falta Git, descarga el archivo +verificado de Git for Windows en el almacén de Hermes, sin reemplazar el Git +del sistema. MSIX/App Installer es una distribución separada. -Si ya tienes Git instalado, el instalador lo detecta y lo usa en su lugar. De lo contrario, una descarga de ~45MB de MinGit es todo lo que necesitas — no tocará ni interferirá con ningún Git del sistema. - -> **Android:** Hermes ya no es compatible con Android ni Termux. Consulta la [página de plataformas compatibles](https://hermes-agent.nousresearch.com/docs/getting-started/platform-support) para ver las plataformas admitidas. +> **Android / Termux:** Hay un paquete APT en pruebas para dispositivos aarch64. Incluye Python, Node.js y la TUI. Sigue la [guía de Termux](https://hermes-agent.nousresearch.com/docs/getting-started/termux), no el script de instalación para escritorio y servidor. > > **Windows:** Windows nativo es totalmente compatible — el comando de PowerShell de arriba instala todo. Si prefieres usar WSL2, el comando de Linux también funciona allí. La instalación nativa de Windows se encuentra en `%LOCALAPPDATA%\hermes`; WSL2 instala en `~/.hermes` como en Linux. @@ -182,24 +183,8 @@ Consulta `hermes claw migrate --help` para todas las opciones, o usa la habilida ¡Las contribuciones son bienvenidas! Consulta la [Guía de Contribución](CONTRIBUTING.es.md) para la configuración del desarrollo, el estilo de código y el proceso de PR. -Inicio rápido para colaboradores — clona y comienza con `setup-hermes.sh`: - -```bash -git clone https://github.com/NousResearch/hermes-agent.git -cd hermes-agent -./setup-hermes.sh # instala uv, crea venv, instala .[all], enlaza ~/.local/bin/hermes -./hermes # detecta automáticamente el venv, no necesitas hacer `source` primero -``` - -Ruta manual (equivalente a lo anterior): - -```bash -curl -LsSf https://astral.sh/uv/install.sh | sh -uv venv .venv --python 3.11 -source .venv/bin/activate -uv pip install -e ".[all,dev]" -scripts/run_tests.sh -``` +La configuración de PM, el entorno de pruebas con Python 3.14 y los comandos +de verificación están en [Development Setup](CONTRIBUTING.md#development-setup). --- diff --git a/README.md b/README.md index b256850777..5a407aa8fe 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,13 @@ Run this in PowerShell: iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` -The installer handles everything: uv, Python 3.14, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands. +The source installer delegates Python 3.14, Node.js, npm, ripgrep, FFmpeg, +and Python dependencies to PM. If Git is absent, it stages the verified Git +for Windows archive in Hermes' tool store. It does not replace your system Git. +See [installation methods](https://hermes-agent.nousresearch.com/docs/getting-started/installation) +for the separate MSIX/App Installer package and its update ownership. -If you already have Git installed, the installer detects it and uses that instead. Otherwise a ~45MB MinGit download is all you need — it won't touch or interfere with any system Git. - -> **Android:** Hermes no longer supports Android or Termux. See the [platform support page](https://hermes-agent.nousresearch.com/docs/getting-started/platform-support) for the supported platforms. +> **Android / Termux:** A prerelease APT package is available for aarch64 devices. It includes Python, Node.js, and the TUI. Use the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux), not the desktop/server installer script. > > **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. @@ -218,32 +220,9 @@ See `hermes claw migrate --help` for all options, or use the `openclaw-migration We welcome contributions! See the [Contributing Guide](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) for development setup, code style, and PR process. -Quick start for contributors — use the standard installer, then work from the -full git checkout it creates at `$HERMES_HOME/hermes-agent` (usually -`~/.hermes/hermes-agent`). This matches the layout used by `hermes update`, the -managed venv, lazy dependencies, gateway, and docs tooling. - -```bash -curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent" -uv pip install -e ".[all,dev]" -scripts/run_tests.sh -``` - -Manual clone fallback (for throwaway clones/CI where you intentionally do not -want the managed install layout): - -Create the venv outside the cloned source tree — a venv inside the directory -the agent operates from can be wiped by a relative-path command the agent runs -against its own checkout, destroying the running runtime mid-session. - -```bash -curl -LsSf https://astral.sh/uv/install.sh | sh -uv venv ~/.hermes/venvs/hermes-dev --python 3.14 -source ~/.hermes/venvs/hermes-dev/bin/activate -uv pip install -e ".[all,dev]" -scripts/run_tests.sh -``` +Start with the [PM developer workflow](website/docs/reference/package-management.md#developer-workflow) +for activation, daily use, dependency changes, and leaving the environment. +[Development Setup](CONTRIBUTING.md#development-setup) covers the separate test environment and verification commands. --- diff --git a/README.ur-pk.md b/README.ur-pk.md index 56c091975e..3cb67640c2 100644 --- a/README.ur-pk.md +++ b/README.ur-pk.md @@ -57,13 +57,14 @@ iex (irm https://hermes-agent.nousresearch.com/install.ps1) -انسٹالر سب کچھ خود سنبھالتا ہے: uv، Python 3.11، Node.js، ripgrep، ffmpeg، **اور ایک پورٹ ایبل (portable) گٹ بیش (Git Bash)** (یعنی MinGit، جو `%LOCALAPPDATA%\hermes\git` میں ان پیک ہوتا ہے — اس کے لیے ایڈمن کی اجازت درکار نہیں، اور یہ سسٹم کے کسی بھی گٹ انسٹال سے بالکل الگ ہے)۔ ہرمیس اس بنڈل شدہ گٹ بیش کو شیل کمانڈز چلانے کے لیے استعمال کرتا ہے۔ +سورس انسٹالر Python 3.14، Node.js، npm، ripgrep، FFmpeg اور Python کی +ڈیپینڈینسیز کے لیے PM استعمال کرتا ہے۔ اگر Git موجود نہ ہو تو Git for Windows +کا تصدیق شدہ آرکائیو ہرمیس کے ٹول اسٹور میں نصب کرتا ہے۔ سسٹم کا Git تبدیل نہیں +ہوتا۔ MSIX/App Installer ایک الگ پیکیج ہے۔ -اگر آپ کے پاس پہلے سے گٹ (Git) انسٹال ہے، تو انسٹالر اسے شناخت کر لیتا ہے اور اسے ہی استعمال کرتا ہے۔ بصورت دیگر آپ کو صرف ~45MB کے MinGit ڈاؤنلوڈ کی ضرورت ہوگی — یہ آپ کے سسٹم کے گٹ پر کوئی اثر نہیں ڈالے گا۔ - -> **اینڈرائیڈ (Android):** ہرمیس اب اینڈرائیڈ یا ٹرمکس کو سپورٹ نہیں کرتا۔ معاونت یافتہ پلیٹ فارمز کے لیے [پلیٹ فارم سپورٹ پیج](https://hermes-agent.nousresearch.com/docs/getting-started/platform-support) دیکھیں۔ +> **اینڈرائیڈ / ٹرمکس (Android / Termux):** aarch64 آلات کے لیے آزمائشی APT پیکیج دستیاب ہے۔ اس میں Python، Node.js اور TUI شامل ہیں۔ ڈیسک ٹاپ اور سرور کے انسٹالیشن اسکرپٹ کے بجائے [Termux گائیڈ](https://hermes-agent.nousresearch.com/docs/getting-started/termux) استعمال کریں۔ > -> **ونڈوز (Windows):** مقامی ونڈوز کی مکمل سپورٹ موجود ہے — اوپر دی گئی پاور شیل کی کمانڈ سب کچھ انسٹال کر دیتی ہے۔ اگر آپ WSL2 استعمال کرنا چاہتے ہیں، تو لینکس کی کمانڈ وہاں کام کرتی ہے۔ مقامی ونڈوز میں انسٹالیشن `%LOCALAPPDATA%\hermes` میں ہوتی ہے؛ جبکہ WSL2 میں لینکس کی طرح `~/.hermes` میں ہوتی ہے۔ ہرمیس کا وہ واحد فیچر جسے فی الحال خاص طور پر WSL2 کی ضرورت ہے وہ براؤزر پر مبنی ڈیش بورڈ چیٹ پین ہے (یہ POSIX PTY استعمال کرتا ہے — کلاسک CLI اور گیٹ وے دونوں مقامی طور پر چلتے ہیں)۔ +> **ونڈوز (Windows):** مقامی سورس انسٹال کے لیے اوپر دیا گیا PowerShell کمانڈ استعمال کریں۔ WSL2 میں لینکس کمانڈ استعمال ہوتا ہے۔ مقامی ڈیٹا `%LOCALAPPDATA%\hermes` میں اور WSL2 کا ڈیٹا `~/.hermes` میں ہوتا ہے۔ ڈیش بورڈ چیٹ مقامی Windows پر pywinpty/ConPTY استعمال کرتا ہے؛ پلیٹ فارم کی حدود [Windows گائیڈ](https://hermes-agent.nousresearch.com/docs/user-guide/windows-native) میں درج ہیں۔ انسٹالیشن کے بعد: @@ -213,32 +214,8 @@ hermes claw migrate --overwrite # موجودہ متصادم فائلوں کو ہم آپ کے تعاون کا خیرمقدم کرتے ہیں! ڈیویلپمنٹ سیٹ اپ، کوڈ کے انداز اور PR کے طریقہ کار کے لیے براہ کرم ہماری [Contributing گائیڈ](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) دیکھیں۔ -معاونین (contributors) کے لیے فوری آغاز — کلون (clone) کریں اور `setup-hermes.sh` چلائیں: - -
- -```bash -git clone https://github.com/NousResearch/hermes-agent.git -cd hermes-agent -./setup-hermes.sh # uv کو انسٹال کرتا ہے، venv بناتا ہے، .[all] کو انسٹال کرتا ہے، اور ~/.local/bin/hermes کا سیم لنک (symlink) بناتا ہے -./hermes # خود بخود venv کی شناخت کرتا ہے، پہلے `source` کرنے کی ضرورت نہیں -``` - -
- -مینوئل طریقہ (اوپر والے طریقے کے مساوی): - -
- -```bash -curl -LsSf https://astral.sh/uv/install.sh | sh -uv venv .venv --python 3.11 -source .venv/bin/activate -uv pip install -e ".[all,dev]" -scripts/run_tests.sh -``` - -
+PM اور Python 3.14 کے ٹیسٹ ماحول اور تصدیقی کمانڈز کے لیے +[Development Setup](CONTRIBUTING.md#development-setup) دیکھیں۔ --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 0e96a22217..bf9f4a33db 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -37,7 +37,7 @@ curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash 支持 Linux、macOS 和 WSL2。安装程序会自动处理平台特定的配置。 -> **Android:** Hermes 不再支持 Android 或 Termux。请参阅[平台支持页面](https://hermes-agent.nousresearch.com/docs/getting-started/platform-support)了解支持的平台。 +> **Android / Termux:** aarch64 设备可使用预发布的 APT 软件包,其中包含 Python、Node.js 和 TUI。请按照 [Termux 指南](https://hermes-agent.nousresearch.com/docs/getting-started/termux)安装,不要使用桌面和服务器的安装脚本。 > > **Windows:** 在 PowerShell 中运行: > ```powershell @@ -168,26 +168,8 @@ hermes claw migrate --overwrite # 覆盖已有冲突 欢迎贡献!请参阅 [贡献指南](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) 了解开发设置、代码风格和 PR 流程。 -贡献者快速开始——使用标准安装器,然后在它创建的完整 git checkout 中开发: -`$HERMES_HOME/hermes-agent`(通常是 `~/.hermes/hermes-agent`)。这会匹配 -`hermes update`、托管 venv、lazy dependencies、gateway 和 docs tooling 使用的布局。 - -```bash -curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent" -uv pip install -e ".[all,dev]" -scripts/run_tests.sh -``` - -手动克隆备用路径(用于一次性 clone / CI,或你明确不想使用 managed install layout 时): - -```bash -curl -LsSf https://astral.sh/uv/install.sh | sh -uv venv venv --python 3.11 -source venv/bin/activate -uv pip install -e ".[all,dev]" -python -m pytest tests/ -q -``` +PM 引导、Python 3.14 测试环境和规范验证命令见 +[开发环境配置](CONTRIBUTING.md#development-setup)。 --- diff --git a/apps/desktop/BUILDING.md b/apps/desktop/BUILDING.md index aebcca4b43..aedb94c36f 100644 --- a/apps/desktop/BUILDING.md +++ b/apps/desktop/BUILDING.md @@ -1,143 +1,188 @@ # Building the Desktop Installers -This document tells you how the bundled desktop installers are built, and how -to build and test one on your machine. For the app architecture, read -`AGENTS.md` in this directory. +Use the complete bundle builder for release artifacts. Ordinary `dist:*` +commands package the current desktop build; they do not stage a fresh runtime. -## What a bundle is +## Artifact and update ownership -A bundled installer contains the full Hermes runtime. The user installs one -file and gets everything. Nothing downloads at first launch. - -The installer contains: - -- The Electron app (the chat surface). -- The agent source tree at the release tag, without `.git`. -- `uv` and a CPython interpreter for the target architecture. -- A ready `site-packages` tree, built from the lockfile. -- A Node runtime and the prebuilt JS surfaces (ui-tui, dashboard SPA). -- An install stamp (`install-stamp.json`) recording the tag, the commit, the - distribution, the update mechanism, and where the managed runtime dir - lives. - -The app runs the backend directly from its own resources. This is the -"bundled" install axis, built by `pm bundle` against the `pm/lock.json` pin -table — every managed tool (uv, python, node, npm, ripgrep, git, chromium, -cua-driver) is digest-pinned and staged at build time. - -## The installer for each platform - -| Platform | Artifact | Notes | +| Target | Artifact | Update owner | |---|---|---| -| Windows | MSIX `.msix` / `.msixbundle` | The shipping artifact. Signed with Azure Trusted Signing. Out-of-store installs update via the OS App Installer (.appinstaller source; the app checks + prompts, the OS applies). Store-submission builds (HERMES_DESKTOP_VARIANT=store) use the Partner Center identity and update via the Store. | -| macOS | `.dmg` | Signed and notarized when the `APPLE_*` / `CSC_*` secrets are set. Updated via electron-updater against the stable/canary macOS feed. | -| Linux | unpacked / AppImage | Unsigned. | +| Windows x64 / ARM64, sideload | Signed per-architecture MSIX packages, combined into a universal `.msixbundle`; `.appinstaller` descriptor | Windows App Installer | +| Windows x64 / ARM64, Store | Store-identity MSIX packages and a separate Store bundle | Microsoft Store | +| macOS ARM64 / x64 | Signed, notarized `Hermes.app` in DMG and ZIP artifacts | `electron-updater` with Squirrel.Mac | +| Linux x64 / ARM64 | Local builder can produce AppImage | External replacement; Linux desktop release legs are disabled | -NSIS is intentionally dead (D1 decision): Windows ships MSIX only. +The current MSIX manifest requires Windows 11 22H2 (`10.0.22621.0`). +The source-script Windows support range is separate from this package floor. +Windows desktop packaging uses MSIX, not NSIS or MSI. -## How the build works +`bundled` carries the local runtime. `store` carries the same runtime under +Partner Center's package identity. `light` is a remote-only client without +Python or a local agent. Light has build/feed support but no release matrix leg. -One script drives the whole build: +## Payload contents -``` -uv run --no-project --python 3.11 python scripts/bundles/desktop.py --tag=vX.Y.Z +The bundled payload contains: + +- The release's source snapshot without `.git`. +- Pinned CPython and uv, plus a ready Python dependency tree. +- Node.js, npm, supported managed tools, and prebuilt TUI/dashboard assets. +- A manifest, tool facts, the installed feature list, and build provenance. +- Generated CLI launchers from the archived project's script declarations. + +`pm/lock.json` owns managed-tool pins. `pyproject.toml` and `uv.lock` own Python +requirements. Native staging uses `--all-extras`, subject to platform markers. +This is broader than the source installer's extra named `all`. + +The backend runs from app resources. Launchers execute the store interpreter +with the source and dependency paths; they do not boot through a relocated +venv executable. First boot verifies payload facts without changing signed files. +It can create user-state records and CLI links. Provider calls, model downloads, +and optional integration setup can still use the network. + +Git is a platform exception: Windows stages Git for Windows with Bash. +POSIX targets use system Git. A Mac without Command Line Tools can therefore +need them for Git-dependent operations. + +User data remains outside the package. Optional additions use writable PM +storage and complete Python environment generations, not writes into the app. +See [Package management](../../website/docs/reference/package-management.md). + +## Current Python migration blocker + +The staging pin now selects Python 3.14, but +`electron/payload-backend.ts` still defaults POSIX dependency discovery to +`venv/lib/python3.11/site-packages`. No build path supplies its `PYTHON_VER` +override. This mismatch prevents normal bundled macOS/Linux backend resolution. +The native resolver must use the payload's actual Python version before a +3.14 bundle can satisfy startup and update acceptance. A successful staging +step does not resolve this blocker. + +## Complete native build + +From a checkout whose `HEAD` equals the release tag, run: + +```sh +uv run --no-project --python 3.14 python scripts/bundles/desktop.py --tag=vX.Y.Z ``` -The shared Python builder performs these steps: +Replace `vX.Y.Z` with an actual immutable tag. Stable tags must match the +version in `pyproject.toml`. Canary tags use the release script's tag grammar. +The host must provide Git, native-architecture Node/npm, and official uv 0.12+ +with a build triple in `uv --version`. Native dependency builds also need the +platform's compiler and libraries. -1. Validate the release tag and checkout identity. Check native Node architecture. -2. Install the locked JS workspace and validate its declared engine constraints. -3. Build the TUI and dashboard outputs. -4. Stage the native payload through PM, place JS assets, relocate links, and - generate launchers from the archived project's script declarations. -5. Build the Electron app and package MSIX, DMG/ZIP, or AppImage. +The builder: -Light omits the embedded runtime by definition. Its app is built and packaged -without staging the unused Python payload. The normal development loop remains -separate from release bundle assembly. +1. Checks the tag, checkout, Node architecture, and workspace engine constraints. +2. Installs the locked root JS workspace when its install stamp differs. +3. Builds the TUI and dashboard for variants with a payload. +4. Stages the PM payload, places JS assets, relocates links, and generates launchers. +5. Builds Electron and packages MSIX, DMG/ZIP, or AppImage for the current OS. -See [shared bundle builds](../../docs/shared-bundle-builds.md) for the modules -shared with Termux and the target-specific boundaries. +Use `--variant store` on Windows, or `--variant light` for the remote client. +Arguments after `--` go to Electron Builder. `electron-builder.config.cjs` +is the sole packaging configuration. The wrapper disables automatic publishing; +the release workflow owns uploads and channel promotion. -## Code signing (Windows) +`pm bundle --out DIR --ref REF` stages the native runtime only. +`scripts/bundles/stage.py` also generates its launchers. Neither command builds +the TUI/dashboard outputs or creates a signed installer by itself. +The launcher stage checks the payload and records its relative launch paths. +The Electron build bakes these paths into its stamp. Desktop startup does not +inspect, create, or repair a PM payload. Non-bundled builds carry no placeholder payload. +See [shared bundle builds](../../docs/shared-bundle-builds.md) for Termux reuse. -Signing turns on when the `AZURE_SIGN_*` environment variables are set: +## Windows signing and App Installer -``` -AZURE_SIGN_ENDPOINT https://cus.codesigning.azure.net -AZURE_SIGN_ACCOUNT codesign2 -AZURE_SIGN_PROFILE hermesagent -AZURE_SIGN_PUBLISHER CN=Nous Research Inc., ... -AZURE_CLIENT_ID (the OIDC app id) +The signing jobs provide `AZURE_SIGN_ENDPOINT`, `AZURE_SIGN_ACCOUNT`, +`AZURE_SIGN_PROFILE`, `AZURE_SIGN_PUBLISHER`, and `AZURE_CLIENT_ID`, plus +Azure authentication. Do not pass publisher names through shell-split `-c` +arguments. Local unsigned builds are not release-acceptance artifacts. + +The packaging hooks sanitize invalid PE certificate tables, then sign and +timestamp payload EXEs/DLLs. The product EXE receives its signature after +resource edits. `scripts/sign-msix.mjs` signs the sideload package envelope. +Store package envelopes remain unsigned for Partner Center to sign. + +Unchanged payload files reuse verified signatures from +`${ELECTRON_BUILDER_CACHE}-payload-signatures`. The key binds input bytes, +Azure policy, signing tools, and timestamp policy. Filenames and release +versions do not determine identity. A hit must match executable content and +pass Authenticode publisher/timestamp checks. Invalid entries become misses. +The product EXE and package envelopes still receive fresh signatures. + +The `before-build.mjs` hook generates MSIX extensions from payload launcher +names. One execution-alias extension carries the CLI aliases. The manifest +also registers the Copilot hardware-key provider. Its minimum Windows version +is part of the package contract. + +The release job combines only matching sideload packages from both architectures. +Store packages never enter the sideload bundle. It publishes bundle bytes before +the channel descriptor at `releases/win32/CHANNEL/CHANNEL.appinstaller`. +Light uses `releases/win32/light/CHANNEL/`. + +Windows records the descriptor source at installation. The app checks that +registered source through WinRT and distinguishes an unknown result from no +update. Apply downloads the descriptor before teardown, then opens the local +`.appinstaller` file. It does not require the disabled `ms-appinstaller:` protocol. +The app registers a detached relaunch waiter before handoff. + +The build stamp declares `updateMechanism`: `app-installer` for sideload bundles, +`external` for Store builds, `electron-updater` for macOS packages, and `self` +for source-built apps. The runtime does not infer Store ownership from Electron +flags or carry a second Store boolean. Windows Light declares `external` because +it has no bundled Python checker. Its OS-registered App Installer source still +owns automatic updates. + +Sideload stable versions are `X.Y.Z.0`. Canary revisions derive from elapsed +minutes after the stable baseline. The release script rejects ambiguous or +overflowing cuts. Store versions use `year.hour-of-year.second-of-hour.0` in UTC, +with the fourth component reserved for Microsoft. App semver and package +version are different facts. `scripts/msix-shared.mjs` owns these derivations. + +## macOS signing and updates + +`CSC_LINK` and `CSC_KEY_PASSWORD` supply the Developer ID identity. +The notarization hook accepts `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and +`APPLE_API_ISSUER`, or a keychain profile. CI materializes the API key from +`APPLE_API_KEY_P8`. + +The app and nested Mach-O binaries, including Chromium, must be signed before +notarization. The existing after-sign hook owns submission and stapling. +Publication checks signatures, the stapled ticket, and Gatekeeper assessment. +Unsigned local builds do not satisfy these gates. + +Both DMG and ZIP artifacts are required by the release pipeline. The ZIP is +the update artifact, not an optional duplicate of the download DMG. +See [macOS bundle updates](../../docs/macos-bundle-updates.md) for feed validation +and native-event ordering. + +## Development, assets, and verification + +Prepare and activate the [PM developer environment](../../website/docs/reference/package-management.md#developer-workflow) +first. Use Bash `source ./activate` or PowerShell `. .\activate.ps1`, and keep a +separate development home. Activation supplies the toolchain, not `node_modules`. + +From the repository root: + +```sh +npm ci +npm run dev --workspace apps/desktop ``` -`electron-builder.config.cjs` reads these variables and composes the -`win.sign` configuration itself. Do not pass the values as `-c` arguments: -the publisher name contains spaces, and spaces do not survive the cmd.exe -argument hop. `scripts/batch-sign-binaries.mjs` signs and timestamps payload -EXEs/DLLs after packing. The product EXE is signed after resource edits. -`scripts/sign-msix.mjs` signs the package, except Store packages that -Partner Center signs on ingestion. +For an ordinary package of that desktop build, use the workspace's +`dist:win`, `dist:mac`, `dist:linux`, or `pack` command. Those commands do not +replace the complete tagged build described above. -Unchanged payload files reuse signatures from -`${ELECTRON_BUILDER_CACHE}-payload-signatures`. The cache key combines the exact -pre-sign bytes with the Azure profile, publisher, signing tools and timestamp -policy. Paths, filenames and release versions do not affect the key. -Each hit must match the input's executable content and pass Windows -Authenticode verification with the expected publisher and a timestamp. -Invalid entries become misses. Only verified, signed and timestamped results -enter the cache. Product EXEs and package envelopes still receive fresh signatures. +Icons are generated from `assets/nous-girl-*.svg` and `assets/backgrounds/`. +`node scripts/generate-icons.mjs` uses the locked, isolated `icon-build` group. +Generated PNG/ICO/ICNS files are not source assets. Build-only renderers do not +belong in production payload dependencies. -Bundled and Store builds share the cache. CI restores the most recent snapshot -and saves additions under a new run key. Dispatch on the default branch to -share GitHub's cache scope across release tags. Delete the cache to force -fresh signatures. The signer logs hits, misses, duplicate copies and time. - -On win32, `scripts/after-pack.mjs` runs `sanitize-pe-signatures.mjs` before -the MSIX pack: python-build-standalone's `llvm-strip` can leave dangling PE -certificate tables, and one dangling table fails the whole package with -0x800700C1. This is a hard-fail step, not best-effort. - -## The MSIX manifest - -`assets/msix-manifest.xml` is the stock app-builder-lib template plus the -`uap5`/`desktop4` namespaces needed by the CLI execution aliases, and the -`uap3` fragment (declared on its own root) that registers the app as a -Windows Copilot hardware key provider. The extensions file -(`build/msix-extensions.xml`) is generated by the `before-build.mjs` hook. -Keep the manifest -in sync with app-builder-lib when electron-builder bumps. - -Store builds also stage `build/store-msix-manifest.xml` through that hook. -Its package version is `year.hour-of-year.second-of-hour.0` in UTC. The -canary tag timestamp supplies the time; stable tags use their Git creator -time (tagger time for annotated tags, commit time for lightweight tags). -This leaves the fourth component reserved for Microsoft and gives the -first component a nonzero value. Increasing release times increase package -versions, including stable releases after flights. Tags must be immutable -and release times must increase; rerunning a tag deliberately reuses its -package identity. App semver, artifact filenames, and sideload MSIX version -ordering are unchanged. The Store bundle envelope uses the same derivation. - -## Code signing (macOS) - -The existing after-sign hook owns notarization using `APPLE_API_KEY`, -`APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`, or a keychain profile. The workflow -writes the key from its `APPLE_API_KEY_P8` secret. The outer app and nested -Mach-O executables must be signed before publication. See -[macOS bundle updates](../../docs/macos-bundle-updates.md) for the feed contract -and publishing gates. - -## Local build - -``` -cd apps/desktop -npm install -npm run dist:win:msix # or dist:mac / dist:linux -``` - -For a fast dev loop without payload staging: - -``` -npm run dev # vite + electron against a dev backend -``` +[Stable release admission](../../docs/stable-releases.md) requires the full +pipeline, not just successful packaging. Native signed-package update tests +live in the [existing install/update family](../../tests/install/BUNDLED_UPDATES.md). +A helper test or unpacked-app smoke is not proof of native install, update, +or automatic relaunch. Historical receipts and current unresolved gates are +separate in [PM audit status](../../docs/pm-audit-status.md). diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 12ef67797b..e7e120215d 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -40,17 +40,20 @@ Prebuilt installers are built and distributed via [the Hermes Desktop website.]( ## Updating -The app checks for updates in the background and offers a one-click update when one is ready. You can also update any time from the CLI: +Update through the owner of the installed artifact: Windows App Installer for +sideload MSIX, Microsoft Store for Store packages, and `electron-updater` for +macOS bundles. Source-built apps use the checkout update handoff. -```bash -hermes update -``` +`hermes update` updates managed source checkouts; it does not rewrite a bundled +payload. See [BUILDING.md](BUILDING.md) for package and release contracts. --- ## Requirements -The installer handles everything for you (Python 3.11+, a portable Git, ripgrep). +Bundled packages provide Python 3.14 and their supported dependencies. +Source/bootstrap builds have a separate preparation path. Platform-native +requirements, including system Git on POSIX, are described in [BUILDING.md](BUILDING.md). --- @@ -68,7 +71,7 @@ Point the app at a specific source checkout, or sandbox it away from your real c ```bash # throwaway HERMES_HOME, separate Electron userData, distinct app name to avoid the single-instance lock -../scripts/dev-sandbox.sh npm run dev +../../scripts/dev-sandbox.sh npm run dev HERMES_DESKTOP_HERMES_ROOT=/path/to/clone npm run dev HERMES_HOME=/tmp/throwaway npm run dev npm run dev:fake-boot # exercise the startup overlay with deterministic delays @@ -83,14 +86,17 @@ npm run dist:linux # AppImage + deb + rpm npm run pack # unpacked app under release/ (no installer) ``` -Installers are built and uploaded to GitHub Releases manually. macOS/Windows signing & notarization happen automatically when the relevant credentials are present in the environment (`CSC_LINK` / `CSC_KEY_PASSWORD` / `APPLE_*` for macOS, `WIN_CSC_*` for Windows). +These are ordinary packaging commands, not complete tagged payload builds. +Use [BUILDING.md](BUILDING.md) for the native bundled builder, Azure/Apple +signing, R2 artifact publication, and release gates. The current release matrix +publishes Windows and macOS packages; Linux desktop legs are disabled. ### How it works -The packaged app ships the Electron shell and a native React chat surface. On -first launch it can install the Hermes Agent runtime into `HERMES_HOME` -(`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows), using the same layout as a -CLI install. +The bundled app carries the Electron shell, native React chat surface, and +local agent payload. It runs the payload directly from resources. User data +lives in `HERMES_HOME` outside the app. Bootstrap builds instead provision a +source installation; Light is a remote-only variant without a local runtime. The app has three boundaries: @@ -102,16 +108,13 @@ The app has three boundaries: `tui_gateway` JSON-RPC/WebSocket API. The renderer connects through [`apps/shared`](../shared/), which is also used by the browser dashboard. -Backend resolution is an ordered ladder: +A bundled artifact uses its payload. If that payload is unusable, the app +reports damage rather than installing a second checkout. It does not adopt +an arbitrary `hermes` command on PATH or a system Python installation. -1. `HERMES_DESKTOP_HERMES_ROOT` -2. the current source checkout during development -3. a completed managed install -4. `HERMES_DESKTOP_HERMES`, or `hermes` on `PATH` -5. a system Python that can import the Hermes runtime -6. the first-launch bootstrap installer - -Candidates are probed before use; an existing shim or interpreter is not enough. +Non-bundled builds can use the explicit source-root override, development +checkout, completed managed install, or `HERMES_DESKTOP_HERMES` deployment +override before offering bootstrap. Candidates are probed before use. A runtime that predates `serve` falls back to headless `dashboard --no-open`. This is compatibility for the backend command only and does not launch or embed the dashboard UI. diff --git a/apps/desktop/electron-builder.config.cjs b/apps/desktop/electron-builder.config.cjs index 05699dde2b..d473568e70 100644 --- a/apps/desktop/electron-builder.config.cjs +++ b/apps/desktop/electron-builder.config.cjs @@ -109,10 +109,9 @@ module.exports = { from: 'build/install-stamp.json', to: 'install-stamp.json' }, - { - from: 'build/agent-payload', - to: 'agent-payload' - }, + ...(['bundled', 'store'].includes(process.env.HERMES_DESKTOP_VARIANT || '') + ? [{ from: 'build/agent-payload', to: 'agent-payload' }] + : []), { from: 'assets/icon.ico', to: 'icon.ico' diff --git a/apps/desktop/electron/app-updater.ts b/apps/desktop/electron/app-updater.ts index 49f39e7e38..0587b0333a 100644 --- a/apps/desktop/electron/app-updater.ts +++ b/apps/desktop/electron/app-updater.ts @@ -7,9 +7,8 @@ // an update is available (via the bundled payload python's winrt), // show its own prompt, run graceful teardown, then trigger // the downloaded App Installer file and quit. Installations that Windows manages -// for us — Microsoft Store deployments (process.windowsStore) and -// stamps whose updateMechanism is 'external' — get NO in-app -// updater at all: the store/steward owns the update loop. +// for us declare updateMechanism 'external'. They get no in-app +// updater: the store or package manager owns the update loop. // // Source installs never reach this module. The callers gate on the install // stamp first and fall through to the git-based update path. diff --git a/apps/desktop/electron/bundle-swap.test.ts b/apps/desktop/electron/bundle-swap.test.ts index 8640b0657f..7e3fafaf3b 100644 --- a/apps/desktop/electron/bundle-swap.test.ts +++ b/apps/desktop/electron/bundle-swap.test.ts @@ -1,9 +1,33 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + import { describe, expect, it } from 'vitest' -import { detectBundleSwap } from './bundle-swap' +import { detectBundleSwap, readBundleSwapStamp } from './bundle-swap' const RUNNING = { builtAt: '2026-08-29T04:00:00.000Z', commit: 'a'.repeat(40), source: 'local' } +it('reads only the installed stamp for swap detection, without schema conversion', () => { + const resources = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-swap-')) + + try { + expect(readBundleSwapStamp(resources)).toBeNull() + + const file = path.join(resources, 'install-stamp.json') + + fs.writeFileSync(file, JSON.stringify(RUNNING)) + expect(readBundleSwapStamp(resources)).toEqual(RUNNING) + + const replaced = { ...RUNNING, commit: 'b'.repeat(40) } + + fs.writeFileSync(file, JSON.stringify(replaced)) + expect(detectBundleSwap(RUNNING, readBundleSwapStamp(resources))).toBe(true) + } finally { + fs.rmSync(resources, { recursive: true, force: true }) + } +}) + describe('detectBundleSwap', () => { it('reports a swap when the on-disk stamp carries a different commit', () => { const onDisk = { ...RUNNING, commit: 'b'.repeat(40) } @@ -40,10 +64,5 @@ describe('detectBundleSwap', () => { expect(detectBundleSwap(RUNNING, fallbackCommit)).toBe(false) }) - it('treats a missing builtAt on either side as unprovable at the same commit', () => { - const noBuiltAt = { commit: RUNNING.commit, source: 'local' } - expect(detectBundleSwap(noBuiltAt, { ...RUNNING })).toBe(false) - expect(detectBundleSwap(RUNNING, noBuiltAt)).toBe(false) - }) }) diff --git a/apps/desktop/electron/bundle-swap.ts b/apps/desktop/electron/bundle-swap.ts index 48f2b3af8a..42188046dc 100644 --- a/apps/desktop/electron/bundle-swap.ts +++ b/apps/desktop/electron/bundle-swap.ts @@ -27,16 +27,31 @@ * Pure so it is testable without booting Electron. */ +import fs from 'node:fs' +import path from 'node:path' + import { isFallbackCommit } from './bundle-skew' export interface BundleSwapStamp { /** write-build-stamp.mjs build timestamp — differs on every rebuild. */ - builtAt?: null | string - commit: string + builtAt: null | string + commit: string | null /** write-build-stamp.mjs source tag — 'fallback' means the commit is fake. */ source?: null | string } +/** Read the current installed artifact only to detect replacement by an update. + * This never chooses a runtime, normalizes a schema, or reads dev build output. + */ +export function readBundleSwapStamp(resourcesPath: string): BundleSwapStamp | null { + try { + return JSON.parse(fs.readFileSync(path.join(resourcesPath, 'install-stamp.json'), 'utf8')) + } catch { + // An unreadable replacement is not proof of a swap. + return null + } +} + /** True only on positive proof that the bundle on disk is not the running one. */ export function detectBundleSwap(running: BundleSwapStamp | null, onDisk: BundleSwapStamp | null): boolean { if (!running?.commit || !onDisk?.commit) { @@ -55,7 +70,5 @@ export function detectBundleSwap(running: BundleSwapStamp | null, onDisk: Bundle return true } - // Same commit: only a builtAt PRESENT ON BOTH sides can prove a rebuild — - // a missing timestamp (older stamp schema) proves nothing. - return Boolean(running.builtAt && onDisk.builtAt && running.builtAt !== onDisk.builtAt) + return running.builtAt !== onDisk.builtAt } diff --git a/apps/desktop/electron/first-run-setup-gate.test.ts b/apps/desktop/electron/first-run-setup-gate.test.ts index 94cd05bdc7..9122162068 100644 --- a/apps/desktop/electron/first-run-setup-gate.test.ts +++ b/apps/desktop/electron/first-run-setup-gate.test.ts @@ -23,6 +23,7 @@ test('first-run setup gate skips non-bootstrap backends', async () => { const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 }) await gate.wait({ kind: 'remote' }) + await gate.wait({ kind: 'python', local: 'bundled' }) await gate.wait(null) assert.deepEqual(prompts, []) @@ -131,44 +132,3 @@ test('remote apply without a waiter has no first-run side effects', async () => assert.equal(hidden, 0) assert.equal(gate.isLocalBootstrapConfirmed(), true) }) - -const bundledDamagedBackend = { - activeRoot: '/tmp/hermes-home/hermes-agent', - kind: 'bundled-unusable', - platform: 'win32', - local: 'bundled-damaged' -} as const - -test('the gate prompts on bundled-unusable (a damaged bundled payload)', async () => { - const prompts = [] - const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 }) - - const pending = gate.wait(bundledDamagedBackend) - - assert.equal(await settledState(pending), 'pending') - assert.equal(prompts.length, 1) - assert.equal(gate.hasWaiter(), true) -}) - -test('continueLocal settles bundled-unusable without any install side effect', async () => { - const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) - const pending = gate.wait(bundledDamagedBackend) - - gate.continueLocal() - - assert.equal(await pending, 'continue-local') - assert.equal(gate.hasWaiter(), false) - assert.equal(gate.isLocalBootstrapConfirmed(), true) -}) - -test('remote apply on bundled-unusable settles for a remote re-resolution', async () => { - let hidden = 0 - const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 }) - const pending = gate.wait(bundledDamagedBackend) - - const resumedWaiter = gate.abandonForRemoteApply() - - assert.equal(resumedWaiter, true) - assert.equal(hidden, 1) - assert.equal(await pending, 'remote-applied') -}) diff --git a/apps/desktop/electron/first-run-setup-gate.ts b/apps/desktop/electron/first-run-setup-gate.ts index a9402dfde7..6f4a4a5a6b 100644 --- a/apps/desktop/electron/first-run-setup-gate.ts +++ b/apps/desktop/electron/first-run-setup-gate.ts @@ -3,7 +3,7 @@ interface FirstRunSetupBackend { kind?: string platform?: string /** What the local setup card represents: 'none' = installer offer, the rest = use existing. */ - local?: 'none' | 'installed' | 'bundled' | 'bundled-damaged' + local?: 'none' | 'installed' | 'bundled' } interface FirstRunSetupGateOptions { @@ -59,13 +59,10 @@ export function createFirstRunSetupGate({ } } - // Both sentinels need a user decision: 'bootstrap-needed' offers install-or- - // connect; 'bundled-unusable' (a bundled install whose payload is damaged) - // offers connect-or-reinstall — never install. const shouldGate = (backend?: FirstRunSetupBackend | null) => Boolean( backend && - (backend.kind === 'bootstrap-needed' || backend.kind === 'bundled-unusable') && + backend.kind === 'bootstrap-needed' && !localBootstrapConfirmed ) diff --git a/apps/desktop/electron/first-run-setup-main-process.test.ts b/apps/desktop/electron/first-run-setup-main-process.test.ts index 6f724ed7c3..1dc1b920ef 100644 --- a/apps/desktop/electron/first-run-setup-main-process.test.ts +++ b/apps/desktop/electron/first-run-setup-main-process.test.ts @@ -117,108 +117,3 @@ test('a primary apply without an active first-run gate tears down before reconne assert.deepEqual(teardownPrimaryBackend.mock.calls, [[{ soft: true }]]) assert.deepEqual(order, ['clear-failure', 'teardown', 'notify']) }) - -const bundledDamagedBackend = { - activeRoot: '/tmp/hermes-home/hermes-agent', - kind: 'bundled-unusable', - platform: 'win32', - local: 'bundled-damaged' -} - -test('a bundled-unusable continueLocal never reaches an installer — the local step refuses instead', async () => { - const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) - - const runBootstrap = vi.fn() - - // Production ordering (electron/main.ts ensureRuntime): the - // bundled-unusable branch throws the reinstall error BEFORE any - // runBootstrap call. Model that contract — the installer must never fire - // for a bundled install, damaged or not. - const ensureLocalRuntime = vi.fn(async (backend: any) => { - assert.equal(backend.kind, 'bundled-unusable') - assert.equal(backend.local, 'bundled-damaged') - - throw new Error('This app bundles its own Hermes runtime, but the runtime files are missing or damaged. Reinstall Hermes Desktop to restore it.') - }) - - const pendingConnection = runPrimaryBackendStartup({ - connectRemote: vi.fn(), - ensureLocalRuntime, - prepareLocalBackend: vi.fn(async () => bundledDamagedBackend), - resolveRemote: vi.fn(async () => null), - waitForDecision: gate.wait, - waitForLocalStart: vi.fn(async () => {}) - }) - - await vi.waitFor(() => assert.equal(gate.hasWaiter(), true)) - - gate.continueLocal() - - await assert.rejects(pendingConnection, /Reinstall Hermes Desktop to restore it/) - assert.equal(ensureLocalRuntime.mock.calls.length, 1) - assert.equal(runBootstrap.mock.calls.length, 0) -}) - -test('a bundled-unusable remote apply connects without ensuring or bootstrapping locally', async () => { - const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) - - const candidateRemote = { - authMode: 'token', - baseUrl: 'https://gateway.example.com/hermes', - source: 'settings', - token: 'secret', - wsUrl: 'wss://gateway.example.com/hermes/api/ws?token=secret' - } - - let savedRemote: typeof candidateRemote | null = null - - const resolveRemote = vi.fn(async () => savedRemote) - const connectRemote = vi.fn(async remote => ({ ...remote, mode: 'remote' as const })) - const ensureLocalRuntime = vi.fn() - const teardownPrimaryBackend = vi.fn(async () => {}) - const cancelSshBootstrap = vi.fn(async () => {}) - const teardownSsh = vi.fn(async () => {}) - const clearLocalBootstrapFailure = vi.fn() - const notifyConnectionApplied = vi.fn() - - const pendingConnection = runPrimaryBackendStartup({ - connectRemote, - ensureLocalRuntime, - prepareLocalBackend: vi.fn(async () => bundledDamagedBackend), - resolveRemote, - waitForDecision: gate.wait, - waitForLocalStart: vi.fn(async () => {}) - }) - - await vi.waitFor(() => assert.equal(gate.hasWaiter(), true)) - - // Remote is the escape hatch on a damaged bundle: persist + re-home, same - // production ordering as the bootstrap-needed path. - savedRemote = candidateRemote - - await applyConnectionChange({ - cancelAndWait: cancelSshBootstrap, - isPrimary: true, - rehomePrimary: () => - rehomePrimaryConnection({ - clearLocalBootstrapFailure, - mode: 'remote', - notifyConnectionApplied, - resumeFirstRunRemote: gate.abandonForRemoteApply, - teardownPrimaryBackend - }), - scope: '', - sendApplied: notifyConnectionApplied, - stopPool: vi.fn(), - teardownPrimary: teardownPrimaryBackend, - teardownSsh - }) - - assert.deepEqual(await pendingConnection, { - kind: 'remote', - connection: { ...candidateRemote, mode: 'remote' } - }) - assert.deepEqual(resolveRemote.mock.calls, [[], []]) - assert.deepEqual(connectRemote.mock.calls, [[candidateRemote]]) - assert.equal(ensureLocalRuntime.mock.calls.length, 0) -}) diff --git a/apps/desktop/electron/install-stamp.test.ts b/apps/desktop/electron/install-stamp.test.ts index 3f437781c1..6cdc282a76 100644 --- a/apps/desktop/electron/install-stamp.test.ts +++ b/apps/desktop/electron/install-stamp.test.ts @@ -47,11 +47,5 @@ describe('installShape', () => { expect(installShape(null)).toBe('checkout') }) - test('the shape comes from the stamp alone — no probe can change it', () => { - // Same stamp, any filesystem state: the answer is a pure function of - // the constant. (The probes live INSIDE the chosen shape as integrity - // checks; see resolveHermesBackend.) - const bundled = stamp({ payload: 'bundled' }) - expect(installShape(bundled)).toBe(installShape(bundled)) - }) + }) diff --git a/apps/desktop/electron/install-stamp.ts b/apps/desktop/electron/install-stamp.ts index 5745c46fc1..988c604602 100644 --- a/apps/desktop/electron/install-stamp.ts +++ b/apps/desktop/electron/install-stamp.ts @@ -1,6 +1,6 @@ // install-stamp.ts — the typed build-time install stamp. // -// scripts/write_install_stamp.py writes build/install-stamp.json during +// scripts/write-build-stamp.mjs writes build/install-stamp.json during // `npm run build`. // bundle-electron-main.mjs bakes that file into the // production bundle by defining the __HERMES_INSTALL_STAMP__ global as @@ -20,7 +20,16 @@ */ export type ArtifactKind = 'bootstrap' | 'bundled' | 'light' -/** Mirrors the dict scripts/write_install_stamp.py::build_stamp returns. */ +/** Relative paths declared by the PM bundle builder, below agent-payload. */ +export interface PayloadRuntime { + repoDir: string + toolsDir: string + storePython: string + sitePackages: string + commands: Record +} + +/** Mirrors the build stamp with the PM builder's completed launch contract. */ export interface InstallStamp { schemaVersion: number commit: string | null @@ -33,18 +42,18 @@ export interface InstallStamp { /** The steward of a sealed tree ('desktop-app' | 'docker' | 'nix'), when packaged. */ distribution: string | null /** Who applies the next update. Required in every stamp. */ - updateMechanism: 'self' | 'electron-updater' | 'external' + updateMechanism: 'self' | 'app-installer' | 'electron-updater' | 'external' baseVersion: string | null displayVersion: string | null distance: number | null payload: ArtifactKind - /** True for the Store-submission build (HERMES_DESKTOP_VARIANT=store). */ - store?: boolean + /** Present on bundled artifacts. Validated at build time, never discovered at boot. */ + runtime?: PayloadRuntime /** The pinned release tag. Always set for 'bundled' and 'light', never for 'bootstrap'. */ tag: string | null } -declare const __HERMES_INSTALL_STAMP__: InstallStamp | undefined +declare const __HERMES_INSTALL_STAMP__: InstallStamp /** The baked stamp of this artifact, or null on dev bundles. */ export const INSTALL_STAMP: Readonly | null = @@ -61,9 +70,8 @@ export const INSTALL_STAMP: Readonly | null = * * Derived from the stamp CONSTANT, never from filesystem probes: a * payload/venv/marker probe answers "is this artifact intact?", not - * "which shape am I?" — those probes remain only as integrity checks - * inside an already-chosen shape (a bundled stamp with a damaged - * payload throws; it must not quietly become a checkout). Dev runs + * "which shape am I?". PM and the bundle builder own payload integrity. + * A backend launch failure must not quietly turn a bundle into a checkout. Dev runs * (null stamp) and bootstrap artifacts are 'checkout': their runtime * is a local install the app bootstraps and maintains. */ diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index a2cb52373b..df4a1eed63 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -91,7 +91,7 @@ import { buildBrowserWindowUrl } from './browser-windows' import { detectBundleSkew } from './bundle-skew' -import { detectBundleSwap } from './bundle-swap' +import { detectBundleSwap, readBundleSwapStamp } from './bundle-swap' import { applyConnectionChange, sshQuitShouldBlock, teardownSshState } from './connection-apply' import { apiRequestRegistryConnectionId, @@ -233,7 +233,7 @@ import { snapHudBounds } from './hud-snap' import { createHudSnapShortcut } from './hud-snap-shortcut' import { buildHudWindowUrl } from './hud-url' import { resolveHudWindowing } from './hud-windowing' -import { INSTALL_STAMP as BAKED_INSTALL_STAMP } from './install-stamp' +import { INSTALL_STAMP, installShape } from './install-stamp' import type { InstallStamp } from './install-stamp' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' @@ -278,7 +278,7 @@ import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { LEGACY_OAUTH_PARTITION, resolveOauthPartition } from './oauth-partition' import { listWindowsProcesses, reapPackageRootedProcesses } from './package-process-reap' import { createParentStartMarkerResolver, parentWatchdogEnv } from './parent-process-identity' -import { adoptPayloadVenv, installIdForRoot, isBundledInstall, type PayloadInfo, resolvePayload } from './payload-backend' +import { bundledPayload, installIdForRoot, type PayloadInfo } from './payload-backend' import { registerPetOverlayIpc } from './pet-overlay-ipc' import { pendingNotice as pendingPluginCompatNotice, @@ -695,75 +695,7 @@ app.commandLine.appendSwitch('disable-renderer-backgrounding') const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') -// Build-time install stamp -- the git ref this .exe was built against. -// -// Written by apps/desktop/scripts/write-build-stamp.mjs during `npm run build` -// and bundled into packaged apps via electron-builder's extraResources entry, -// so the runtime stamp ends up at process.resourcesPath/install-stamp.json -// after install. The bootstrap runner (Phase 1D) reads it to know which -// commit to clone when running install.ps1 stages at first launch. -// -// Returns null when the file is missing (dev runs from a checkout where -// build hasn't been invoked, or schema mismatch). Callers must handle null. -// -// Schema: -// { schemaVersion: 1, commit, branch, builtAt, dirty, source } -const INSTALL_STAMP_SCHEMA_VERSION = 1 - -function loadInstallStamp() { - // Try packaged location first (resources/install-stamp.json), then the - // dev/local build output (apps/desktop/build/install-stamp.json) so - // someone running `npm run start` after a local `npm run build` also - // sees a stamp without needing a packaged build. - const candidates = [ - process.resourcesPath ? path.join(process.resourcesPath, 'install-stamp.json') : null, - path.join(APP_ROOT, 'build', 'install-stamp.json') - ].filter(Boolean) - - for (const p of candidates) { - try { - const raw = fs.readFileSync(p, 'utf8') - const parsed = JSON.parse(raw) - - if (parsed && typeof parsed === 'object' && typeof parsed.commit === 'string' && parsed.commit.length >= 7) { - if (parsed.schemaVersion !== INSTALL_STAMP_SCHEMA_VERSION) { - console.warn( - `[hermes] install-stamp.json schemaVersion ${parsed.schemaVersion} != expected ${INSTALL_STAMP_SCHEMA_VERSION}; ignoring` - ) - - continue - } - - return Object.freeze({ - schemaVersion: parsed.schemaVersion, - commit: parsed.commit, - branch: parsed.branch || null, - builtAt: parsed.builtAt || null, - dirty: Boolean(parsed.dirty), - source: parsed.source || null, - path: p, - // Bundled/light artifacts carry these; mirror them so the union - // with the baked stamp stays typed (tag/payload drive the App - // Installer channel + variant; store separates Microsoft Store - // deployments from App Installer sideloads). - tag: typeof parsed.tag === 'string' ? parsed.tag : null, - payload: parsed.payload === 'light' || parsed.payload === 'bundled' ? parsed.payload : 'bootstrap', - store: typeof parsed.store === 'boolean' ? parsed.store : undefined - }) - } - } catch (e) { - console.warn(`[hermes] install-stamp.json found at ${p} , but parsing failed with ${e}`) - // Either ENOENT or malformed JSON; try the next candidate - } - } - - return null -} - -// The baked build-time constant (production bundles) wins; loadInstallStamp() -// remains as the dev/extraResources fallback when nothing was baked. -const INSTALL_STAMP = BAKED_INSTALL_STAMP ?? loadInstallStamp() - +// Runtime identity comes only from the baked artifact stamp. Dev runs have none. if (INSTALL_STAMP) { console.log( `[hermes] install stamp: ${INSTALL_STAMP.commit ? INSTALL_STAMP.commit.slice(0, 12) : 'no-commit'}${INSTALL_STAMP.branch ? ` (${INSTALL_STAMP.branch})` : ''}${INSTALL_STAMP.dirty ? ' [DIRTY]' : ''} from ${INSTALL_STAMP.source || 'unknown'}` @@ -2123,7 +2055,7 @@ function promptFirstRunSetupChoice(backend) { platform: backend.platform || process.platform, activeRoot: backend.activeRoot || ACTIVE_HERMES_ROOT, local: backend.local || 'none', - bundled: isBundledInstall(process.resourcesPath, { fileExists }) + bundled: installShape() === 'bundled' }) } @@ -2329,7 +2261,7 @@ function relaunchIntoSwappedBundle() { return false } - if (!detectBundleSwap(INSTALL_STAMP, loadInstallStamp())) { + if (!detectBundleSwap(INSTALL_STAMP, readBundleSwapStamp(process.resourcesPath))) { return false } @@ -3209,11 +3141,8 @@ let packagedUpdateStrategy: UpdaterStrategy | undefined function resolvePackagedUpdateStrategy(): UpdaterStrategy | null { const mechanism = resolveUpdaterMechanism({ - isPackaged: IS_PACKAGED, platform: process.platform, - payload: INSTALL_STAMP?.payload, - updateMechanism: BAKED_INSTALL_STAMP?.updateMechanism, - isWindowsStore: isWindowsStore() + updateMechanism: INSTALL_STAMP?.updateMechanism }) if (mechanism === 'windows-handoff' || mechanism === 'posix-handoff') { return null } @@ -3243,15 +3172,17 @@ function resolvePackagedUpdateStrategy(): UpdaterStrategy | null { } if (mechanism === 'app-installer') { - const bundledPayload = resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS }) + const payload = bundledPayload(process.resourcesPath)! - if (!bundledPayload) { return new ExternalStrategy() } packagedUpdateStrategy = new AppInstallerStrategy({ - python: bundledPayload.storePython, + python: payload.storePython, // The checker ships inside the payload's repo snapshot (git archive of // the committed tree): //apps/desktop/scripts/. - script: path.join(bundledPayload.repoDir, 'apps', 'desktop', 'scripts', 'check-appinstaller-update.py'), - run: runPayloadPython, + script: path.join(payload.repoDir, 'apps', 'desktop', 'scripts', 'check-appinstaller-update.py'), + run: (python, script) => runAppInstallerChecker(python, script, { + env: { ...process.env, PYTHONPATH: payload.sitePackages }, + onStderr: stderr => console.error(`[app-installer] checker stderr: ${stderr.slice(0, 400)}`) + }), channel: resolveUpdaterChannelFromStamp(), light: isLightVariant(), feedBaseUrl: resolveDesktopFeedBaseUrl(), @@ -3279,7 +3210,7 @@ function resolvePackagedUpdateStrategy(): UpdaterStrategy | null { processStartTimeMs: Math.round(Date.now() - process.uptime() * 1000), identityName: PRODUCT_IDENTITY.msixAppIdWithOrg, scriptPath: path.join( - bundledPayload.repoDir, + payload.repoDir, 'apps', 'desktop', 'scripts', @@ -3337,21 +3268,6 @@ function resolveCheckoutUpdateStrategy(): UpdaterStrategy { }) } -/** True when this process is a Microsoft Store deployment. */ -function isWindowsStore(): boolean { - // process.windowsStore is true for ANY MSIX package — App Installer - // sideloads included — not just Microsoft Store deployments (electron.d.ts: - // "If the app is running as an MSIX package ... this property is true"). - // The store-vs-sideload distinction is a BUILD-TIME fact baked into the - // stamp (HERMES_DESKTOP_VARIANT=store vs bundled); trust it when present - // and fall back to the Electron flag only for dev runs / legacy stamps. - if (INSTALL_STAMP && typeof INSTALL_STAMP.store === 'boolean') { - return INSTALL_STAMP.store - } - - return Boolean((process as any).windowsStore) -} - /** * The App Installer feed base URL for a bundled MSIX install: config.yaml's * `updates.desktop_feed_base_url`, then HERMES_DESKTOP_FEED_BASE_URL, then @@ -3396,28 +3312,6 @@ function isLightVariant(): boolean { return INSTALL_STAMP?.payload === 'light' } -/** - * Run a bundled payload python script (the App Installer update checker). - * The payload python needs the payload venv's site-packages on PYTHONPATH to - * import the winrt module — the same way the bundled CLI launcher sets it. - * Bounded via runAppInstallerChecker: a wedged child is killed at the - * deadline and reported as an unknown, and the deadline resolves even if the - * child never emits close (see appinstaller-checker.ts). - */ -async function runPayloadPython(python: string, script: string): Promise<{ code: number; stdout: string }> { - const payload = resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS }) - const env = { ...process.env } - - if (payload?.sitePackages) { - env.PYTHONPATH = payload.sitePackages - } - - return runAppInstallerChecker(python, script, { - env, - onStderr: stderr => console.error(`[app-installer] checker stderr: ${stderr.slice(0, 400)}`) - }) -} - /** * Graceful teardown before the OS App Installer swaps the package: stop the * primary backend + all pool backends (tree-kill their children) so no @@ -4045,7 +3939,7 @@ async function handOffWindowsBootstrapRecovery(reason) { // runtime; recovery means reinstalling the app, not spawning the // updater. (ensureRuntime's bundled guard also short-circuits before // this call; this is the belt-and-suspenders check.) - if (isBundledInstall(process.resourcesPath, { fileExists })) { + if (installShape() === 'bundled') { rememberLog('[bootstrap] refusing updater recovery hand-off on a bundled install; reinstall the app') return false @@ -4656,7 +4550,7 @@ function createActiveBackend(backendArgs) { * NOT get this: the AppExecutionAlias is the mechanism there. */ function provisionPosixCliOnPath(payload: PayloadInfo): void { - const names = ['hermes', 'hermes-agent', 'hermes-acp'] + const names = Object.keys(payload.commands) try { const binDir = path.join(os.homedir(), '.local', 'bin') @@ -4664,7 +4558,7 @@ function provisionPosixCliOnPath(payload: PayloadInfo): void { let linked = 0 for (const name of names) { - const source = path.join(payload.root, 'bin', name) + const source = payload.commands[name] const target = path.join(binDir, name) // lstat, not exists: a DANGLING symlink from a previous install (the @@ -4686,44 +4580,19 @@ function provisionPosixCliOnPath(payload: PayloadInfo): void { } function resolveHermesBackend(backendArgs) { - // 0. Shipped payload — a bundled install carries the whole runtime under - // resources/agent-payload (staged by `hermes pm bundle`). The venv's - // interpreter self-locates: adoptPayloadVenv() verifies the store - // python + site-packages resolve (no pyvenv.cfg write — read-only - // MSIX-safe), and HERMES_RUNTIME_DIR aims pm at the payload store. - // Nothing installs on the user machine. - const payload = resolvePayload(process.resourcesPath, { - fileExists, - directoryExists, - isWindows: IS_WINDOWS - }) + const payload = bundledPayload(process.resourcesPath) - if (payload && adoptPayloadVenv(payload, { isWindows: IS_WINDOWS, log: rememberLog })) { - if (bootstrapRepairRequested) { - // A bundled payload is immutable — repair means "reinstall the app". - rememberLog('[payload] repair requested on a bundled install; the payload is read-only — ignoring') - } - - // POSIX-only, silent best-effort: symlink the CLI trampolines out to - // ~/.local/bin (the ExecutionAlias covers this on Windows). + if (payload) { if (!IS_WINDOWS) { provisionPosixCliOnPath(payload) } - // Bundled builds run the STORE python via the self-relative CLI - // launcher (bin/hermes.exe on win32, bin/hermes on POSIX — works on - // read-only MSIX, no pyvenv.cfg write). The launcher sets PYTHONPATH - // to the payload's repo + venv site-packages itself, so the backend - // needs only the managed-tools dir from us. return { kind: 'python', label: `bundled payload at ${payload.root}`, command: payload.shim, args: [...backendArgs], - env: { - ...buildDesktopBackendEnv(), - HERMES_RUNTIME_DIR: payload.toolsDir - }, + env: { ...buildDesktopBackendEnv(), HERMES_RUNTIME_DIR: payload.toolsDir }, root: payload.repoDir, bootstrap: false, shell: false, @@ -4731,37 +4600,6 @@ function resolveHermesBackend(backendArgs) { } } - // A bundled artifact that failed to resolve must NEVER fall through the - // ladder to bootstrap-needed: the payload IS the local Hermes, it is - // immutable (sealed at build time, often read-only MSIX), and running - // install.ps1 would download and install a SECOND, separate Hermes into - // the user's machine on top of one the app already carries. Skip ALL - // remaining local rungs — explicit dev overrides included; a developer - // who wants a checkout should run a non-bundled build - // (HERMES_DESKTOP_VARIANT unset → external stub → not bundled). The - // payload-healthy path above returns before this guard, so a working - // bundle is unaffected. - if (isBundledInstall(process.resourcesPath, { fileExists })) { - rememberLog( - '[bootstrap] bundled payload missing or damaged; REFUSING to run the installer — reinstall the app to restore the runtime' - ) - - return { - kind: 'bundled-unusable', - label: 'The Hermes runtime bundled with this app is missing or damaged; reinstall the app', - command: null, - args: backendArgs, - bootstrap: false, - env: {}, - shell: false, - activeRoot: ACTIVE_HERMES_ROOT, - installStamp: INSTALL_STAMP, - isPackaged: IS_PACKAGED, - platform: process.platform, - local: 'bundled-damaged' - } - } - // 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer // checkout. Honour it as-is (no bootstrap; the user is driving). const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT) @@ -4904,11 +4742,8 @@ async function ensureRuntime(backend) { // will rewire startup to spawn the window first and route bootstrap events // to a renderer-side install overlay. // - // Defense in depth: a bundled artifact must NEVER reach this branch. The - // resolver's bundled-unusable sentinel is the primary guard; this check - // covers any future path that produces bootstrap-needed on a bundled - // install (e.g. a stale repair flag racing the resolver). - if (backend.kind === 'bootstrap-needed' && isBundledInstall(process.resourcesPath, { fileExists })) { + // The artifact kind forbids bootstrap even when a caller requests repair. + if (backend.kind === 'bootstrap-needed' && installShape() === 'bundled') { rememberLog('[bootstrap] REFUSING installer on a bundled install; payload missing or damaged — reinstall the app') const bundledError: Error & { isBootstrapFailure?: boolean } = new Error( @@ -4920,22 +4755,6 @@ async function ensureRuntime(backend) { throw bundledError } - // A bundled install whose payload failed to resolve. The payload is the - // ONLY local runtime a bundle has (immutable, sealed at build time), so - // there is nothing to install or repair here — reinstall the app. The - // setup gate still lets the user connect to a REMOTE Hermes. - if (backend.kind === 'bundled-unusable') { - rememberLog('[bootstrap] bundled payload is unusable; no installer path exists — reinstall the app') - - const bundledError: Error & { isBootstrapFailure?: boolean } = new Error( - 'This app bundles its own Hermes runtime, but the runtime files are missing or damaged. Reinstall Hermes Desktop to restore it.' - ) - - bundledError.isBootstrapFailure = true - bootstrapFailure = bundledError - throw bundledError - } - if (backend.kind === 'bootstrap-needed') { rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap') @@ -12553,7 +12372,7 @@ function reapInstallRootedStragglers(excludePids: number[]): void { return } - const payloadRoot = isBundledInstall(process.resourcesPath, { fileExists }) ? process.resourcesPath : null + const payloadRoot = installShape() === 'bundled' ? process.resourcesPath : null try { reapPackageRootedProcesses({ @@ -14982,7 +14801,7 @@ ipcMain.handle('hermes:bootstrap:repair', async () => { // %LOCALAPPDATA%\hermes tree the app doesn't own. The only repair for a // damaged bundle is reinstalling the app itself. Refuse without touching // bootstrapRepairRequested so a stale renderer can't drive an install. - if (isBundledInstall(process.resourcesPath, { fileExists })) { + if (installShape() === 'bundled') { rememberLog('[bootstrap] repair refused on a bundled install; repair means reinstalling the app') return { ok: false, error: 'bundled-immutable' } @@ -17532,7 +17351,7 @@ ipcMain.handle('hermes:version', async () => { // Packaged only: a dev `--build-only` rewrites build/install-stamp.json // under a running `npm start`, which is a rebuild the developer asked for, // not a torn install to offer a restart for. - bundleSwapPending: IS_PACKAGED && detectBundleSwap(INSTALL_STAMP, loadInstallStamp()) + bundleSwapPending: IS_PACKAGED && detectBundleSwap(INSTALL_STAMP, readBundleSwapStamp(process.resourcesPath)) } }) diff --git a/apps/desktop/electron/payload-backend.test.ts b/apps/desktop/electron/payload-backend.test.ts index 5783a96671..d675991894 100644 --- a/apps/desktop/electron/payload-backend.test.ts +++ b/apps/desktop/electron/payload-backend.test.ts @@ -1,182 +1,52 @@ import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import fs from 'node:fs' -import os from 'node:os' import path from 'node:path' -import { test } from 'vitest' +import { test, vi } from 'vitest' -import { adoptPayloadVenv, installIdForRoot, isBundledInstall, resolvePayload } from './payload-backend' +import type { InstallStamp, PayloadRuntime } from './install-stamp' +import { bundledPayload, installIdForRoot } from './payload-backend' -function tmpdir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'payload-test-')) -} - -function writePayload( - root: string, - { - manifest = { schema: 1, target: 'win32-x64', repo: 'hermes-agent', venv: 'venv', store: 'tools' }, - isWindows = true - }: any = {} -) { - const dir = path.join(root, 'agent-payload') - - fs.mkdirSync(path.join(dir, 'hermes-agent'), { recursive: true }) - fs.mkdirSync(path.join(dir, 'tools', 'python-3.11.16-win32-x64'), { recursive: true }) - - // Store python (win: python.exe at the entry root; posix: bin/python3). - if (isWindows) { - fs.writeFileSync(path.join(dir, 'tools', 'python-3.11.16-win32-x64', 'python.exe'), '') - } else { - fs.mkdirSync(path.join(dir, 'tools', 'python-3.11.16-win32-x64', 'bin'), { recursive: true }) - fs.writeFileSync(path.join(dir, 'tools', 'python-3.11.16-win32-x64', 'bin', 'python3'), '') - } - - // Venv site-packages (where the project deps are installed). - const sp = isWindows - ? path.join(dir, 'venv', 'Lib', 'site-packages') - : path.join(dir, 'venv', 'lib', 'python3.11', 'site-packages') - - fs.mkdirSync(sp, { recursive: true }) - - // The self-relative CLI entrypoint (minted launcher exe on win32, bash - // trampoline on POSIX) — the bundled entry point. - const binDir = path.join(dir, 'bin') - fs.mkdirSync(binDir, { recursive: true }) - fs.writeFileSync(path.join(binDir, isWindows ? 'hermes.exe' : 'hermes'), '') - - fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest)) - fs.writeFileSync( - path.join(dir, 'tools', 'facts.json'), - JSON.stringify({ packages: { python: { entry: 'python-3.11.16-win32-x64', version: '3.11.16' } } }) - ) - - return dir -} - -const fsDeps = { - fileExists: (p: string) => { - try { - return fs.statSync(p).isFile() - } catch { - return false - } - }, - directoryExists: (p: string) => { - try { - return fs.statSync(p).isDirectory() - } catch { - return false - } +function stamp(runtime: PayloadRuntime, payload: InstallStamp['payload'] = 'bundled'): InstallStamp { + return { + schemaVersion: 1, commit: 'a'.repeat(40), commitDate: null, branch: null, + builtAt: null, dirty: false, source: 'ci', distribution: 'desktop-app', + updateMechanism: 'external', baseVersion: null, displayVersion: null, + distance: null, payload, runtime, tag: 'v1.0.0' } } -test('resolvePayload finds a complete payload (store python + venv site-packages)', () => { - const root = tmpdir() +test('bundled launch paths come from build metadata without filesystem access', () => { + const runtime: PayloadRuntime = { + repoDir: 'source-tree', toolsDir: 'binary-store', + storePython: 'binary-store/custom-python/python', + sitePackages: 'dependencies/lib/python3.14/site-packages', + commands: { hermes: 'commands/run-hermes', custom: 'commands/custom' } + } - writePayload(root) + const probe = vi.spyOn(fs, 'existsSync').mockImplementation(() => { throw new Error('runtime probe') }) + const read = vi.spyOn(fs, 'readFileSync').mockImplementation(() => { throw new Error('runtime read') }) - const payload = resolvePayload(root, { ...fsDeps, isWindows: true }) + try { + for (const resources of ['/not-created/resources', '/relocated app/resources']) { + const result = bundledPayload(resources, stamp(runtime)) + assert.ok(result) + assert.equal(result.storePython, path.join(resources, 'agent-payload', runtime.storePython)) + assert.equal(result.sitePackages, path.join(resources, 'agent-payload', runtime.sitePackages)) + assert.equal(result.shim, result.commands.hermes) + assert.equal(result.commands.custom, path.join(resources, 'agent-payload', runtime.commands.custom)) + } - assert.ok(payload) - assert.equal(payload.repoDir, path.join(root, 'agent-payload', 'hermes-agent')) - assert.ok(payload.storePython.endsWith(path.join('tools', 'python-3.11.16-win32-x64', 'python.exe'))) - assert.ok(payload.sitePackages.endsWith(path.join('venv', 'Lib', 'site-packages'))) - assert.ok(payload.shim.endsWith(path.join('bin', 'hermes.exe'))) -}) - -test('resolvePayload resolves the posix store python + site-packages layout', () => { - const root = tmpdir() - - writePayload(root, { isWindows: false }) - - const payload = resolvePayload(root, { ...fsDeps, isWindows: false }) - - assert.ok(payload) - assert.ok(payload.storePython.endsWith(path.join('tools', 'python-3.11.16-win32-x64', 'bin', 'python3'))) - assert.ok(payload.sitePackages.endsWith(path.join('venv', 'lib', 'python3.11', 'site-packages'))) - assert.ok(payload.shim.endsWith(path.join('bin', 'hermes'))) -}) - -test('resolvePayload returns null without a manifest, for external stubs, and for a broken payload', () => { - assert.equal(resolvePayload(tmpdir(), { ...fsDeps, isWindows: true }), null) - assert.equal(resolvePayload(undefined, { ...fsDeps, isWindows: true }), null) - - const externalRoot = tmpdir() - - writePayload(externalRoot, { manifest: { schema: 1, external: true } }) - assert.equal(resolvePayload(externalRoot, { ...fsDeps, isWindows: true }), null) - - const brokenRoot = tmpdir() - const dir = writePayload(brokenRoot) - - fs.rmSync(path.join(dir, 'venv'), { recursive: true }) - assert.equal(resolvePayload(brokenRoot, { ...fsDeps, isWindows: true }), null) -}) - -test('adoptPayloadVenv verifies store python + site-packages without any cfg write', () => { - const root = tmpdir() - const dir = writePayload(root) - const payload = resolvePayload(root, { ...fsDeps, isWindows: true }) - - assert.ok(payload) - // Bundled builds run the store python directly — there is NO pyvenv.cfg - // write (the payload may be read-only, e.g. MSIX). The cfg, if present, - // is left untouched. - fs.writeFileSync(path.join(dir, 'venv', 'pyvenv.cfg'), 'home = C:\\ci\\build\\python\nversion_info = 3.11.16\n') - assert.equal(adoptPayloadVenv(payload, { isWindows: true }), true) - const text = fs.readFileSync(path.join(dir, 'venv', 'pyvenv.cfg'), 'utf8') - assert.ok(text.includes('C:\\ci\\build'), 'the shipped cfg is not rewritten') - - // second call: still reports usable - assert.equal(adoptPayloadVenv(payload, { isWindows: true }), true) -}) - -// adoptPayloadVenv is now a pure presence-verify; resolvePayload's own -// existence checks already reject a payload missing the store python or -// site-packages (covered above), so there is no separate fail-closed path -// to assert at this layer. - -test('isBundledInstall is true for a real payload manifest', () => { - const root = tmpdir() - - writePayload(root) - - assert.equal(isBundledInstall(root, fsDeps), true) -}) - -test('isBundledInstall is false for the external stub and for missing manifests', () => { - const externalRoot = tmpdir() - - writePayload(externalRoot, { manifest: { schema: 1, external: true } }) - assert.equal(isBundledInstall(externalRoot, fsDeps), false) - - assert.equal(isBundledInstall(tmpdir(), fsDeps), false) - assert.equal(isBundledInstall(undefined, fsDeps), false) -}) - -test('isBundledInstall is true even when the payload is damaged (never-install guard)', () => { - const root = tmpdir() - const dir = writePayload(root) - - // A broken payload must still read as bundled: isBundledInstall is the - // guard that REFUSES the installer, and a damaged bundle is exactly the - // case where the installer must not run. - fs.rmSync(path.join(dir, 'venv'), { recursive: true }) - fs.rmSync(path.join(dir, 'tools', 'python-3.11.16-win32-x64'), { recursive: true }) - - assert.equal(resolvePayload(root, { ...fsDeps, isWindows: true }), null) - assert.equal(isBundledInstall(root, fsDeps), true) -}) - -test('isBundledInstall is false for a malformed manifest', () => { - const root = tmpdir() - const dir = path.join(root, 'agent-payload') - - fs.mkdirSync(dir, { recursive: true }) - fs.writeFileSync(path.join(dir, 'manifest.json'), 'not json {') - - assert.equal(isBundledInstall(root, fsDeps), false) + assert.equal(probe.mock.calls.length, 0) + assert.equal(read.mock.calls.length, 0) + assert.equal(bundledPayload('/resources', stamp(runtime, 'light')), null) + assert.equal(bundledPayload('/resources', stamp(runtime, 'bootstrap')), null) + assert.equal(bundledPayload('/resources', null), null) + } finally { + probe.mockRestore() + read.mockRestore() + } }) // ─── update channel helpers ───────────────────────────────────────── diff --git a/apps/desktop/electron/payload-backend.ts b/apps/desktop/electron/payload-backend.ts index b12d3a5f63..5936e58c77 100644 --- a/apps/desktop/electron/payload-backend.ts +++ b/apps/desktop/electron/payload-backend.ts @@ -1,157 +1,40 @@ -/** - * payload-backend.ts - * - * The bundled-install backend: a pm payload staged by `hermes pm bundle` - * and shipped under resources/agent-payload. The payload carries the repo - * snapshot, the tool store (with facts.json), and a relocatable venv - * built on the staged python-build-standalone interpreter. - * - * Electron's whole job here is finding the interpreter and verifying the - * payload can boot. Bundled builds run the store python directly — - * self-relative, no pyvenv.cfg write, so read-only installs (MSIX) work. - * Everything else — managed tool PATHs, env composition — happens - * in-process via pm when the backend runs. - */ - +/** Electron consumes the launch contract completed by the PM bundle builder. */ import { createHash } from 'node:crypto' -import fs from 'node:fs' import path from 'node:path' -export interface PayloadInfo { +import { INSTALL_STAMP, type InstallStamp, type PayloadRuntime } from './install-stamp' + +export interface PayloadInfo extends PayloadRuntime { root: string - repoDir: string - toolsDir: string - /** The payload's own CPython (tools//python(.exe)). */ - storePython: string - /** The venv's site-packages (Lib/site-packages on win, lib/python3.11/site-packages on posix). */ - sitePackages: string - /** The self-relative CLI trampoline (bin/hermes(.exe)) — the bundled entry point. */ shim: string } -export function resolvePayload( - resourcesPath: string | undefined, - deps: { - fileExists: (p: string) => boolean - directoryExists: (p: string) => boolean - isWindows: boolean - } +export function bundledPayload( + resourcesPath: string, + stamp: Readonly | null = INSTALL_STAMP ): PayloadInfo | null { - if (!resourcesPath) { + if (stamp?.payload !== 'bundled') { return null } + // The builder validates these paths before baking the stamp. There is no + // discovery, filesystem validation or alternative payload at runtime. + const runtime = stamp.runtime! const root = path.join(resourcesPath, 'agent-payload') - const manifestPath = path.join(root, 'manifest.json') - if (!deps.fileExists(manifestPath)) { - return null + const commands = Object.fromEntries( + Object.entries(runtime.commands).map(([name, relative]) => [name, path.join(root, relative)]) + ) + + return { + root, + repoDir: path.join(root, runtime.repoDir), + toolsDir: path.join(root, runtime.toolsDir), + storePython: path.join(root, runtime.storePython), + sitePackages: path.join(root, runtime.sitePackages), + commands, + shim: commands.hermes } - - let manifest: any - - try { - manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) - } catch { - return null - } - - if (!manifest || manifest.external === true) { - return null - } - - // The manifest names the payload layout; a bundle that omits the fields - // is malformed — refuse it rather than guess at cmd_bundle's layout. - if (typeof manifest.repo !== 'string' || typeof manifest.store !== 'string' || typeof manifest.venv !== 'string') { - return null - } - - const repoDir = path.join(root, manifest.repo) - const toolsDir = path.join(root, manifest.store) - const venvDir = path.join(root, manifest.venv) - // The CLI trampoline staged into bin/ (hermes/hermes-agent/hermes-acp — - // scripts/bundles/desktop.py 5b: distlib-minted launchers on win32, - // $0-relative bash trampolines on POSIX). It execs the store python with - // the payload's own PYTHONPATH, so it is the single bundled entry point. - const shim = path.join(root, 'bin', deps.isWindows ? 'hermes.exe' : 'hermes') - - // The store CPython + the venv's site-packages. Bundled builds run the - // STORE python (self-relative, no pyvenv.cfg write — works on read-only - // MSIX) with PYTHONPATH pointing at the venv site-packages (where the - // project deps are installed). The venv python itself is NOT used in - // bundled builds. - let storePython = '' - let sitePackages = '' - - try { - const facts = JSON.parse(fs.readFileSync(path.join(toolsDir, 'facts.json'), 'utf8')) - const entry = facts?.packages?.python?.entry - - if (typeof entry === 'string') { - storePython = path.join(toolsDir, entry, deps.isWindows ? 'python.exe' : 'bin', deps.isWindows ? '' : 'python3') - sitePackages = deps.isWindows - ? path.join(venvDir, 'Lib', 'site-packages') - : path.join(venvDir, 'lib', `python${process.env.PYTHON_VER || '3.11'}`, 'site-packages') - } - } catch { - // fall through to the existence checks below - } - - if (!deps.directoryExists(repoDir) || !deps.fileExists(storePython) || !deps.directoryExists(sitePackages) || !deps.fileExists(shim)) { - return null - } - - return { root, repoDir, toolsDir, storePython, sitePackages, shim } -} - -/** - * "Is this artifact a bundled install?" — the app ships its own Hermes payload. - * True whenever resources/agent-payload/manifest.json exists and is not the - * external stub (before-build.mjs writes {schema:1, external:true} for - * non-bundled builds). Deliberately does NOT verify payload usability: a - * damaged bundle still must never install — callers use this to refuse the - * installer, not to decide the payload can boot. - */ -export function isBundledInstall( - resourcesPath: string | undefined, - deps: { fileExists: (p: string) => boolean } -): boolean { - if (!resourcesPath) { - return false - } - - const manifestPath = path.join(resourcesPath, 'agent-payload', 'manifest.json') - - if (!deps.fileExists(manifestPath)) { - return false - } - - try { - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) - - return Boolean(manifest) && manifest.external !== true - } catch { - return false - } -} - -/** - * Verify the payload is usable — the store python + venv site-packages - * resolve. NO pyvenv.cfg write: bundled builds run the store python - * directly (self-relative, works on read-only MSIX), so there is nothing - * to re-point. Returns true when the payload can boot. - */ -export function adoptPayloadVenv( - payload: PayloadInfo, - deps: { isWindows: boolean; log?: (m: string) => void } -): boolean { - if (!payload.storePython || !payload.sitePackages) { - deps.log?.('[payload] missing store python or site-packages') - - return false - } - - return true } // ─── update channel ───────────────────────────────────────────────────────── diff --git a/apps/desktop/electron/update-root-policy.test.ts b/apps/desktop/electron/update-root-policy.test.ts index 5fa0a7183d..3078895bd3 100644 --- a/apps/desktop/electron/update-root-policy.test.ts +++ b/apps/desktop/electron/update-root-policy.test.ts @@ -32,23 +32,14 @@ test('a self-managed checkout is updatable', () => { assert.equal(result.message, null) }) -test('a checkout owned by the external steward is refused with a git pull pointer', () => { - const result = classifyUpdateRoot({ isGitTree: true, updateMechanism: 'external' }) +test.each(['external', 'electron-updater', 'app-installer'] as const)('a %s-owned checkout is never git-updated by the desktop', updateMechanism => { + const result = classifyUpdateRoot({ isGitTree: true, updateMechanism }) assert.equal(result.updatable, false) assert.equal(result.verdict, 'steward-owned-git-tree') assert.equal(result.provenance, 'steward-owned') assert.equal(result.advice, 'git pull') - assert.ok(result.message?.includes('external')) -}) - -test('a checkout owned by electron-updater is refused the same way', () => { - const result = classifyUpdateRoot({ isGitTree: true, updateMechanism: 'electron-updater' }) - - assert.equal(result.updatable, false) - assert.equal(result.verdict, 'steward-owned-git-tree') - assert.equal(result.advice, 'git pull') - assert.ok(result.message?.includes('electron-updater')) + assert.ok(result.message?.includes(updateMechanism)) }) test('an unstamped git tree (dev checkout) stays updatable with unknown provenance', () => { diff --git a/apps/desktop/electron/update-root-policy.ts b/apps/desktop/electron/update-root-policy.ts index af0ed97bb1..f7af06b0b4 100644 --- a/apps/desktop/electron/update-root-policy.ts +++ b/apps/desktop/electron/update-root-policy.ts @@ -23,7 +23,7 @@ // is unit-testable without booting Electron, and every caller gets the same // answer from one authority. -export type UpdateMechanism = 'self' | 'electron-updater' | 'external' +import type { InstallStamp } from './install-stamp' export type UpdateRootProvenance = 'managed-self' | 'steward-owned' | 'unknown' | 'not-a-checkout' @@ -43,7 +43,7 @@ export interface UpdateRootFacts { /** True when the resolved update root contains a `.git` entry. */ isGitTree: boolean /** The install stamp's updateMechanism, or null when there is no stamp. */ - updateMechanism: UpdateMechanism | null + updateMechanism: InstallStamp['updateMechanism'] | null } /** @@ -71,7 +71,7 @@ export function classifyUpdateRoot(facts: UpdateRootFacts): UpdateRootClassifica } } - if (facts.updateMechanism === 'external' || facts.updateMechanism === 'electron-updater') { + if (facts.updateMechanism !== null) { return { updatable: false, verdict: 'steward-owned-git-tree', diff --git a/apps/desktop/electron/updater/checkout-ownership.test.ts b/apps/desktop/electron/updater/checkout-ownership.test.ts index 359fd054e0..cc5d7258d6 100644 --- a/apps/desktop/electron/updater/checkout-ownership.test.ts +++ b/apps/desktop/electron/updater/checkout-ownership.test.ts @@ -36,8 +36,9 @@ function dependencies(): CheckoutStrategyDeps { } describe('checkout update admission', () => { - it('refuses steward-owned code without fetching or stopping the backend', async () => { + it.each(['external', 'app-installer', 'electron-updater'])('refuses %s-owned code without fetching or stopping the backend', async updateMechanism => { const deps = dependencies() + deps.readCanonicalInstallStamp = () => ({ updateMechanism }) const strategy = createCheckoutStrategy(deps) const result = await strategy.check() diff --git a/apps/desktop/electron/updater/external.ts b/apps/desktop/electron/updater/external.ts index f3842c28c6..14cedf3fc6 100644 --- a/apps/desktop/electron/updater/external.ts +++ b/apps/desktop/electron/updater/external.ts @@ -1,14 +1,12 @@ // updater/external.ts — the steward-owned strategy. // -// Store deployments (process.windowsStore) and stamps whose updateMechanism -// is 'external': the steward (Microsoft Store / App Installer on-launch -// re-check) owns the update loop. The app does not check, does not apply — -// it only tells the user updates happen outside the app. +// The stamp declares 'external' when the package owner handles updates +// without an in-app check or apply action. import type { UpdaterApplyResultWire, UpdaterStatusWire } from './index' export const EXTERNAL_UNSUPPORTED_MESSAGE = - 'bundled install: updates are applied by the installer (Microsoft Store or App Installer).' + 'Updates are managed by the package owner outside this app.' export class ExternalStrategy { readonly mechanism = 'external' as const diff --git a/apps/desktop/electron/updater/index.ts b/apps/desktop/electron/updater/index.ts index acd5efcc04..9b55473a09 100644 --- a/apps/desktop/electron/updater/index.ts +++ b/apps/desktop/electron/updater/index.ts @@ -12,10 +12,10 @@ // manual checkout with no staged updater — the user runs // `hermes update` themselves. // -// The mechanism is resolved from the packaged identity, platform and store -// flag by resolveUpdaterMechanism — a pure function, unit -// tested — and every strategy reports it on the wire so the renderer can -// tailor copy per mechanism without probing the install shape itself. +// The build stamp declares the owner. Runtime dispatch needs no payload probe +// or Store inference; the strategy reports its mechanism to the renderer. + +import type { InstallStamp } from '../install-stamp' export type UpdaterMechanism = | 'app-installer' @@ -27,12 +27,8 @@ export type UpdaterMechanism = /** The facts the mechanism dispatch keys on. Pure data — injectable for tests. */ export interface MechanismFacts { - isPackaged: boolean platform: NodeJS.Platform - payload: 'bundled' | 'light' | 'bootstrap' | undefined - updateMechanism: 'self' | 'external' | 'electron-updater' | undefined - /** This process is a Microsoft Store deployment. */ - isWindowsStore: boolean + updateMechanism: InstallStamp['updateMechanism'] | undefined } /** @@ -40,13 +36,8 @@ export interface MechanismFacts { * packaged app into a checkout, and Light needs no payload to update itself. */ export function resolveUpdaterMechanism(facts: MechanismFacts): UpdaterMechanism { - if (facts.isPackaged && (facts.payload === 'bundled' || facts.payload === 'light')) { - if (facts.isWindowsStore) { return 'external' } - - if (facts.platform === 'win32') { return 'app-installer' } - - return facts.platform === 'darwin' && facts.updateMechanism === 'electron-updater' - ? 'electron-updater' : 'external' + if (facts.updateMechanism && facts.updateMechanism !== 'self') { + return facts.updateMechanism } return facts.platform === 'win32' ? 'windows-handoff' : 'posix-handoff' diff --git a/apps/desktop/electron/updater/updater.test.ts b/apps/desktop/electron/updater/updater.test.ts index 18aa9b5f87..3f500ea312 100644 --- a/apps/desktop/electron/updater/updater.test.ts +++ b/apps/desktop/electron/updater/updater.test.ts @@ -3,31 +3,44 @@ import { describe, expect, it } from 'vitest' +import { buildStampPayload } from '../../scripts/write-build-stamp.mjs' + import { appInstallerCheckToStatus, parseCheckOutput } from './app-installer' import { buildManualUpdateCommand } from './checkout' import { consumePendingRelaunch, PENDING_RELAUNCH_FILENAME, registerUpdateRelaunch, writePendingRelaunch } from './relaunch' import { resolveUpdaterMechanism } from './index' -describe('resolveUpdaterMechanism — install ownership', () => { - it.each(['bundled', 'light'] as const)('macOS %s updates without probing for Python', payload => { - expect(resolveUpdaterMechanism({ isPackaged: true, payload, platform: 'darwin', updateMechanism: 'electron-updater', isWindowsStore: false })).toBe('electron-updater') - }) +describe('build stamp → update ownership', () => { + const provenance = { commit: 'a'.repeat(40), branch: 'main', dirty: false, source: 'ci' } + + const runtime = { + repoDir: 'app', toolsDir: 'tools', storePython: 'tools/python/python', + sitePackages: 'deps', commands: { hermes: 'bin/hermes' } + } it.each([ - ['win32', false, 'app-installer'], - ['win32', true, 'external'], - ['linux', false, 'external'], - ['darwin', false, 'external'] - ] as const)('preserves %s steward ownership (store=%s)', (platform, isWindowsStore, expected) => { - expect(resolveUpdaterMechanism({ isPackaged: true, payload: 'bundled', platform, isWindowsStore, updateMechanism: 'external' })).toBe(expected) + ['win32', 'bundled', 'app-installer', 'app-installer'], + ['win32', 'store', 'external', 'external'], + ['win32', 'light', 'external', 'external'], + ['darwin', 'bundled', 'electron-updater', 'electron-updater'], + ['darwin', 'light', 'electron-updater', 'electron-updater'], + ['linux', 'bundled', 'external', 'external'], + ['linux', 'light', 'external', 'external'], + ['win32', '', 'self', 'windows-handoff'], + ['darwin', '', 'self', 'posix-handoff'], + ['linux', '', 'self', 'posix-handoff'] + ] as const)('%s %s dispatches its declared owner without a Store flag', (platform, variant, declared, strategy) => { + const stamp = buildStampPayload(provenance, { HERMES_DESKTOP_VARIANT: variant }, platform, { runtime }) + + expect(stamp.updateMechanism).toBe(declared) + expect(stamp).not.toHaveProperty('store') + expect(resolveUpdaterMechanism({ platform, updateMechanism: stamp.updateMechanism })).toBe(strategy) }) - it.each(['win32', 'darwin', 'linux'] as const)('dev and bootstrap %s retain checkout updates', platform => { - const facts = { isPackaged: true, platform, payload: 'bootstrap' as const, updateMechanism: 'self' as const, isWindowsStore: false } - const expected = platform === 'win32' ? 'windows-handoff' : 'posix-handoff' - expect(resolveUpdaterMechanism(facts)).toBe(expected) - expect(resolveUpdaterMechanism({ ...facts, isPackaged: false, payload: 'bundled' })).toBe(expected) + it.each(['win32', 'darwin', 'linux'] as const)('unstamped %s development uses source updates', platform => { + expect(resolveUpdaterMechanism({ platform, updateMechanism: undefined })) + .toBe(platform === 'win32' ? 'windows-handoff' : 'posix-handoff') }) }) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c926d5270d..55ca9c26ea 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -35,7 +35,7 @@ "pack": "npm run build && npm run builder -- --dir --publish never", "dist": "npm run build && npm run builder", "payload": "uv run --no-project --python 3.11 python ../../scripts/bundles/stage.py --out build/agent-payload", - "dist:bundled": "npm run payload && npm run dist", + "dist:bundled": "npm run payload && cross-env HERMES_DESKTOP_VARIANT=bundled npm run dist", "dist:mac": "npm run build && npm run builder -- --mac", "dist:mac:dmg": "npm run build && npm run builder -- --mac dmg", "dist:mac:zip": "npm run build && npm run builder -- --mac zip", diff --git a/apps/desktop/scripts/before-build.mjs b/apps/desktop/scripts/before-build.mjs index 01fa844400..01b1f6cdad 100644 --- a/apps/desktop/scripts/before-build.mjs +++ b/apps/desktop/scripts/before-build.mjs @@ -4,12 +4,7 @@ * avoids workspace dependency graph explosions and keeps packaging * deterministic across environments. * - * Also guarantees build/agent-payload exists: extraResources copies it on - * every build, and electron-builder's behavior for a missing `from` varies - * between versions. A bundled build stages the real payload there first - * (`hermes pm bundle --out apps/desktop/build/agent-payload`); anything else - * gets a stub manifest with external:true, which resolvePayload() treats as - * "no payload" so the backend resolver falls through to the runtime rungs. + * Payload assembly belongs to scripts/bundles. This hook never creates it. * * Also stages the MSIX build-time assets (the build/appx icon set and the * build/msix-extensions.xml fragment the config points customExtensionsPath @@ -36,14 +31,6 @@ export default async function beforeBuild() { writeMsixExtensions() if (store) stageStoreManifest(path.join(import.meta.dirname, '..'), process.env.HERMES_PAYLOAD_TAG) - const payloadDir = path.join(import.meta.dirname, '..', 'build', 'agent-payload') - const manifest = path.join(payloadDir, 'manifest.json') - - if (!fs.existsSync(manifest)) { - fs.mkdirSync(payloadDir, { recursive: true }) - fs.writeFileSync(manifest, JSON.stringify({ schema: 1, external: true }, null, 2) + '\n') - } - return false } @@ -82,9 +69,8 @@ function writeMsixExtensions() { const output = path.join('build', 'msix-extensions.xml') const file = path.join(desktop, output) const manifest = path.join(desktop, 'build', 'agent-payload', 'manifest.json') - const payload = fs.existsSync(manifest) ? JSON.parse(fs.readFileSync(manifest, 'utf8')) : null - const launchers = light || !payload || payload.external - ? [] : payload.launchers + const launchers = ['bundled', 'store'].includes(process.env.HERMES_DESKTOP_VARIANT || '') + ? JSON.parse(fs.readFileSync(manifest, 'utf8')).launchers : [] if (!Array.isArray(launchers)) throw new Error('Bundled payload has no declared launchers') const aliases = appExecutionAliasExtensions(launchers) // The uap3:AppExtension fragment that registers the app as a Windows diff --git a/apps/desktop/scripts/write-build-stamp.mjs b/apps/desktop/scripts/write-build-stamp.mjs index 3981a7e929..a642e3ebee 100644 --- a/apps/desktop/scripts/write-build-stamp.mjs +++ b/apps/desktop/scripts/write-build-stamp.mjs @@ -1,38 +1,12 @@ /** - * Writes apps/desktop/build/install-stamp.json with the git ref the desktop - * .exe should pin to at first-launch bootstrap time. This file ships inside - * the packaged app via electron-builder's extraResources entry and is read - * by electron/main.ts to drive the install.ps1 stage bootstrap flow. - * - * Schema (subject to bump via STAMP_SCHEMA_VERSION): - * { - * "schemaVersion": 1, - * "commit": "<40-char SHA>", - * "branch": "", - * "builtAt": "", - * "dirty": true|false, - * "source": "ci" | "local" | "fallback", - * // Staged desktop builds only (HERMES_DESKTOP_VARIANT set): - * "payload": "bundled" | "light" | "bootstrap", - * "store": true|false, - * "distribution": "desktop-app", - * "updateMechanism": "external", - * "tag": "" - * } - * - * Source preference order: - * 1. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with - * shallow clones, detached HEADs, etc. in CI. - * 2. Local `git rev-parse` against the parent repo (../..). - * 3. Fallback stamp for local/personal builds from non-git source trees - * (ZIP extract, interrupted clone with no HEAD, etc.). - * - * Dev / out-of-repo builds without git produce an explicit fallback stamp - * rather than aborting the whole build. Bootstrap treats the all-zero - * commit as unpinned and follows the branch instead of fetching a fake SHA. + * Write the desktop artifact stamp for source, bundled and Light builds. + * bundle-electron-main.mjs bakes it into the running code. The packaged + * sidecar exists only to detect replacement of that artifact by an update. + * PM's bundle builder supplies relative launch paths for bundled artifacts. + * Provenance comes from CI, local git, or an explicit unknown-source stamp. */ -import { mkdirSync, writeFileSync } from "fs" +import { mkdirSync, readFileSync, writeFileSync } from "fs" import { resolve, join, relative } from "path" import { execSync } from "child_process" @@ -155,8 +129,13 @@ function main() { ) } + const bundled = ['bundled', 'store'].includes(process.env.HERMES_DESKTOP_VARIANT) + const payload = bundled + ? JSON.parse(readFileSync(join(OUT_DIR, 'agent-payload', 'manifest.json'), 'utf8')) + : null + const built = buildStampPayload(stamp, process.env, process.platform, payload) mkdirSync(OUT_DIR, { recursive: true }) - writeFileSync(OUT_FILE, JSON.stringify(buildStampPayload(stamp, process.env), null, 2) + "\n", "utf8") + writeFileSync(OUT_FILE, JSON.stringify(built, null, 2) + "\n", "utf8") console.log( "[write-build-stamp] wrote " + relative(REPO_ROOT, OUT_FILE) + @@ -168,25 +147,10 @@ function main() { ) } -/** - * Build the install-stamp.json payload. The legacy 5-field shape (schemaVersion - * 1: commit/branch/builtAt/dirty/source) is always present; when the desktop - * build is staged (HERMES_DESKTOP_VARIANT set), the full-schema fields the - * updater mechanism resolution reads are added: payload, distribution, - * updateMechanism, tag, and the store-submission flag. The variant is a - * BUILD-TIME fact — process.windowsStore cannot tell a Microsoft Store - * deployment from an App Installer sideload (Electron sets it true for any - * MSIX package), so the resolver must read it from the baked stamp. - * - * Mirrors scripts/write_install_stamp.py::build_stamp's variant handling: - * store -> payload 'bundled' (steward-owned, no in-app updater) - * bundled -> payload 'bundled' - * light -> payload 'light' - * '' / bootstrap -> payload 'bootstrap' - * Dev/local builds (no variant) keep the old shape; installShape() then treats - * them as checkout, which is correct for a dev run. +/** One artifact schema for source, bundled and Light builds. + * The PM bundle builder supplies launch paths only for bundled artifacts. */ -export function buildStampPayload(stamp, env = process.env, platform = process.platform) { +export function buildStampPayload(stamp, env = process.env, platform = process.platform, payload = null) { const variant = (env.HERMES_DESKTOP_VARIANT || "").trim() const base = { schemaVersion: STAMP_SCHEMA_VERSION, @@ -194,17 +158,32 @@ export function buildStampPayload(stamp, env = process.env, platform = process.p branch: stamp.branch, builtAt: new Date().toISOString(), dirty: stamp.dirty, - source: stamp.source + source: stamp.source, + commitDate: stamp.commitDate ?? null, + baseVersion: stamp.baseVersion ?? null, + displayVersion: stamp.displayVersion ?? null, + distance: stamp.distance ?? null } - if (!variant) return base + const updateMechanism = { + '': 'self', + bootstrap: 'self', + store: 'external', + bundled: { win32: 'app-installer', darwin: 'electron-updater' }[platform] || 'external', + light: platform === 'darwin' ? 'electron-updater' : 'external' + }[variant] + if (!updateMechanism) throw new Error(`Unknown desktop variant: ${variant}`) + const bundled = variant === 'bundled' || variant === 'store' + if (bundled && !payload?.runtime?.commands?.hermes) { + throw new Error('PM payload has no completed launch contract; stage the bundle before packaging') + } return { ...base, payload: variant === "store" ? "bundled" : variant || "bootstrap", - store: variant === "store", distribution: "desktop-app", - updateMechanism: platform === 'darwin' && ['bundled', 'light'].includes(variant) ? 'electron-updater' : 'external', - tag: env.HERMES_PAYLOAD_TAG || null + updateMechanism, + tag: env.HERMES_PAYLOAD_TAG || null, + ...(bundled ? { runtime: payload.runtime } : {}) } } diff --git a/apps/desktop/scripts/write-build-stamp.test.mjs b/apps/desktop/scripts/write-build-stamp.test.mjs index af6d5ebc9c..ffb7d9b9a9 100644 --- a/apps/desktop/scripts/write-build-stamp.test.mjs +++ b/apps/desktop/scripts/write-build-stamp.test.mjs @@ -97,48 +97,51 @@ const baseStamp = { source: 'ci' } -test('buildStampPayload without a variant keeps the legacy 5-field shape', () => { - const payload = buildStampPayload(baseStamp, {}) - assert.deepEqual(payload, { - schemaVersion: 1, - commit: baseStamp.commit, - branch: 'main', - builtAt: payload.builtAt, // stamped at write time, not the fixture's - dirty: false, - source: 'ci' - }) - assert.equal(typeof payload.builtAt, 'string') - assert.ok(!('payload' in payload)) - assert.ok(!('store' in payload)) - assert.ok(!('tag' in payload)) +const runtime = { + repoDir: 'app', toolsDir: 'tools', storePython: 'tools/python/bin/python3', + sitePackages: 'venv/lib/python3.14/site-packages', commands: { hermes: 'bin/hermes' } +} + +test('bundled stamp carries the builder contract and refuses an unstaged payload', () => { + const env = { HERMES_DESKTOP_VARIANT: 'bundled' } + assert.throws(() => buildStampPayload(baseStamp, env, 'darwin'), /payload/) + assert.deepEqual(buildStampPayload(baseStamp, env, 'darwin', { runtime }).runtime, runtime) + assert.equal(buildStampPayload(baseStamp, { HERMES_DESKTOP_VARIANT: 'light' }, 'darwin', { runtime }).runtime, undefined) }) -test('buildStampPayload with bundled variant stamps payload bundled, store false', () => { +test('source builds declare the same artifact schema, without a payload', () => { + const stamp = buildStampPayload(baseStamp, {}) + assert.equal(stamp.payload, 'bootstrap') + assert.equal(stamp.distribution, 'desktop-app') + assert.equal(stamp.runtime, undefined) + assert.equal(stamp.tag, null) + assert.equal(stamp.commit, baseStamp.commit) +}) + +test('buildStampPayload with bundled variant declares the App Installer owner', () => { const payload = buildStampPayload(baseStamp, { HERMES_DESKTOP_VARIANT: 'bundled', HERMES_PAYLOAD_TAG: 'v0.27.1-canary.20260901072553' - }, 'win32') + }, 'win32', { runtime }) assert.equal(payload.payload, 'bundled') - assert.equal(payload.store, false) assert.equal(payload.distribution, 'desktop-app') - assert.equal(payload.updateMechanism, 'external') + assert.equal(payload.updateMechanism, 'app-installer') assert.equal(payload.tag, 'v0.27.1-canary.20260901072553') }) test('macOS bundles and Light declare app-owned updates, never Store builds', () => { for (const variant of ['bundled', 'light', 'store']) { - const stamp = buildStampPayload(baseStamp, { HERMES_DESKTOP_VARIANT: variant }, 'darwin') + const stamp = buildStampPayload(baseStamp, { HERMES_DESKTOP_VARIANT: variant }, 'darwin', { runtime }) assert.equal(stamp.updateMechanism, variant === 'store' ? 'external' : 'electron-updater') } }) -test('buildStampPayload with store variant stamps payload bundled, store true', () => { +test('buildStampPayload with store variant declares external ownership', () => { const payload = buildStampPayload(baseStamp, { HERMES_DESKTOP_VARIANT: 'store', HERMES_PAYLOAD_TAG: 'v0.27.1' - }) + }, 'win32', { runtime }) assert.equal(payload.payload, 'bundled') - assert.equal(payload.store, true) }) test('buildStampPayload with light variant stamps payload light', () => { @@ -147,13 +150,12 @@ test('buildStampPayload with light variant stamps payload light', () => { HERMES_PAYLOAD_TAG: 'v0.27.1' }) assert.equal(payload.payload, 'light') - assert.equal(payload.store, false) }) test('buildStampPayload keeps schemaVersion + provenance in the staged shape', () => { const payload = buildStampPayload(baseStamp, { HERMES_DESKTOP_VARIANT: 'bundled' - }) + }, 'win32', { runtime }) assert.equal(payload.schemaVersion, 1) assert.equal(payload.commit, baseStamp.commit) assert.equal(payload.source, 'ci') diff --git a/apps/desktop/src/components/desktop-install-local-card.test.ts b/apps/desktop/src/components/desktop-install-local-card.test.ts index cd6458f52d..d8ad468b36 100644 --- a/apps/desktop/src/components/desktop-install-local-card.test.ts +++ b/apps/desktop/src/components/desktop-install-local-card.test.ts @@ -8,7 +8,6 @@ test('none is the installer offer with the install-to footer', () => { assert.deepEqual(localCardPresentation('none'), { title: 'installLocalTitle', desc: 'installLocalDesc', - disabled: false, showInstallTo: true }) }) @@ -17,7 +16,6 @@ test('installed uses the existing-runtime copy and hides the install-to footer', assert.deepEqual(localCardPresentation('installed'), { title: 'useLocalTitle', desc: 'useLocalDesc', - disabled: false, showInstallTo: false }) }) @@ -26,21 +24,10 @@ test('bundled uses the bundled flavor of the existing-runtime copy', () => { assert.deepEqual(localCardPresentation('bundled'), { title: 'useLocalTitle', desc: 'bundledLocalDesc', - disabled: false, - showInstallTo: false - }) -}) - -test('bundled-damaged is disabled and never shows the install-to footer', () => { - assert.deepEqual(localCardPresentation('bundled-damaged'), { - title: 'bundledDamagedTitle', - desc: 'bundledDamagedDesc', - disabled: true, showInstallTo: false }) }) test('an absent local field falls back to the installer offer (old backends)', () => { assert.equal(localCardPresentation(undefined).title, 'installLocalTitle') - assert.equal(localCardPresentation(undefined).disabled, false) }) diff --git a/apps/desktop/src/components/desktop-install-local-card.ts b/apps/desktop/src/components/desktop-install-local-card.ts index cbb0036ea6..f79a0f0fc3 100644 --- a/apps/desktop/src/components/desktop-install-local-card.ts +++ b/apps/desktop/src/components/desktop-install-local-card.ts @@ -1,31 +1,11 @@ -/** - * desktop-install-local-card.ts - * - * Pure presentation derivation for the setup screen's local card. The card - * has four states, keyed off the `local` field the backend stamps on every - * resolved backend: - * - * none — no Hermes on this machine: the card IS an install offer. - * installed — a runtime resolved (PATH hermes, active root, …): clicking - * starts the existing runtime; nothing downloads. - * bundled — a healthy bundled install: the payload is the runtime. - * bundled-damaged— a bundled install whose payload failed to resolve: the - * card is disabled (never an install action); reinstall the - * app instead. - * - * Extracted so the disabled/title/desc/footer logic is unit-testable without - * rendering the overlay. - */ - -export type LocalCardState = 'none' | 'installed' | 'bundled' | 'bundled-damaged' +/** Presentation for an install offer or an already-selected local runtime. */ +export type LocalCardState = 'none' | 'installed' | 'bundled' export interface LocalCardPresentation { /** i18n key into `t.install` for the card title. */ - title: 'installLocalTitle' | 'useLocalTitle' | 'bundledDamagedTitle' + title: 'installLocalTitle' | 'useLocalTitle' /** i18n key into `t.install` for the card body. */ - desc: 'installLocalDesc' | 'useLocalDesc' | 'bundledLocalDesc' | 'bundledDamagedDesc' - /** When true the card cannot be clicked — there is no install to fire. */ - disabled: boolean + desc: 'installLocalDesc' | 'useLocalDesc' | 'bundledLocalDesc' /** Whether the "Will install to " footer is accurate for this state. */ showInstallTo: boolean } @@ -36,7 +16,6 @@ export function localCardPresentation(local: LocalCardState | undefined): LocalC return { title: 'useLocalTitle', desc: 'useLocalDesc', - disabled: false, showInstallTo: false } @@ -44,15 +23,6 @@ export function localCardPresentation(local: LocalCardState | undefined): LocalC return { title: 'useLocalTitle', desc: 'bundledLocalDesc', - disabled: false, - showInstallTo: false - } - - case 'bundled-damaged': - return { - title: 'bundledDamagedTitle', - desc: 'bundledDamagedDesc', - disabled: true, showInstallTo: false } @@ -62,7 +32,6 @@ export function localCardPresentation(local: LocalCardState | undefined): LocalC return { title: 'installLocalTitle', desc: 'installLocalDesc', - disabled: false, showInstallTo: true } } diff --git a/apps/desktop/src/components/desktop-install-overlay.test.tsx b/apps/desktop/src/components/desktop-install-overlay.test.tsx index e5887622ef..51165480c4 100644 --- a/apps/desktop/src/components/desktop-install-overlay.test.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.test.tsx @@ -617,40 +617,5 @@ describe('DesktopInstallOverlay bundled / already-installed cards', () => { expect(screen.queryByText(/Will install to/i)).toBeNull() }) - it('disables the local card for a damaged bundled payload — never an install action', async () => { - const desktop = installDesktopMock( - bootstrapState({ - setupChoice: { - platform: 'win32', - activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent', - local: 'bundled-damaged', - bundled: true - } - }) - ) - render() - - expect(await screen.findByText('Use Hermes bundled with this app')).toBeTruthy() - expect(screen.getByText(/missing or damaged/i)).toBeTruthy() - - const localCard = screen.getByText('Use Hermes bundled with this app').closest('button') - expect(localCard).toBeTruthy() - expect(localCard?.getAttribute('aria-disabled')).toBe('true') - - // Clicking the disabled card must never fire the local bootstrap bridge. - fireEvent.click(screen.getByText('Use Hermes bundled with this app')) - expect(desktop.continueBootstrapLocal).not.toHaveBeenCalled() - - // The docs "Reinstall the app" affordance is present and opens the docs - // site — not an installer. - const reinstall = screen.getByText('Reinstall the app') - expect(reinstall).toBeTruthy() - fireEvent.click(reinstall) - expect(desktop.openExternal).toHaveBeenCalledWith('https://hermes-agent.nousresearch.com/docs/user-guide/desktop') - - // The remote card stays clickable — connecting remotely is the working path. - fireEvent.click(screen.getByText('Connect to existing Hermes')) - expect(await screen.findByText('Gateway URL')).toBeTruthy() - }) }) diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index 95fb43dd2b..71026701d0 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -15,7 +15,6 @@ import type { DesktopBootstrapState } from '@/global' import { useI18n } from '@/i18n' -import { DESKTOP_DOCS_URL } from '@/lib/docs' import { AlertCircle, ChevronDown, ChevronRight, Globe, iconSize, Loader2, Monitor } from '@/lib/icons' import { capitalize } from '@/lib/text' import { cn } from '@/lib/utils' @@ -403,10 +402,6 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP } if (state.setupChoice) { - // The local card's copy + behavior derive from what the backend found on - // this machine: 'none' is an install offer, the rest say "use what's - // already here" — and 'bundled-damaged' disables the card entirely - // (there is no install to fire; reinstall the app instead). const localState = state.setupChoice.local const localPres = localCardPresentation(localState) @@ -437,9 +432,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP - {localPres.disabled ? ( -
- - -
- ) : null} - {localStartError ? (
diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 3d0f3c69dc..34b8c33eec 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -1267,7 +1267,7 @@ export interface DesktopBootstrapSetupChoice { platform: string activeRoot: string /** What the local card represents: 'none' = installer offer; the rest = use existing. */ - local: 'none' | 'installed' | 'bundled' | 'bundled-damaged' + local: 'none' | 'installed' | 'bundled' /** This artifact is a bundled install (payload ships in-app). */ bundled: boolean } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index e6e47fc05f..d000268ca1 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2988,10 +2988,6 @@ export const en: Translations = { useLocalTitle: 'Use Hermes on this computer', useLocalDesc: 'A Hermes runtime is already installed here — start it with one click. Nothing downloads.', bundledLocalDesc: 'Use the Hermes runtime included with this app — the bundled backend is the local install.', - bundledDamagedTitle: 'Use Hermes bundled with this app', - bundledDamagedDesc: - "The Hermes backend bundled with this app is missing or damaged — reinstall the app to restore it.", - reinstallApp: 'Reinstall the app', localStartUnavailable: 'Local installation could not start. Restart Hermes Desktop and try again.', remoteSetupTitle: 'Connect to existing Hermes', remoteSetupDesc: 'Enter your gateway URL. Hermes Desktop will detect whether it needs a token or browser sign-in.', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 6b48a62b32..663731604e 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2553,9 +2553,6 @@ export interface Translations { useLocalTitle: string useLocalDesc: string bundledLocalDesc: string - bundledDamagedTitle: string - bundledDamagedDesc: string - reinstallApp: string localStartUnavailable: string remoteSetupTitle: string remoteSetupDesc: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 4899b19791..db619fa580 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -3145,10 +3145,6 @@ export const zh: Translations = { useLocalTitle: '使用这台电脑上的 Hermes', useLocalDesc: '此电脑已安装 Hermes 运行时——一键启动,无需下载。', bundledLocalDesc: '此应用自带 Hermes 运行时——捆绑后端即本地安装。', - bundledDamagedTitle: '捆绑后端不可用', - bundledDamagedDesc: - '此应用捆绑的 Hermes 运行时未能加载。重新安装 Hermes Desktop 以恢复。', - reinstallApp: '重新安装 Hermes Desktop', localStartUnavailable: '无法启动本地安装。请重启 Hermes Desktop 后重试。', remoteSetupTitle: '连接到现有 Hermes', remoteSetupDesc: '输入网关 URL。Hermes Desktop 会检测需要令牌还是浏览器登录。', diff --git a/docs/macos-bundle-updates.md b/docs/macos-bundle-updates.md index 0627e72a83..8b589f0050 100644 --- a/docs/macos-bundle-updates.md +++ b/docs/macos-bundle-updates.md @@ -55,8 +55,13 @@ The Darwin publish job waits for both native builds and serializes channel write ## Verification limits -Local tests exercise the strategy, native-event ordering, feed validation, -conditional publication and retention with injected OS/network boundaries. -The desktop TypeScript and JavaScript build run on the development host. -These checks are not proof of a signed macOS install or an actual app replacement. -No E2E work, release dispatch or public feed publication is included here. +Helper tests exercise the strategy, native-event ordering, feed validation, +conditional publication, and retention. They are not proof of a signed install +or actual app replacement. + +Native macOS packaged-update drivers are part of the existing +[install/update family](../tests/install/BUNDLED_UPDATES.md). The stable gate +requires signed-package transitions on both architectures. Each acceptance +claim needs a successful native run for the exact old/new package pair. +Workflow definitions and historical helper results do not establish acceptance +of the current head. See [PM audit status](pm-audit-status.md) for scoped receipts. diff --git a/docs/pm-audit-status.md b/docs/pm-audit-status.md index c7665cae97..1e721913a0 100644 --- a/docs/pm-audit-status.md +++ b/docs/pm-audit-status.md @@ -1,9 +1,47 @@ # PM audit remediation status +This is a historical audit record, not a current-head release status page. +Counts and native receipts below apply only to the revisions they name. +Subsequent work adds shared bundle assembly, signed-package E2E, the stable +release gate, Termux APT distribution, and Python 3.14. Those changes do not +retroactively extend earlier test receipts. + +For current implementation contracts, use [Package management](../website/docs/reference/package-management.md), +[shared bundle builds](shared-bundle-builds.md), [stable releases](stable-releases.md), +and [bundled update acceptance](../tests/install/BUNDLED_UPDATES.md). + This change integrates repairs from the aggregate branch audit. It is not a release certificate. The audit compared `49945b14029e09fef608db9ede899377cdb54e11` with merge base `433f7196760e5d76f77ea6d5464ee7f1b602a4ee`. +## Runtime repairs after the documentation audit + +The audit found a Python-layout mismatch in Electron, a fixed Python family in +Nix, contradictory cryptography requirements, and failures in clean Windows setup. +The follow-up changes the owning implementations: + +- The bundle builder checks the completed payload and publishes its launch paths. + Electron consumes that contract without payload probes, adoption, or creation. +- Nix selects Python’s major/minor from `pm/lock.json`. The uv2nix environment, + package overrides, extra packages, and developer shell use that family. + Real Nix evaluation and build acceptance remain CI gates. +- The direct cryptography requirement and security override match the patched + version in `uv.lock`. The override still bypasses the vendor SDK’s stale cap. +- The bootstrap waits for uv to finish, then runs PM through Python directly. + PM can replace its uv entry without the bootstrap holding its executable. +- PM receipt publication uses stdlib-only atomic JSON writes under a shared lock. + Failure reporting no longer imports PyYAML through `utils`. +- The unused development-shell command is removed. Setup plus activation and + `deactivate` is the single PM developer path. + +A native Windows check started with an empty tool store in a disposable home. +Setup completed, PM published a dependency generation, and PowerShell activation +ran the store interpreter and source CLI. A separate PM installation passed +its real dependency check. These checks do not prove signed-package installation, +updates, or Nix builds. The broader PM plugin/config YAML coupling remains unchanged. +The [developer workflow](../website/docs/reference/package-management.md#developer-workflow) +distinguishes runtime activation from independent test and editor environments. + ## Implemented repairs | Findings | Implementation | @@ -166,5 +204,5 @@ branch. Earlier GitHub graph failures reported The external audit directory contains original findings, exact test selections, per-run logs, source snapshots, and review adjudication. Host application, -certificate trust, and production services remain unchanged. No release has -been published. +certificate trust, and production services remained unchanged during that audit. +That audit did not publish a release; this is not a statement about later runs. diff --git a/docs/shared-bundle-builds.md b/docs/shared-bundle-builds.md index f33e02feb9..d959b9aa9b 100644 --- a/docs/shared-bundle-builds.md +++ b/docs/shared-bundle-builds.md @@ -16,12 +16,19 @@ second package manager. Build a desktop bundle from a checkout at its release tag: ```sh -uv run --no-project --python 3.11 python scripts/bundles/desktop.py --tag=vX.Y.Z +uv run --no-project --python 3.14 python scripts/bundles/desktop.py --tag=vX.Y.Z ``` The builder derives console entrypoints from the archived `pyproject.toml`. -It records launcher names in the payload manifest. The MSIX hook reads those -names rather than carrying a second entrypoint list. POSIX launchers follow +It checks the interpreter, dependency tree, and generated launchers before +publishing their relative paths in the payload manifest. The desktop build +bakes this launch contract into its stamp. Electron uses the declared paths +without payload probes, adoption, or repair. Non-bundled builds carry no +placeholder payload. The stamp also declares the update mechanism. Store +packages use `external`, and sideload bundles use `app-installer`. The runtime +has no separate Store probe or compatibility fallback. +The MSIX hook reads the declared launcher names +rather than carrying a second entrypoint list. POSIX launchers follow links to the installed payload and call each declared function directly. Windows launchers are minted by the payload's own Python, preserving native architecture and relocation behavior. @@ -42,6 +49,36 @@ Ordinary bundle staging does not scan user plugin trees. Runtime plugin admission is a separate PM transaction. Build output cleanup is confined to its payload store; it must not prune the machine-wide downloader partials. +## Target boundaries + +Desktop stages on the target OS and architecture. Its dependency step uses +`uv sync --frozen --all-extras --active` on the staged interpreter. +The complete desktop builder, not `pm bundle` alone, adds prebuilt JS surfaces +and invokes Electron packaging. + +Termux's host scripts run on Linux ARM64. `build_cpython.sh` and `build_node.sh` +stage pinned bionic packages; `stage_runtime_libs.py` supplies their native +library closure. `termux_build.sh` exports the tag's core plus `acp` requirements, +builds the required native wheels in the pinned bionic container, and records +wheelhouse inputs. `build_deb.sh` assembles the environment offline, creates the +APT package, and validates it in a fresh container without network access. +Those container checks do not substitute for Android device acceptance. + +The installed Termux root is `$PREFIX/lib/hermes-agent/`. It contains `app`, +`tools`, `runtime-libs`, `venv`, `bin`, and the PM manifest/facts. Maintainer +hooks manage only the package's CLI symlinks under `$PREFIX/bin` and refuse +foreign launcher conflicts. They do not compile dependencies on install. + +`stage_apt_repo.py` owns repository metadata and signatures. Stable and canary +suites are `hermes-stable` and `hermes-canary`; package files publish before +signed metadata. Runtime and repository pins are distinct from the native +build-toolchain packages used only inside CI. + +Docker and Nix consume PM pins but do not run this desktop payload assembly. +Docker has its own curated extras and image lifecycle. Nix uses uv2nix and +separate derivations. [Stable release admission](stable-releases.md) coordinates +their acceptance and publication with desktop and Termux packages. + ## Verification boundary Tests execute shared snapshot/manifest helpers on real git fixtures, stage diff --git a/hermes_cli/steward.py b/hermes_cli/steward.py index 0d5eb511ce..2ca9e4ed7e 100644 --- a/hermes_cli/steward.py +++ b/hermes_cli/steward.py @@ -25,6 +25,7 @@ from pathlib import Path from typing import Optional BUILD_INFO_NAME = "install-stamp.json" +UPDATE_MECHANISMS = ("self", "app-installer", "electron-updater", "external") STEWARD_DESKTOP = "desktop-app" STEWARD_DOCKER = "docker" diff --git a/hermes_cli/update_channel.py b/hermes_cli/update_channel.py index 4eb844a9ae..948a1ff8d6 100644 --- a/hermes_cli/update_channel.py +++ b/hermes_cli/update_channel.py @@ -123,8 +123,8 @@ def default_channel(project_root: Optional[Path] = None) -> str: """The channel an unconfigured install tracks. ``self`` source installs follow main (historical behavior). - ``electron-updater`` bundles follow the channel their own artifact was - published to: a canary artifact tracks canary, every other bundle + ``electron-updater`` and ``app-installer`` bundles report their artifact + channel: a canary artifact tracks canary, every other bundle tracks stable. The stamp's ``tag`` is the authority, the same fact apps/desktop/product-identity.cjs keys the published feed name on — so the feed a canary artifact asks for and the feed it was published to @@ -135,7 +135,7 @@ def default_channel(project_root: Optional[Path] = None) -> str: """ root = Path(project_root) if project_root is not None else _default_root() stamp = _read_stamp(root) - if stamp.get("updateMechanism") != "electron-updater": + if stamp.get("updateMechanism") not in ("electron-updater", "app-installer"): return CHANNEL_MAIN return CHANNEL_CANARY if is_canary_tag(stamp.get("tag")) else CHANNEL_STABLE @@ -148,7 +148,7 @@ def resolve_update_channel( Resolution: the per-install record (``update.installs..channel``) when valid; otherwise the mechanism default (main for self-source, - stable/canary for electron-updater bundles by artifact tag). Source + stable/canary for release bundles by artifact tag). Source installs asking for canary normalize to main — canary builds are release artifacts, and a git checkout tracks branches; callers print the note. @@ -161,7 +161,7 @@ def resolve_update_channel( if channel == CHANNEL_CANARY: root = Path(project_root) if project_root is not None else _default_root() - if _read_stamp(root).get("updateMechanism") != "electron-updater": + if _read_stamp(root).get("updateMechanism") not in ("electron-updater", "app-installer"): # canary→main normalization for source installs. return CHANNEL_MAIN return channel @@ -181,9 +181,9 @@ def set_install_channel( ) -> str: """Persist ``channel`` for THIS install in config.yaml. Returns the id. - Refuses on ``external`` mechanism — those installs have no channel; - the steward owns updates. Raises ``ValueError`` for both bad channel - values and external installs; the CLI surfaces the message. + Refuses on ``external`` and ``app-installer`` mechanisms: their update + source belongs to the OS or package owner, not this configuration. + Raises ``ValueError`` for an invalid channel or an OS-owned install. """ channel = (channel or "").strip().lower() if channel not in VALID_CHANNELS: @@ -193,7 +193,7 @@ def set_install_channel( root = Path(project_root) if project_root is not None else _default_root() stamp = _read_stamp(root) - if stamp.get("updateMechanism") == "external": + if stamp.get("updateMechanism") in ("external", "app-installer"): distribution = stamp.get("distribution") or "an external steward" raise ValueError( f"channels don't apply here; updates are owned by {distribution}" @@ -279,7 +279,7 @@ def _stamp_channel_hint(stamp: dict) -> Optional[str]: is restored before the shape check, so validation stays with the single canary authority). Switch text only; resolution stays with :func:`resolve_update_channel`.""" - if stamp.get("updateMechanism") != "electron-updater": + if stamp.get("updateMechanism") not in ("electron-updater", "app-installer"): return None version = str(stamp.get("tag") or stamp.get("displayVersion") or "") if not version: diff --git a/hermes_cli/venv_sync.py b/hermes_cli/venv_sync.py index 9f1877cc68..b2f5e76ee7 100644 --- a/hermes_cli/venv_sync.py +++ b/hermes_cli/venv_sync.py @@ -48,6 +48,8 @@ import subprocess import sys from pathlib import Path +from hermes_cli.steward import UPDATE_MECHANISMS + STAMP_NAME = "venv-sync.json" STAMP_SCHEMA = 1 @@ -125,10 +127,10 @@ def _is_sealed(project_root: Path) -> bool: return False if not (isinstance(data, dict) and bool(data)): return False - if data.get("updateMechanism") not in ("self", "electron-updater", "external"): + if data.get("updateMechanism") not in UPDATE_MECHANISMS: raise RuntimeError( f"install-stamp.json at {project_root} is missing a valid " - "'updateMechanism' (one of self, electron-updater, external). The " + f"'updateMechanism' (one of {', '.join(UPDATE_MECHANISMS)}). The " "build lane that wrote this stamp must pass --update-mechanism to " "scripts/write_install_stamp.py." ) diff --git a/hermes_cli/version_info.py b/hermes_cli/version_info.py index 6df9d4668c..7f591f2270 100644 --- a/hermes_cli/version_info.py +++ b/hermes_cli/version_info.py @@ -22,6 +22,7 @@ from pathlib import Path from typing import Literal, cast from hermes_cli import __release_date__, __version__ +from hermes_cli.steward import UPDATE_MECHANISMS @dataclass(frozen=True) @@ -114,10 +115,10 @@ def _stamp_version_info() -> VersionInfo | None: # updateMechanism is required in every stamp. A stamp without it means # the writing build lane must be fixed, not tolerated. - if data.get("updateMechanism") not in ("self", "electron-updater", "external"): + if data.get("updateMechanism") not in UPDATE_MECHANISMS: raise RuntimeError( f"install-stamp.json at {stamp_file} is missing a valid " - "'updateMechanism' (one of self, electron-updater, external). The " + f"'updateMechanism' (one of {', '.join(UPDATE_MECHANISMS)}). The " "build lane that wrote this stamp must pass --update-mechanism to " "scripts/write_install_stamp.py (or bake the field directly)." ) diff --git a/nix/checks.nix b/nix/checks.nix index 227f57d5e5..54ea644c10 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -11,6 +11,10 @@ configMergeScript = pkgs.callPackage ./configMergeScript.nix { }; + # Same lock-derived interpreter the packages use (nix/pythonLock.nix + # owns the pm/lock.json -> family -> nixpkgs selection). + pythonLock = pkgs.callPackage ./pythonLock.nix { }; + # ── How the checks evaluate the modules ─────────────────────────── # The checks evaluate both modules for real. The NixOS module goes # through lib.evalModules with the NixOS module list. The Home Manager @@ -1175,7 +1179,9 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) # Verify extraPythonPackages PYTHONPATH injection extra-python-packages = let - testPkg = pkgs.python312Packages.pyfiglet; + # Built with the lock-derived interpreter, so this check fails + # loudly if the package set and the lock drift apart. + testPkg = pythonLock.interpreter.pkgs.pyfiglet; hermesWithExtra = hermes-agent.override { extraPythonPackages = [ testPkg ]; }; @@ -1202,6 +1208,41 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) echo "ok" > $out/result ''; + # Exercise the actual uv2nix environment, not only the selector. + python-lock-derived = pkgs.runCommand "hermes-python-lock-derived" { } '' + set -e + echo "=== Checking Nix Python derives from pm/lock.json ===" + family=${pythonLock.family} + echo "locked family: $family" + if [ "$family" != "$(${hermesVenv}/bin/python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" ]; then + echo "FAIL: selected interpreter major.minor does not match pm/lock.json"; exit 1 + fi + echo "PASS: interpreter matches lock" + mkdir -p $out + echo "ok" > $out/result + ''; + + # Selection must reject a package set without the locked family. + python-lock-no-fallback = let + inherit (pythonLock) selectPython; + lockedFamily = pythonLock.family; + # Fake package sets as data: matching family -> selected; absent + # family -> throw (never silently pick another interpreter). + matching = { "python${builtins.replaceStrings [ "." ] [ "" ] lockedFamily}" = "fake-python-matching"; }; + missing = { }; + selected = selectPython lockedFamily matching; + threw = !(builtins.tryEval (selectPython lockedFamily missing)).success; + in pkgs.runCommand "hermes-python-lock-no-fallback" { } '' + set -e + echo "=== Checking python selector has no silent fallback ===" + if [ "${toString (selected == "fake-python-matching")}" != "1" ] || [ "${toString threw}" != "1" ]; then + echo "FAIL: selector behavior wrong (selected=${toString selected} threw=${toString threw})"; exit 1 + fi + echo "PASS: selector picks locked family, throws on missing family" + mkdir -p $out + echo "ok" > $out/result + ''; + # Verify extraDependencyGroups passes through to python.nix extra-dependency-groups = let hermesWithGroups = hermes-agent.override { diff --git a/nix/desktop.nix b/nix/desktop.nix index 7f7bcfdb0e..169b3242ee 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -14,6 +14,7 @@ hermesNpmLib, electron, hermesAgent, + installStampFile, python3, # Environment to bake into the launcher. A GUI launcher reads none of the # shell profile, so a variable that an interactive shell exports does not @@ -77,6 +78,7 @@ let runHook preBuild mkdir -p apps/desktop/build + cp ${installStampFile} apps/desktop/build/install-stamp.json patchShebangs . @@ -147,7 +149,7 @@ let # before the cd. cp -rn apps/desktop/dist $out/ - echo '{"schemaVersion":1,"commit":"nix-dummy-commit","branch":"nix","dirty":false,"source":"nix"}' > $out/install-stamp.json + cp ${installStampFile} $out/install-stamp.json cp -n apps/desktop/package.json $out/ runHook postInstall diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index a8f8fe2465..139082075a 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -9,7 +9,6 @@ stdenv, makeWrapper, callPackage, - python312, electron, ripgrep, git, @@ -42,6 +41,11 @@ extraDependencyGroups ? [ ], }: let + # One owner (pythonLock.nix) reads pm/lock.json and selects the matching + # nixpkgs interpreter. Everything Python-shaped below derives from it. + pythonLock = callPackage ./pythonLock.nix { }; + python = pythonLock.interpreter; + version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version; versionModule = builtins.readFile ../hermes_cli/__init__.py; releaseRevCountLine = lib.findFirst (line: lib.hasPrefix "__release_rev_count__" line) null ( @@ -67,6 +71,23 @@ let else version; + # CLI and Electron consume the same provenance and update owner. + installStampFile = builtins.toFile "hermes-install-stamp.json" (builtins.toJSON { + schemaVersion = 2; + commit = rev; + commitDate = lastModified; + inherit branch dirty; + builtAt = null; + baseVersion = version; + displayVersion = stampDisplayVersion; + distance = stampDistance; + source = "nix"; + distribution = "nix"; + updateMechanism = "external"; + payload = "bootstrap"; + tag = null; + }); + mkHermesVenv = extraDependencyGroups: callPackage ./python.nix { @@ -138,12 +159,12 @@ let runtimePath = lib.makeBinPath runtimeDeps; - sitePackagesPath = python312.sitePackages; + sitePackagesPath = python.sitePackages; # Walk propagatedBuildInputs to include transitive Python deps in PYTHONPATH. # Without this, a plugin listing e.g. requests as a dep would fail at runtime # if requests isn't already in the sealed uv2nix venv. - allExtraPythonPackages = python312.pkgs.requiredPythonModules extraPythonPackages; + allExtraPythonPackages = python.pkgs.requiredPythonModules extraPythonPackages; pythonPath = lib.makeSearchPath sitePackagesPath allExtraPythonPackages; @@ -210,14 +231,7 @@ stdenv.mkDerivation (finalAttrs: { ln -s ${hermesWeb} $out/share/hermes-agent/web_dist ln -s ${hermesTui}/lib/hermes-tui $out/ui-tui - # Write the canonical install stamp. version_info.py resolves it - # through HERMES_INSTALL_ROOT (set by the wrapper below) — one file, - # one resolution path for the Python runtime (CLI, TUI), no .git - # probing and no stamp-specific env channel. updateMechanism is - # `external`: the nix store path is replaced by nix, never by hermes. - cat > $out/share/hermes-agent/install-stamp.json <. version — cannot derive the Nix interpreter from it" + else + builtins.elemAt parts 0 + "." + builtins.elemAt parts 1; + + # Select pkgs.python3NN by family ("3.14" -> pkgs.python314). A missing + # lookup throws; there is no default interpreter to fall back to. + selectPython = + family: packageSet: + let + name = "python" + lib.replaceStrings [ "." ] [ "" ] family; + interp = packageSet.${name} or null; + in + if interp == null then + throw "package set does not provide ${name}, but pm/lock.json pins Python ${family} — update the nixpkgs input; Nix will not silently substitute another Python" + else + interp; + + lockfile = ../pm/lock.json; + family = pythonFamily lockfile; +in +{ + inherit pythonFamily selectPython; + + inherit family; + + interpreter = selectPython family pkgs; +} diff --git a/optional-skills/creative/comfyui/tests/README.md b/optional-skills/creative/comfyui/tests/README.md index 783735ac4c..3045716ef7 100644 --- a/optional-skills/creative/comfyui/tests/README.md +++ b/optional-skills/creative/comfyui/tests/README.md @@ -43,9 +43,9 @@ When you change a script: ## Why the explicit `-c` / `-o`? -The parent hermes-agent repo used to enable `pytest-xdist` by default -(`-n auto`); the canonical runner has since moved to per-file subprocess -pytest-xdist with `--dist loadfile` via `scripts/run_tests.sh`. +The parent hermes-agent repo uses `scripts/run_tests.sh`, which runs each +test file in a separate subprocess through `scripts/run_tests_parallel.py`. +It does not use xdist on any platform. This suite is small enough that parallelism isn't worth the complexity, and pytest-xdist isn't always installed in the user's environment. The `-c tests/pytest.ini -o addopts="-p no:xdist"` flags make the suite run diff --git a/plugins/memory/hindsight/README.md b/plugins/memory/hindsight/README.md index fc9e166ea2..47de3e68b4 100644 --- a/plugins/memory/hindsight/README.md +++ b/plugins/memory/hindsight/README.md @@ -32,13 +32,19 @@ Hermes spins up a local Hindsight daemon with built-in PostgreSQL. Requires an L Supports any OpenAI-compatible LLM endpoint (llama.cpp, vLLM, LM Studio, etc.) — pick `openai_compatible` as the provider and enter the base URL. -Daemon startup logs: `~/.hermes/logs/hindsight-embed.log` -Daemon runtime logs: `~/.hindsight/profiles/.log` +The embedded runtime lives in separately resolved generations under +`$HERMES_HOME/profiles/Hindsight/env/`. `active.json` selects the current +generation. Its own interpreter runs `hindsight-embed` and `hindsight-api-slim`; +Hermes uses the HTTP client instead of importing that server into its main environment. -To open the Hindsight web UI (local embedded mode only): -```bash -hindsight-embed -p hermes ui start -``` +A failed runtime build preserves the previous generation. Re-run +`hermes memory setup` and select Local Embedded to repair it. Initial model +loading can require network access and several minutes. + +Hermes records side-environment preparation and daemon-bridge errors in its +normal logs. The sidecar manager owns its separate daemon logs. The +`hindsight-embed` CLI is inside the selected generation, not automatically on +the host PATH. ### Local External @@ -147,4 +153,7 @@ Available in `hybrid` and `tools` memory modes: ## Client Version -Requires `hindsight-client >= 0.6.1`. The plugin auto-upgrades on session start if an older version is detected. +The client version comes from the `hindsight` extra in `pyproject.toml` and +`uv.lock`. PM prepares client dependency changes without replacing libraries +already imported by Hermes. Restart Hermes when the dependency operation +reports that the new environment is selected. diff --git a/pm/cli.py b/pm/cli.py index c69bedfec2..7b5aab0924 100644 --- a/pm/cli.py +++ b/pm/cli.py @@ -10,7 +10,6 @@ import subprocess import sys import threading from pathlib import Path -from typing import Optional from pm.ensure import _facts, _lockfile, _store, ensure, stage_only from pm.ensure import uv as pm_uv @@ -194,118 +193,6 @@ def cmd_doctor(args) -> int: return 1 if bad else 0 -def _venv_site_packages(venv_dir: Path, win: bool) -> Optional[Path]: - """The site-packages dir uv sync fills inside the venv (the venv stays - a dependency target; it is never where `python` resolves).""" - if win: - candidate = venv_dir / "Lib" / "site-packages" - return candidate if candidate.is_dir() else None - for candidate in sorted(venv_dir.glob("lib/python3.*/site-packages")): - if candidate.is_dir(): - return candidate - return None - - -def _develop_env(names: list[str]) -> Optional[dict]: - """The `pm develop` subshell environment. `python` resolves to the pm - STORE python (its bin dir first on PATH); imports come from - PYTHONPATH=;/site-packages (repo first — the venv stays a - dependency target only). VIRTUAL_ENV and the venv bin dir are - deliberately absent: nothing in the devshell boots through the venv - (pm work item 3; pyvenv.cfg is inert dead config). Returns None when - the store interpreter has not been materialized (`hermes pm install`).""" - import os - - from pm import paths - from pm.ensure import env_for - - facts = _facts() - python_fact = facts.get("python") - target = current_target() - if not python_fact or "entry" not in python_fact: - return None - python_bin = get_package("python").binary( - _store().entry(python_fact["entry"]), target - ) - if python_bin is None or not python_bin.is_file(): - return None - - env = env_for(*names, base_env=dict(os.environ)) - repo = paths.repo_root() - venv_dir = repo / (".venv" if (repo / ".venv").is_dir() else "venv") - win = target.startswith("win32") - venv_bin = venv_dir / ("Scripts" if win else "bin") - env.pop("VIRTUAL_ENV", None) - env.pop("PYTHONHOME", None) - # The venv bin dir must never be where `python` resolves. - path_entries = [ - p for p in env.get("PATH", "").split(os.pathsep) if p and Path(p) != venv_bin - ] - env["PATH"] = os.pathsep.join([str(python_bin.parent), *path_entries]) - site = _venv_site_packages(venv_dir, win) - ours = str(repo) + (os.pathsep + str(site) if site else "") - existing = env.get("PYTHONPATH", "") - env["PYTHONPATH"] = ours + (os.pathsep + existing if existing else "") - return env - - -def cmd_develop(args) -> int: - """Install everything, sync the venv, then activate: spawn a subshell - with every tool's env composed in and the pm STORE python resolving - `python` (PYTHONPATH=repo;venv-site-packages for imports — never the - venv interpreter). The devshell equivalent of nix develop. --print - emits eval-able exports for the current shell.""" - import os - import subprocess - - from pm.ensure import sync_venv - - install_names = [ - n for n in _lockfile().names() - if not get_package(n).optional - and get_package(n).missing_reason(current_target()) is None - ] - # The devshell boots the STORE python — make sure it is materialized. - if ( - "python" not in install_names - and get_package("python").missing_reason(current_target()) is None - ): - install_names.append("python") - failed = _install_names(install_names) - try: - sync_venv(explicit=True) - print("✓ venv") - except InstallError as e: - print(f"✗ {e}") - failed += 1 - if failed: - return 1 - - env = _develop_env(_lockfile().names()) - if env is None: - print("✗ develop: no pm store interpreter — run 'hermes pm install' first") - return 1 - - from pm import paths - - win = current_target().startswith("win32") - if args.print_env: - changed = {k: v for k, v in env.items() if os.environ.get(k) != v} - for key, value in sorted(changed.items()): - if win and os.environ.get("SHELL") is None: - print(f'$env:{key} = "{value}"') - else: - escaped = value.replace("'", "'\\''") - print(f"export {key}='{escaped}'") - return 0 - - shell = os.environ.get("SHELL") or os.environ.get("COMSPEC") or ( - "cmd.exe" if win else "/bin/sh" - ) - print(f"pm develop: entering {shell} (exit to leave)") - return subprocess.call([shell], env=env, cwd=paths.repo_root()) - - def _gc_store(store, facts) -> tuple[int, int]: """The sweep core shared by `pm gc` and `pm bundle`. @@ -578,11 +465,6 @@ def main(argv=None) -> int: p = sub.add_parser("doctor", help="check installed state against the lockfile") p.set_defaults(func=cmd_doctor) - p = sub.add_parser("develop", help="install + sync, then activate a devshell with the composed env") - p.add_argument("--print", dest="print_env", action="store_true", - help="print eval-able exports instead of spawning a shell") - p.set_defaults(func=cmd_develop) - p = sub.add_parser("gc", help="remove store entries nothing references") p.set_defaults(func=cmd_gc) diff --git a/pm/receipt.py b/pm/receipt.py index 5b32020be7..2ad08eabc3 100644 --- a/pm/receipt.py +++ b/pm/receipt.py @@ -262,16 +262,19 @@ def _receipt_name(data: dict[str, Any]) -> str: def _write_rotated(data: dict[str, Any]) -> Path: - """Atomic + durable publication: temp file + fsync + rename for both - the stamped receipt and latest.json (utils.atomic_json_write).""" - import utils + """Use the same stdlib-only atomic writer as PM's installed facts.""" + from hermes_cli.runtime_state import _lock + from pm.lock import _write d = _receipt_dir() d.mkdir(parents=True, exist_ok=True) path = d / _receipt_name(data) - utils.atomic_json_write(path, data) - utils.atomic_json_write(d / "latest.json", data) - _rotate(d) + # Concurrent completions share latest.json; serialize its replacement. + with (d / ".pm-write.lock").open("a+b") as lock: + _lock(lock.fileno(), wait=True) + _write(path, data) + _write(d / "latest.json", data) + _rotate(d) return path diff --git a/pyproject.toml b/pyproject.toml index ccf3a9072c..d79745a069 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ dependencies = [ # A pin here is not sufficient on its own. alibabacloud-tea-openapi caps # cryptography<49, so [tool.uv] also holds an override. Read that comment # before you move this version. - "cryptography==50.0.0", # CVE-2026-69247, GHSA-m2h6-j472-rp4c, GHSA-jwv3-5hgf-82ww, CVE-2026-39892, CVE-2026-34073, GHSA-537c-gmf6-5ccf + "cryptography==50.0.1", # CVE-2026-69247, GHSA-m2h6-j472-rp4c, GHSA-jwv3-5hgf-82ww, CVE-2026-39892, CVE-2026-34073, GHSA-537c-gmf6-5ccf # Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo`` # (PEP 615) raises ``ZoneInfoNotFoundError`` for every non-UTC timezone # out of the box. ``tzdata`` ships the Olson database as a data package @@ -501,8 +501,10 @@ override-dependencies = [ # and its three advisories (see the cryptography pin in # [project].dependencies). The package uses cryptography only to sign # requests with RSA and AES, and that API did not break in 49 or 50. + # Keep this exact version equal to the direct dependency. uv overrides + # replace that requirement too, not only the vendor's upper bound. # Remove this line when alibabacloud-tea-openapi lifts the cap. - "cryptography>=50,<51", + "cryptography==50.0.1", ] exclude-newer = "14 days" # h2: temporary exclude-newer exception for the CVE-2026-71554 (GHSA-6hr6-w5qg-qmwg, diff --git a/scripts/bundles/desktop.py b/scripts/bundles/desktop.py index aec26ba005..2999dce63d 100644 --- a/scripts/bundles/desktop.py +++ b/scripts/bundles/desktop.py @@ -79,7 +79,7 @@ def build(repo: Path, tag: str, variant: str, builder_args: list[str]) -> None: "node": capture([node, "--version"], repo), "npm": capture([*npm, "--version"], repo), "target": target}, sort_keys=True) stamp_path = repo / "node_modules/.install-stamp" - if not stamp_path.is_file() or stamp_path.read_text(encoding="utf-8") != stamp: + if not stamp_path.is_file() or stamp_path.read_text(encoding="utf-8-sig") != stamp: stamp_path.unlink(missing_ok=True) run([*npm, "ci", "--no-audit", "--no-fund", "--fetch-retries=5", "--prefer-offline"], cwd=repo, env=env) stamp_path.write_text(stamp, encoding="utf-8") @@ -88,8 +88,6 @@ def build(repo: Path, tag: str, variant: str, builder_args: list[str]) -> None: payload = repo / "apps/desktop/build/agent-payload" if variant == "light": shutil.rmtree(payload, ignore_errors=True) - payload.mkdir(parents=True) - (payload / "manifest.json").write_text('{"schema":1,"external":true}\n', encoding="utf-8") else: run([*npm, "run", "build", "--workspace", "ui-tui"], cwd=repo, env=env) run([*npm, "run", "build", "--workspace", "web"], cwd=repo, env=env) diff --git a/scripts/bundles/payload.py b/scripts/bundles/payload.py index de5a8189f5..5f5d45fd17 100644 --- a/scripts/bundles/payload.py +++ b/scripts/bundles/payload.py @@ -204,7 +204,18 @@ def stage_launchers(root: Path, manifest: dict, *, run=subprocess.run) -> list[s output = bindir / name output.write_text(script, encoding="utf-8") output.chmod(0o755) + # Consumers receive a completed launch contract, not a Python-layout puzzle. + if not (root / site).is_dir(): + raise FileNotFoundError(f"payload dependency tree missing: {site}") + commands = {name: f"bin/{name}{'.exe' if windows else ''}" for name in entries} + for command in commands.values(): + if not (root / command).is_file(): + raise FileNotFoundError(f"payload launcher missing: {command}") manifest["launchers"] = list(entries) + manifest["runtime"] = { + "repoDir": manifest["repo"], "toolsDir": manifest["store"], + "storePython": relative_python, "sitePackages": site, "commands": commands, + } (root / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") return list(entries) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index b03ee8ca2a..eec720d5c9 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -496,8 +496,8 @@ function Stage-Venv { } # Delegate the whole python+venv+tools install to pm: stage the pinned uv, -# let uv run pm.cli, and pm provisions the interpreter, the venv (default -# extras = [all], so it matches what `hermes update` force-syncs), and the +# let uv locate Python and exit before PM starts. PM provisions the interpreter, +# the venv (default extras = [all], matching `hermes update`), and the # tool store — all hash-verified against pm/lock.json + uv.lock. install.ps1 # no longer runs `uv sync` directly; pm is the single install authority # (the run_locked_uv_sync contract moved into pm/packages.py::uv_env). @@ -509,7 +509,12 @@ function Invoke-BootstrapPm { Log "delegating python + venv + tools to pm (hash-verified via uv.lock)" Push-Location $InstallDir try { - & $uv run --no-project --python $pyVersion python -m pm.cli install + # Finish bootstrap uv before PM replaces or cleans its store entry. + & $uv python install --no-bin $pyVersion + if ($LASTEXITCODE) { Fail "bootstrap Python installation failed" } + $bootPy = (& $uv python find --managed-python $pyVersion) -join "`n" + if ($LASTEXITCODE -or -not $bootPy) { Fail "bootstrap Python lookup failed" } + & $bootPy.Trim() -m pm.cli install if ($LASTEXITCODE) { Fail "pm install failed" } } finally { Pop-Location diff --git a/scripts/install.sh b/scripts/install.sh index f389c173c3..226ceac6d6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -277,12 +277,8 @@ stage_venv() { (cd "$INSTALL_DIR" && "$UV_CMD" venv --allow-existing venv) || fail "uv venv failed" } -# Delegate the whole python+venv+tools install to pm: stage the pinned uv, -# let uv run pm.cli, and pm provisions the interpreter, the venv (default -# extras = [all], so it matches what `hermes update` force-syncs), and the -# tool store — all hash-verified against pm/lock.json + uv.lock. install.sh -# no longer runs `uv sync` directly; pm is the single install authority -# (the run_locked_uv_sync contract moved into pm/packages.py::uv_env). +# uv installs and locates bootstrap Python, then exits before PM starts. +# PM owns the final interpreter, tool store, and selected dependency generation. bootstrap_pm() { ensure_uv local _py @@ -291,8 +287,12 @@ bootstrap_pm() { "$INSTALL_DIR/pm/lock.json" | cut -d+ -f1 | cut -d. -f1,2)" [ -n "$_py" ] || _py="3.14" log "delegating python + venv + tools to pm (hash-verified via uv.lock)" - (cd "$INSTALL_DIR" && "$UV_CMD" run --no-project --python "$_py" python -m pm.cli install) \ - || fail "pm install failed" + # Finish bootstrap uv before PM replaces or cleans its store entry. + "$UV_CMD" python install --no-bin "$_py" || fail "bootstrap Python installation failed" + local boot_py + boot_py="$("$UV_CMD" python find --managed-python "$_py")" || fail "bootstrap Python lookup failed" + boot_py="${boot_py%$'\r'}" + (cd "$INSTALL_DIR" && "$boot_py" -m pm.cli install) || fail "pm install failed" } stage_python_deps() { diff --git a/scripts/write_install_stamp.py b/scripts/write_install_stamp.py index 6b06ac8137..6b0abf2f1b 100644 --- a/scripts/write_install_stamp.py +++ b/scripts/write_install_stamp.py @@ -37,6 +37,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from hermes_cli import update_channel # noqa: E402 +from hermes_cli.steward import UPDATE_MECHANISMS # noqa: E402 STAMP_SCHEMA_VERSION = 2 _REPO_ROOT = Path(__file__).parent.parent.resolve() @@ -47,9 +48,8 @@ _REPO_ROOT = Path(__file__).parent.parent.resolve() # source checkouts). # electron-updater — the in-app updater replaces the artifact (NSIS, # mac .app, AppImage). -# external — something else replaces the artifact wholesale -# (nix, docker, MSIX / app stores). -UPDATE_MECHANISMS = ("self", "electron-updater", "external") +# app-installer — the app hands the update to Windows App Installer. +# external — a package manager or app store owns updates. # Hermes's historical tags use a four-digit calendar year as their major # component (for example v2026.7.20). Restrict release majors to three digits @@ -295,7 +295,8 @@ def main() -> int: required=True, choices=UPDATE_MECHANISMS, help="Who applies the next update: 'self' (hermes update), " - "'electron-updater' (in-app updater), 'external' (nix/docker/store)", + "'app-installer' (Windows App Installer), 'electron-updater' (in-app updater), " + "'external' (nix/docker/store)", ) parser.add_argument( "--runtime-dir", diff --git a/setup-hermes.ps1 b/setup-hermes.ps1 index b2b0c7e0c1..376402cfd0 100644 --- a/setup-hermes.ps1 +++ b/setup-hermes.ps1 @@ -4,10 +4,9 @@ # Sets up the pm-managed development environment from a fresh clone: # 1. Stage the pinned uv from pm/lock.json (sha256-verified, into the pm # store slot) - pm needs uv to bootstrap, so it cannot stage uv itself. -# 2. Provision Python + the venv + hash-verified dependency sync by running -# `python -m pm.cli install` through that uv (the same code path -# `hermes pm install` uses). pyproject.toml + uv.lock are the single -# authority for pins. +# 2. Use uv to install and locate bootstrap Python, then let uv exit. +# Run `python -m pm.cli install` directly so PM can safely replace uv. +# PM owns the final interpreter, tool store, and dependency generation. # 3. Point you at `.\activate.ps1` - the venv-style way to put the pm env # (PATH + tool vars) into your current session. # ============================================================================ @@ -76,7 +75,12 @@ Write-Host 'Installing python + tools + dependencies via pm (hash-verified via u Write-Host '(first run on a fresh checkout can take 1-5 minutes)' Push-Location $repo try { - & $uv run --no-project --python $pyVersion python -m pm.cli install + # PM can replace its uv entry only after the bootstrap uv has exited. + & $uv python install --no-bin $pyVersion + if ($LASTEXITCODE -ne 0) { throw 'bootstrap Python installation failed' } + $bootPy = (& $uv python find --managed-python $pyVersion) -join "`n" + if ($LASTEXITCODE -ne 0 -or -not $bootPy) { throw 'bootstrap Python lookup failed' } + & $bootPy.Trim() -m pm.cli install if ($LASTEXITCODE -ne 0) { throw 'pm install failed - see output above.' } } finally { Pop-Location diff --git a/setup-hermes.sh b/setup-hermes.sh index 24b16cc5b3..f9b36a8cd0 100755 --- a/setup-hermes.sh +++ b/setup-hermes.sh @@ -5,10 +5,9 @@ # Sets up the pm-managed development environment from a fresh clone: # 1. Stage the pinned uv from pm/lock.json (sha256-verified, into the pm # store slot) — pm needs uv to bootstrap, so it cannot stage uv itself. -# 2. Provision Python + the venv + hash-verified dependency sync by running -# `python -m pm.cli install` through that uv (the same code path -# `hermes pm install` uses). pyproject.toml + uv.lock are the single -# authority for pins (see tests/test_project_metadata.py). +# 2. Use uv to install and locate bootstrap Python, then let uv exit. +# Run `python -m pm.cli install` directly so PM can safely replace uv. +# PM owns the final interpreter, tool store, and dependency generation. # 3. Point you at `source ./activate` — the venv-style way to put the pm # env (PATH + tool vars) into your current shell. # There is no pip fallback tier here on purpose. @@ -125,7 +124,11 @@ fi echo -e "${CYAN}→${NC} Installing python + tools + dependencies via pm (hash-verified via uv.lock)..." echo -e "${CYAN}→${NC} (first run on a fresh checkout can take 1-5 minutes)" -if ! "$uv" run --no-project --python "${py_version:-3.11}" python -m pm.cli install; then +# PM can replace its uv entry only after the bootstrap uv has exited. +"$uv" python install --no-bin "$py_version" +boot_py="$("$uv" python find --managed-python "$py_version")" +boot_py="${boot_py%$'\r'}" +if ! "$boot_py" -m pm.cli install; then echo -e "${RED}✗${NC} pm install failed — see output above." exit 1 fi diff --git a/skills/autonomous-ai-agents/hermes-agent/references/contributor-guide.md b/skills/autonomous-ai-agents/hermes-agent/references/contributor-guide.md index 3db9ed055a..944e0bc964 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/contributor-guide.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/contributor-guide.md @@ -84,9 +84,9 @@ run_conversation(): ### Testing -Use the canonical runner — it enforces CI-parity (hermetic `env -i`, unset -credentials, TZ=UTC, per-file subprocess isolation via -`scripts/run_tests.sh` — pytest-xdist, `--dist loadfile`, worker count = CPU count): +Use `scripts/run_tests.sh` for CI parity. It clears credentials, sets +`TZ=UTC`, and runs each test file in a separate subprocess through +`scripts/run_tests_parallel.py` on every platform. It does not use xdist. ```bash scripts/run_tests.sh # full suite diff --git a/skills/software-development/python-debugpy/SKILL.md b/skills/software-development/python-debugpy/SKILL.md index b5279161fb..abf5494b1a 100644 --- a/skills/software-development/python-debugpy/SKILL.md +++ b/skills/software-development/python-debugpy/SKILL.md @@ -107,7 +107,7 @@ scripts/run_tests.sh tests/path/to/test_file.py::test_name --trace scripts/run_tests.sh tests/path/to/test_file.py --showlocals --tb=long ``` -Note: `scripts/run_tests.sh` runs pytest under xdist workers, so interactive pdb does NOT work under the wrapper. Run pytest directly for `--pdb`: +Note: `scripts/run_tests.sh` captures each test file in a separate subprocess through `scripts/run_tests_parallel.py`. Interactive pdb needs a terminal, so use direct pytest only for the interactive debugger: ```bash source .venv/bin/activate diff --git a/tests-js/macos-bundled-helpers.test.mjs b/tests-js/macos-bundled-helpers.test.mjs index 461f76cf1d..34e075712f 100644 --- a/tests-js/macos-bundled-helpers.test.mjs +++ b/tests-js/macos-bundled-helpers.test.mjs @@ -121,7 +121,6 @@ describe('stampAssertions', () => { commit: 'a'.repeat(40), branch: 'main', payload: 'bundled', - store: false, distribution: 'desktop-app', updateMechanism: 'electron-updater', tag: 'v0.28.0', @@ -138,7 +137,7 @@ describe('stampAssertions', () => { }) it('rejects non-objects and store submissions', () => { expect(stampAssertions(null, sideArg)).toHaveLength(1) - expect(stampAssertions({ ...good, store: true }, sideArg).join(' ')).toMatch(/store/) + expect(stampAssertions({ ...good, updateMechanism: 'external' }, sideArg).join(' ')).toMatch(/updateMechanism/) }) }) diff --git a/tests/hermes_cli/test_update_channel.py b/tests/hermes_cli/test_update_channel.py index 24fb86e9fe..997145bd01 100644 --- a/tests/hermes_cli/test_update_channel.py +++ b/tests/hermes_cli/test_update_channel.py @@ -99,7 +99,8 @@ class TestResolve: assert resolve_update_channel({}, source) == CHANNEL_MAIN assert resolve_update_channel({}, bundle) == CHANNEL_STABLE - def test_canary_artifact_defaults_to_its_own_feed(self, tmp_path): + @pytest.mark.parametrize("mechanism", ["electron-updater", "app-installer"]) + def test_canary_artifact_defaults_to_its_own_feed(self, tmp_path, mechanism): """A canary bundle with no per-install record tracks canary. The artifact publishes to canary.yml (product-identity.cjs keys the @@ -108,7 +109,7 @@ class TestResolve: leaves a fresh canary install unable to update at all. """ root = tmp_path / "canary-bundle" - _stamp(root, "electron-updater", tag="v0.28.0-canary.20260819171926") + _stamp(root, mechanism, tag="v0.28.0-canary.20260819171926") assert default_channel(root) == CHANNEL_CANARY assert resolve_update_channel({}, root) == CHANNEL_CANARY @@ -226,10 +227,11 @@ class TestSetChannel: assert written["model"] == {"provider": "nous"} assert written["update"]["installs"][install_id(root)]["channel"] == "stable" - def test_external_mechanism_refuses(self, tmp_path, monkeypatch): + @pytest.mark.parametrize("mechanism", ["external", "app-installer"]) + def test_os_owned_mechanism_refuses_channel_writes(self, tmp_path, monkeypatch, mechanism): self._home(tmp_path, monkeypatch) - root = tmp_path / "nix-tree" - _stamp(root, "external") + root = tmp_path / "os-owned-tree" + _stamp(root, mechanism) with pytest.raises(ValueError, match="owned by"): set_install_channel("stable", root) diff --git a/tests/install/README.md b/tests/install/README.md index d3ba8444a7..f1646d37e9 100644 --- a/tests/install/README.md +++ b/tests/install/README.md @@ -2,7 +2,11 @@ These tests answer one question: can a user on a released version get to this commit? -Each test leg installs an old released version, then updates it to HEAD. The install and the update run the real user surfaces. The legs do not use mocks and do not use headless proxies of GUI flows. +Each leg installs a released version and updates it through the real user +surface. Source legs target the selected checkout revision; packaged legs use +explicit old/new signed artifacts. The harness can use a mock LLM provider for +onboarding and interaction. It does not substitute a mock installer, updater, +or headless proxy for a native GUI flow. ## The layers @@ -17,11 +21,18 @@ To declare a new method, edit the generator. To implement a method, flip the gat ## The isolation trick -The drivers do not touch the network for git operations. Each driver makes a bare clone of the checkout at `serve.git`. Then it points every git process at this clone. The mechanism is a driver-owned `GIT_CONFIG_GLOBAL` file with `url..insteadOf` rewrites for both canonical repository URLs. +Source drivers redirect canonical Hermes Git URLs to a local bare clone at +`serve.git`, using a driver-owned `GIT_CONFIG_GLOBAL` rewrite. This controls +the source install/update boundary, not all network access: tool/dependency +downloads and published bootstrap artifacts can still use the network. +Packaged-update legs instead use verified signed downloads and a temporary feed. The driver parks the `main` branch of `serve.git` at the old release. The installer runs and lands on the old release. Then the driver moves `main` to HEAD. An update becomes available in the same way that it does for a real user. -The installer script is not downloaded. The install leg runs the copy from the old git ref. This is the copy that a user of that version executed. The update leg runs the copy from HEAD. +For script-install legs, the installer comes from the old Git ref. A +script-reinstall update uses the target revision's script. A `hermes-update` +leg starts the old release's updater, and app-update legs start its app flow. +These paths are intentionally different. ## What one leg does @@ -33,7 +44,10 @@ Each leg with the script drivers has these phases: 4. Update: move `main` to HEAD. Apply one update method. Make sure that the checkout is at HEAD and that `hermes --version` works. 5. Desktop smoke again, at HEAD. -The windows GUI driver replaces phases 2 and 4 when the install method is `desktop-installer@latest`. It downloads the published `Hermes-Setup.exe`, clicks through the installer window with AutoHotkey, and clicks "Update now" in the running app with Playwright. +The Windows `desktop-installer@latest` install downloads the published +`Hermes-Setup.exe` and drives its GUI with AutoHotkey. The selected update +method is a separate axis. App-update methods click the running app's Update +control; script and CLI methods use their corresponding entry points. ## Old versions @@ -74,14 +88,28 @@ The result chart on the run summary shows each leg as passed, failed, or skipped The matrix does not run on pull requests. One leg installs real toolchains and takes more than 10 minutes. The triggers are: - A schedule, every 12 hours. This finds upstream drift. -- A release tag push. This is the moment the set of start versions changes. +- A matching release tag push. +- A reusable workflow call from the stable release gate. - Manual dispatch. You can select the route and the tag count: ``` -gh workflow run install-e2e.yml --ref -f route=both -f tag-count=2 +gh workflow run install-e2e.yml --ref -f route=all -f tag-count=2 ``` -Cost per run, so nobody is surprised: 41 legs per sampled tag (windows 18, macos 15, linux 8), so scheduled and release-tag runs sample 2 tags for up to 82 legs. Manual dispatch defaults to 3 tags for up to 123 legs. A typical green leg finishes in 7-15 minutes; every leg is capped at 60. Route slices for cheaper reads: `update` (linux only, 8/tag), `windows-desktop` (18/tag), `macos-desktop` (15/tag). `tag-count` is validated to 1-10. GitHub's 256-job cap applies to each OS matrix separately, not to the combined leg count; at 10 tags the matrices hold 180 windows, 150 macos, and 80 linux entries. Windows would first exceed the cap at 15 tags (270). +The generator's output is the leg-count authority. Read the workflow's plan +chart before dispatching a large run. Scheduled/tag runs default to two sampled +tags; manual dispatch defaults to three. `tag-count` accepts 1–10. + +`all` selects every source OS. `both`, `update`, and `installer` select Linux +source legs, not Windows plus macOS. `windows-desktop` and `macos-desktop` +select those source/GUI routes. `windows-bundled`, `macos-bundled`, and `bundled` +require their package manifests; they do not expand the source-tag cross product. +`install-ref` selects one exact source baseline. Stable release calls also +exclude the candidate tag so it cannot serve as its own old version. + +Per-leg timeouts and GitHub's matrix limits remain workflow constraints, not +proof that every declared combination ran. Native package acceptance is separate +from the deferred desktop Playwright application suite. Running the drivers locally: don't, except in a disposable VM. The windows driver kills every process named Hermes during teardown and the macos driver operates on `/Applications/Hermes.app`; on a machine with a real Hermes install they will interfere with it. diff --git a/tests/install/e2e-assets/mac-bundled-manifest.cjs b/tests/install/e2e-assets/mac-bundled-manifest.cjs index 49f471b8a5..e292f24b4e 100644 --- a/tests/install/e2e-assets/mac-bundled-manifest.cjs +++ b/tests/install/e2e-assets/mac-bundled-manifest.cjs @@ -150,9 +150,6 @@ function stampAssertions(stamp, side) { if (stamp.tag !== side.tag) { problems.push(`stamp.tag ${JSON.stringify(stamp.tag)} != ${side.tag}`) } - if (stamp.store === true) { - problems.push('stamp.store must not be true for a release bundle') - } return problems } diff --git a/tests/pm/test_activation_runtime.py b/tests/pm/test_activation_runtime.py index 2d2f06f3c9..390bb49e6e 100644 --- a/tests/pm/test_activation_runtime.py +++ b/tests/pm/test_activation_runtime.py @@ -16,15 +16,24 @@ def test_powershell_activation_roundtrip_selected_environment(tmp_path, monkeypa from pm.store import current_target from pm.lock import Lockfile - root = repo_root() + source = repo_root() + root = tmp_path / "checkout" + root.mkdir() + shutil.copytree(source / "pm", root / "pm", ignore=shutil.ignore_patterns("__pycache__")) + (root / "hermes_cli").mkdir() + for relative in ("activate.ps1", "hermes_constants.py", "hermes_cli/__init__.py", + "hermes_cli/runtime_paths.py", "hermes_cli/runtime_state.py"): + shutil.copyfile(source / relative, root / relative) + subprocess.run([sys._base_executable, "-m", "venv", "--without-pip", str(root / ".venv")], + check=True, capture_output=True, timeout=60) home = tmp_path / "home" monkeypatch.setenv("HERMES_HOME", str(home)) facts = runtime_facts_path(root) selected = facts.parent / "environments" / "a" / "venv" site = selected / "Lib" / "site-packages" site.mkdir(parents=True) - (selected / "pyvenv.cfg").write_text("home = test") - facts.write_text(json.dumps({"packages": {"venv": {"environment": str(selected)}}})) + (selected / "pyvenv.cfg").write_text("home = test", encoding="utf-8") + facts.write_text(json.dumps({"packages": {"venv": {"environment": str(selected)}}}), encoding="utf-8") store = tmp_path / "tools" store.mkdir() lock = Lockfile(root / "pm" / "lock.json") @@ -35,7 +44,7 @@ def test_powershell_activation_roundtrip_selected_environment(tmp_path, monkeypa "entry": entry.name, "version": lock.version("python"), "target": target, "artifacts": [a["sha256"] for a in lock.artifacts("python", target)], "env": {"HERMES_ACTIVATE_CANARY": "active"}, - }}})) + }}}), encoding="utf-8") env = dict(os.environ, HERMES_RUNTIME_DIR=str(store), PYTHONPATH="caller-original", PATHEXT=".COM;.EXE;.BAT;.CMD") script = tmp_path / "run.ps1" diff --git a/tests/pm/test_develop_env.py b/tests/pm/test_develop_env.py deleted file mode 100644 index d4c98eab8e..0000000000 --- a/tests/pm/test_develop_env.py +++ /dev/null @@ -1,108 +0,0 @@ -"""`pm develop` must boot the pm STORE python — never the venv. - -Under no-boot-through-venv (pm work item 3) the devshell's `python` -resolves through the store interpreter's bin dir, imports arrive via -PYTHONPATH=;/site-packages (repo first), and VIRTUAL_ENV plus -the venv bin dir are deliberately absent — the venv is only a uv sync -target and pyvenv.cfg is inert dead config. The env composition lives in -``pm.cli._develop_env`` so these are real input→output checks against a -fake store, not source-text assertions. -""" - -from __future__ import annotations - -import json -import os -from pathlib import Path - -import pytest - -import pm.cli as pm_cli -import pm.paths as paths -from pm.store import current_target - - -def _fake_store(tmp_path: Path, monkeypatch, *, with_python: bool = True): - """A store laid out like pm's: facts.json + a python entry. The - interpreter file's bytes don't matter — only the layout does.""" - store = tmp_path / "store" - entry = store / "python-3.11.15+x20260807-win32-arm64" - (entry / "bin").mkdir(parents=True) - if with_python: - (entry / "bin" / "python3").write_bytes(b"MZ") - (entry / "python.exe").write_bytes(b"MZ") - (store / "facts.json").write_text( - json.dumps( - { - "schema": 1, - "packages": { - "python": { - "entry": entry.name, - "version": "3.11.15+x20260807", - } - }, - } - ), - encoding="utf-8", - ) - monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store)) - return store, entry - - -def _fake_repo(tmp_path: Path, monkeypatch) -> Path: - """A fake repo root with a synced venv, substituted for pm.paths.repo_root.""" - repo = tmp_path / "repo" - win = current_target().startswith("win32") - site = repo / "venv" / ("Lib" if win else "lib/python3.14") / "site-packages" - site.mkdir(parents=True) - monkeypatch.setattr(paths, "repo_root", lambda: repo) - return repo - - -def _develop_env(tmp_path, monkeypatch, *, with_python=True): - _fake_store(tmp_path, monkeypatch, with_python=with_python) - repo = _fake_repo(tmp_path, monkeypatch) - win = current_target().startswith("win32") - site = repo / "venv" / ("Lib" if win else "lib/python3.14") / "site-packages" - return pm_cli._develop_env(["faketool"]), repo, site - - -def test_python_resolves_to_the_store_not_the_venv(tmp_path, monkeypatch): - env, _repo, _site = _develop_env(tmp_path, monkeypatch) - - assert env is not None - first_path_entry = Path(env["PATH"].split(os.pathsep)[0]) - # The store python's bin dir leads PATH — the venv bin dir never does. - assert "python-" in str(first_path_entry) - - -def test_pythonpath_is_repo_first_then_venv_site_packages(tmp_path, monkeypatch): - env, repo, site = _develop_env(tmp_path, monkeypatch) - - entries = env["PYTHONPATH"].split(os.pathsep) - assert entries[0] == str(repo) - assert str(site) in entries[1:] - - -def test_venv_boot_markers_are_absent(tmp_path, monkeypatch): - monkeypatch.setenv("VIRTUAL_ENV", str(tmp_path / "somewhere" / "venv")) - monkeypatch.setenv("PYTHONHOME", str(tmp_path / "somewhere" / "python")) - env, _repo, _site = _develop_env(tmp_path, monkeypatch) - - assert env is not None - assert "VIRTUAL_ENV" not in env - assert "PYTHONHOME" not in env - - -def test_venv_bin_dir_is_removed_from_path(tmp_path, monkeypatch): - env, repo, _site = _develop_env(tmp_path, monkeypatch) - win = current_target().startswith("win32") - venv_bin = str(repo / "venv" / ("Scripts" if win else "bin")) - - assert venv_bin not in env["PATH"].split(os.pathsep) - - -def test_no_store_python_yet_means_no_develop_env(tmp_path, monkeypatch): - env, _repo, _site = _develop_env(tmp_path, monkeypatch, with_python=False) - - assert env is None diff --git a/tests/pm/test_receipt.py b/tests/pm/test_receipt.py index 18e7f960a5..d00c8ecf9d 100644 --- a/tests/pm/test_receipt.py +++ b/tests/pm/test_receipt.py @@ -57,7 +57,7 @@ def test_begin_record_finalize_roundtrip(homed): path = receipt.finalize("bisected") assert path is not None and path.is_file() - data = json.loads(path.read_text(encoding="utf-8")) + data = json.loads(path.read_text(encoding="utf-8-sig")) assert data["kind"] == "sync" assert data["outcome"] == "bisected" assert data["venv_rebuild"] == {"ok": True, "reason": ""} @@ -65,6 +65,43 @@ def test_begin_record_finalize_roundtrip(homed): assert data["feature_list"] == ["web", "acp"] +def test_bare_python_can_report_a_failed_bootstrap(tmp_path, monkeypatch): + import os + from pathlib import Path + import subprocess + import sys + + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + repo = Path(__file__).resolve().parents[2] + code = """ +import json +from pm import receipt +from pm.package import InstallError +try: + token = receipt.begin('sync') + try: + raise InstallError('venv', 'original dependency failure') + except InstallError as exc: + receipt.record_step('dependency-sync', False, str(exc)) + raise + finally: + receipt.finalize('failed', 1, token=token) +except InstallError as exc: + assert 'original dependency failure' in str(exc) +row = receipt.latest() +assert row['outcome'] == 'failed' and row['exit_code'] == 1 +assert 'original dependency failure' in row['steps'][0]['detail'] +print(json.dumps(row)) +""" + child = subprocess.run( + [sys.executable, "-S", "-c", code], cwd=repo, env=dict(os.environ), + capture_output=True, text=True, encoding="utf-8", timeout=30, + ) + assert child.returncode == 0, child.stdout + child.stderr + row = json.loads(child.stdout) + assert row["steps"][0]["ok"] is False + + def test_latest_points_at_newest(homed): receipt.begin("sync") receipt.finalize("ok") @@ -360,7 +397,7 @@ def test_nested_update_syncs_do_not_displace_each_other(homed, monkeypatch): assert receipt.last_for_update(inner_id) is None path = ur.finalize_update_receipt("success") assert receipt.last_for_update(outer_id) is None - assert json.loads(path.read_text())["pm_steps"][0]["name"] == "outer-sync" + assert json.loads(path.read_text(encoding="utf-8-sig"))["pm_steps"][0]["name"] == "outer-sync" def test_last_for_update_returns_a_copy(homed): @@ -403,7 +440,7 @@ def test_record_refusal_names_the_policy_conflict(homed): receipt.begin("sync") receipt.record_refusal("lazy-install", "venv out of sync") path = receipt.finalize("failed", 1) - data = json.loads(path.read_text(encoding="utf-8")) + data = json.loads(path.read_text(encoding="utf-8-sig")) assert data["outcome"] == "failed" assert data["refusal"]["code"] == "lazy-install" assert data["refusal"]["detail"] == "venv out of sync" diff --git a/tests/scripts/test_bundle_payload.py b/tests/scripts/test_bundle_payload.py index 706ed62902..bf81711433 100644 --- a/tests/scripts/test_bundle_payload.py +++ b/tests/scripts/test_bundle_payload.py @@ -21,7 +21,7 @@ def test_snapshot_and_manifest_are_shared_by_both_layouts(tmp_path): (source / "pyproject.toml").write_text(project, encoding="utf-8") subprocess.run(["git", "add", "."], cwd=source, check=True) subprocess.run(["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "fixture"], cwd=source, check=True, capture_output=True) - (source / "untracked").write_text("must not ship") + (source / "untracked").write_text("must not ship", encoding="utf-8") for repo_name, target in [("app", "linux-arm64-bionic"), ("hermes-agent", "win32-arm64")]: root = tmp_path / repo_name root.mkdir() @@ -30,7 +30,7 @@ def test_snapshot_and_manifest_are_shared_by_both_layouts(tmp_path): assert project_entries(root / manifest["repo"]) == {"custom": "entry:run"} assert not (root / repo_name / "untracked").exists() assert not (root / repo_name / ".git").exists() - assert json.loads((root / "manifest.json").read_text()) == manifest + assert json.loads((root / "manifest.json").read_text(encoding="utf-8-sig")) == manifest assert release_version(source, "v1.2.3") == "1.2.3" with pytest.raises(ValueError): release_version(source, "v1.2.4") @@ -42,13 +42,13 @@ def test_surfaces_require_complete_outputs_and_replace_stale_files(tmp_path): web = source / "hermes_cli/web_dist" tui.mkdir(parents=True) web.mkdir(parents=True) - (tui / "entry.js").write_text("built tui") - (web / "index.html").write_text("built web") + (tui / "entry.js").write_text("built tui", encoding="utf-8") + (web / "index.html").write_text("built web", encoding="utf-8") plant_surfaces(repo, source) - (repo / "hermes_cli/web_dist/stale").write_text("old") + (repo / "hermes_cli/web_dist/stale").write_text("old", encoding="utf-8") plant_surfaces(repo, source) assert not (repo / "hermes_cli/web_dist/stale").exists() - assert (repo / "hermes_cli/tui_dist/entry.js").read_text() == "built tui" + assert (repo / "hermes_cli/tui_dist/entry.js").read_text(encoding="utf-8-sig") == "built tui" (web / "index.html").unlink() with pytest.raises(FileNotFoundError): plant_surfaces(repo, source) @@ -80,14 +80,16 @@ def test_launcher_stage_reads_declared_entries_and_drops_stale_names(tmp_path, m (repo / "pyproject.toml").write_text('[project.scripts]\ncustom="entry:run"\n', encoding="utf-8") Facts(tools / "facts.json").record("python", "3.11.16", "python", {}, tools) manifest = write_manifest(root, target=current_target(), repo="app") + site = "venv/Lib/site-packages" if current_target().startswith("win32") else "venv/lib/python3.11/site-packages" + (root / site).mkdir(parents=True) (root / "bin").mkdir() - (root / "bin/removed-entry").write_text("old") + (root / "bin/removed-entry").write_text("old", encoding="utf-8") monkeypatch.setattr("pm.registry.get_package", lambda _: SimpleNamespace(binary=lambda *args: interpreter)) calls = [] def mint(argv, *, env, check): calls.append(json.loads(env["HERMES_MINT_SPECS"])) - compile(Path(env["HERMES_MINT_WRAPPER"]).read_text(), "wrapper", "exec") + compile(Path(env["HERMES_MINT_WRAPPER"]).read_text(encoding="utf-8-sig"), "wrapper", "exec") (root / "bin/custom.exe").write_bytes(b"mint fixture") assert stage_launchers(root, manifest, run=mint) == ["custom"] @@ -96,7 +98,13 @@ def test_launcher_stage_reads_declared_entries_and_drops_stale_names(tmp_path, m assert calls == [[{"name": "custom", "module": "entry", "func": "run"}]] else: assert (root / "bin/custom").is_file() - assert json.loads((root / "manifest.json").read_text())["launchers"] == ["custom"] + published = json.loads((root / "manifest.json").read_text(encoding="utf-8-sig")) + assert published["launchers"] == ["custom"] + assert published["runtime"] == { + "repoDir": "app", "toolsDir": "tools", "storePython": "tools/python/python.exe", + "sitePackages": site, + "commands": {"custom": "bin/custom.exe" if current_target().startswith("win32") else "bin/custom"}, + } @pytest.mark.platforms("posix") @@ -106,13 +114,13 @@ def test_relocation_preserves_sibling_and_framework_links(tmp_path): venv = root / "venv/bin" store.mkdir(parents=True) venv.mkdir(parents=True) - (store / "python3").write_text("interpreter") + (store / "python3").write_text("interpreter", encoding="utf-8") (venv / "python").symlink_to("/builder/tools/python/bin/python3") (venv / "python3").symlink_to("python") framework = root / "tools/framework" framework.symlink_to("python/bin/python3") assert relativize_links(root) == 1 - assert (venv / "python3").read_text() == "interpreter" + assert (venv / "python3").read_text(encoding="utf-8-sig") == "interpreter" assert os.readlink(venv / "python3") == "python" assert os.readlink(framework) == "python/bin/python3" assert relativize_links(root) == 0 @@ -128,9 +136,9 @@ def test_both_launchers_keep_active_home_and_call_declared_function(tmp_path, ta (root / "bin").mkdir(parents=True) (root / "app").mkdir() (root / "python").symlink_to(sys.executable) - (root / "app/entry.py").write_text("import json,os,sys\ndef run():\n print(json.dumps([os.environ['HERMES_HOME'],sys.argv[1:]])); return 7\n") + (root / "app/entry.py").write_text("import json,os,sys\ndef run():\n print(json.dumps([os.environ['HERMES_HOME'],sys.argv[1:]])); return 7\n", encoding="utf-8") launcher = root / "bin/custom" - launcher.write_text(posix_launcher("custom", "entry:run", python="python", repo="app", site="deps", target=target)) + launcher.write_text(posix_launcher("custom", "entry:run", python="python", repo="app", site="deps", target=target), encoding="utf-8") result = subprocess.run(["sh", str(launcher), "two words", "$(nope)", ""], cwd=tmp_path, env={**os.environ, "HERMES_HOME": str(tmp_path / "custom/profiles/memory")}, capture_output=True, text=True) assert result.returncode == 7, result.stderr diff --git a/tests/scripts/test_write_install_stamp.py b/tests/scripts/test_write_install_stamp.py index 87de350058..c442cb7db3 100644 --- a/tests/scripts/test_write_install_stamp.py +++ b/tests/scripts/test_write_install_stamp.py @@ -79,7 +79,8 @@ def test_distribution_defaults_to_null(): assert stamp["distribution"] is None -def test_cli_accepts_desktop_app_distribution(tmp_path): +@pytest.mark.parametrize("mechanism", ["electron-updater", "app-installer", "external"]) +def test_cli_stamp_is_accepted_by_runtime_readers(tmp_path, monkeypatch, mechanism): import json import subprocess import sys @@ -94,10 +95,19 @@ def test_cli_accepts_desktop_app_distribution(tmp_path): "--output", str(out), "--commit", "d" * 40, "--distribution", "desktop-app", - "--update-mechanism", "electron-updater", + "--update-mechanism", mechanism, ], capture_output=True, text=True, ) assert result.returncode == 0, result.stderr - assert json.loads(out.read_text(encoding="utf-8-sig"))["distribution"] == "desktop-app" + data = json.loads(out.read_text(encoding="utf-8-sig")) + assert data["distribution"] == "desktop-app" + assert data["updateMechanism"] == mechanism + from hermes_cli.version_info import _stamp_version_info + from hermes_cli.venv_sync import _is_sealed + + out.rename(tmp_path / "install-stamp.json") + monkeypatch.setenv("HERMES_INSTALL_ROOT", str(tmp_path)) + assert _stamp_version_info() is not None + assert _is_sealed(tmp_path) diff --git a/tests/test_install_ps1_managed_python_provenance.py b/tests/test_install_ps1_managed_python_provenance.py index 8969623025..30533c7ad5 100644 --- a/tests/test_install_ps1_managed_python_provenance.py +++ b/tests/test_install_ps1_managed_python_provenance.py @@ -1,9 +1,10 @@ -"""Installer delegates dependency selection to the pinned PM bootstrap.""" +"""The real installer waits for bootstrap uv before it delegates to PM.""" import json import os from pathlib import Path import shutil import subprocess +import sys import pytest @@ -15,36 +16,56 @@ INSTALLER = Path(__file__).resolve().parents[1] / "scripts" / "install.ps1" def test_python_stage_delegates_to_pin_without_touching_existing_env(tmp_path, exit_code): powershell = shutil.which("powershell") assert powershell - install = tmp_path / "install" - (install / "pm").mkdir(parents=True) - (install / "pm" / "lock.json").write_text(json.dumps({"packages": {"python": {"version": "3.12.8+fixture"}}})) + install = tmp_path / "install with spaces" + package = install / "pm" + package.mkdir(parents=True) + (package / "lock.json").write_text(json.dumps({"packages": {"python": {"version": "3.12.8+fixture"}}}), encoding="utf-8") + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "cli.py").write_text( + "import json, os, pathlib, sys\n" + "assert not pathlib.Path(os.environ['UV_BUSY']).exists()\n" + "pathlib.Path(os.environ['PM_LOG']).write_text(json.dumps(sys.argv[1:]))\n" + "sys.exit(int(os.environ['PROBE_EXIT']))\n", encoding="utf-8", + ) existing = install / "venv" / "sentinel" existing.parent.mkdir() - existing.write_text("previous generation") - log = tmp_path / "uv-call.json" + existing.write_text("previous generation", encoding="utf-8") + log = tmp_path / "uv-calls.jsonl" + pm_log = tmp_path / "pm-call.json" wrapper = tmp_path / "boundary.ps1" wrapper.write_text(r''' param([string]$Installer, [string]$InstallDir, [string]$HomeDir, [string]$Log) $ErrorActionPreference = 'Stop' function uv { - ConvertTo-Json -Compress -InputObject @($args) | Set-Content -Encoding UTF8 $Log - $global:LASTEXITCODE = [int]$env:PROBE_EXIT -} -function Get-Command { - param([string]$Name) - if ($Name -eq 'uv') { return [pscustomobject]@{ Source = 'uv' } } - Microsoft.PowerShell.Core\Get-Command $Name + Set-Content -Path $env:UV_BUSY -Value 'running' + try { + ConvertTo-Json -Compress -InputObject @($args) | Add-Content -Encoding UTF8 $Log + switch ($args[1]) { + 'install' { } + 'find' { Write-Output $env:BOOTSTRAP_PYTHON } + default { throw 'PM must not be a child of the bootstrap uv' } + } + $global:LASTEXITCODE = 0 + } finally { Remove-Item $env:UV_BUSY } } function Invoke-WebRequest { throw 'network access outside test boundary' } -# Load the definitions, then execute the real stage dispatcher. . $Installer -InstallDir $InstallDir -HermesHome $HomeDir +# Dot-sourcing defines Get-Uv, so override only that acquisition boundary. +function Get-Uv { return 'uv' } Invoke-StageByName 'python-deps' exit $LASTEXITCODE ''', encoding="utf-8-sig") + # uv python find returns the distribution interpreter, not a venv launcher. + env = dict(os.environ, PROBE_EXIT=str(exit_code), BOOTSTRAP_PYTHON=sys._base_executable, + PM_LOG=str(pm_log), UV_BUSY=str(tmp_path / "uv-busy"), PATHEXT=".COM;.EXE;.BAT;.CMD") + env.pop("PYTHONPATH", None) run = subprocess.run([powershell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(wrapper), "-Installer", str(INSTALLER), "-InstallDir", str(install), "-HomeDir", str(tmp_path / "home"), "-Log", str(log)], - cwd=tmp_path, env=dict(os.environ, PROBE_EXIT=str(exit_code)), stdin=subprocess.DEVNULL, + cwd=tmp_path, env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=120) assert (run.returncode == 0) == (exit_code == 0), run.stdout + run.stderr - assert json.loads(log.read_text(encoding="utf-8-sig")) == ["run", "--no-project", "--python", "3.12", "python", "-m", "pm.cli", "install"] - assert existing.read_text() == "previous generation" + assert [json.loads(line) for line in log.read_text(encoding="utf-8-sig").splitlines()] == [ + ["python", "install", "--no-bin", "3.12"], ["python", "find", "--managed-python", "3.12"], + ] + assert json.loads(pm_log.read_text(encoding="utf-8-sig")) == ["install"] + assert existing.read_text(encoding="utf-8-sig") == "previous generation" diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index f93ac7bfbf..22888d6023 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -17,6 +17,26 @@ def _load_package_data(): return tool["setuptools"]["package-data"] +def test_direct_overrides_preserve_the_declared_exact_version(): + from packaging.requirements import Requirement + + root = Path(__file__).resolve().parents[1] + metadata = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8-sig")) + locked = tomllib.loads((root / "uv.lock").read_text(encoding="utf-8-sig")) + direct = {req.name: req for spec in metadata["project"]["dependencies"] + if (req := Requirement(spec)).specifier} + for spec in metadata["tool"]["uv"].get("override-dependencies", []): + override = Requirement(spec) + requirement = direct.get(override.name) + if requirement is None or not any(s.operator == "==" for s in requirement.specifier): + continue + assert override.specifier == requirement.specifier, ( + f"{override.name}: override {override.specifier} contradicts exact requirement {requirement.specifier}" + ) + versions = {row["version"] for row in locked["package"] if row["name"] == override.name} + assert versions and all(version in requirement.specifier for version in versions) + + def test_matrix_extra_not_in_all(): """The [matrix] extra pulls `mautrix[encryption]` -> `python-olm`, which has Linux-only wheels and no native build path on Windows or @@ -158,7 +178,7 @@ def _uv_lock_versions(package: str) -> set[str]: import re lock_path = Path(__file__).resolve().parents[1] / "uv.lock" - lock = lock_path.read_text(encoding="utf-8") + lock = lock_path.read_text(encoding="utf-8-sig") return { m.group(1) for m in re.finditer( diff --git a/tools/wakewords/README.md b/tools/wakewords/README.md index d85f05eee3..e6fe35a11a 100644 --- a/tools/wakewords/README.md +++ b/tools/wakewords/README.md @@ -12,11 +12,12 @@ required to say "hey hermes". TTS-generated speech), which produces the `.tflite` artifact. Redistribution is permitted under the openWakeWord license. - **Label:** the model registers as `hey_hermes` (matches the filename). -- **Runtime:** the shared feature-extraction models (melspectrogram + - embedding) are bundled inside the `pyopen-wakeword` wheel — byte-identical - to the official openWakeWord v0.5.1 files, so scores match the original - engine exactly. +- **Runtime:** the `pyopen-wakeword` wheel includes the shared + melspectrogram and embedding models. Starting this engine requires no + model download. Identical model files alone do not establish identical + scores across inference engines or platforms. -To use a different phrase, train your own model and point -`wake_word.openwakeword.model` at its `.tflite` path. See the wake-word docs -for the training guide. +To use a different phrase, point `wake_word.openwakeword.model` at an +absolute path to a compatible `.tflite` model. Hermes does not download +models by name, and this engine does not load `.onnx` files. See the +wake-word docs for the training guide and platform limits. diff --git a/ui-tui/README.md b/ui-tui/README.md index 58910fe5b6..f1e67ab9be 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -16,7 +16,10 @@ The client entrypoint is `src/entry.tsx`. It exits early if `stdin` is not a TTY python -m tui_gateway.entry ``` -Interpreter resolution order is: `HERMES_PYTHON` → `PYTHON` → `$VIRTUAL_ENV/bin/python` → `./.venv/bin/python` → `./venv/bin/python` → `python3` (or `python` on Windows). +Interpreter resolution uses `HERMES_PYTHON`, supplied by the CLI launcher or +Nix wrapper. Direct development runs without that value use `python3` on PATH, +or `python` on Windows. The TUI does not search `PYTHON`, `VIRTUAL_ENV`, or +checkout venv directories for a different interpreter. The transport is newline-delimited JSON-RPC over stdio: diff --git a/uv.lock b/uv.lock index b03d98e4be..8b284bfac3 100644 --- a/uv.lock +++ b/uv.lock @@ -110,7 +110,7 @@ dingtalk-stream = false [manifest] overrides = [ - { name = "cryptography", specifier = ">=50,<51" }, + { name = "cryptography", specifier = "==50.0.1" }, { name = "pynacl", specifier = ">=1.6,<1.7" }, ] @@ -1859,7 +1859,7 @@ requires-dist = [ { name = "certifi", specifier = "==2026.5.20" }, { name = "concurrent-log-handler", marker = "sys_platform == 'win32'", specifier = "==0.9.29" }, { name = "croniter", specifier = "==6.0.0" }, - { name = "cryptography", specifier = "==50.0.0" }, + { name = "cryptography", specifier = "==50.0.1" }, { name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" }, { name = "debugpy", marker = "extra == 'dev'", specifier = "==1.8.20" }, { name = "defusedxml", marker = "extra == 'wecom'", specifier = "==0.7.1" }, diff --git a/website/docs/developer-guide/contributing.md b/website/docs/developer-guide/contributing.md index 5cd33fb56c..d30d431c7d 100644 --- a/website/docs/developer-guide/contributing.md +++ b/website/docs/developer-guide/contributing.md @@ -34,109 +34,112 @@ We value contributions in this order: | Requirement | Notes | | -------------------- | --------------------------------------------------------------------------------------------- | | **Git** | With the `git-lfs` extension installed | -| **Python 3.14** | uv will install it if missing | +| **Python 3.14** | The project requires `>=3.14,<3.15`. PM provides the pinned interpreter. | | **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) | -| **Node.js 26+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) | +| **Node.js** | Use the PM pin or a version accepted by root `package.json` engines | -### Install with the standard installer +### PM developer environment -For most contributors, the best development bootstrap is the same path users -take: run the standard installer, then work inside the repository it cloned. -The installer creates the Hermes venv, wires the `hermes` command, stamps the -install method for `hermes update`, and clones the full git project into -`$HERMES_HOME/hermes-agent` (usually `~/.hermes/hermes-agent`). That keeps your -development environment on the same layout the CLI, updater, lazy dependency -installer, gateway, and docs assume. +Use the [PM developer workflow](/reference/package-management#developer-workflow) for preparation, activation, everyday commands, +dependency changes, and test environments. Select your development +home before setup so experimental code does not migrate production data. + +After successful setup, activate from the repository root in each new shell. + +Bash: ```bash -curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent" - -# Add dev/test extras on top of the standard install. -uv pip install -e ".[all,dev]" - -# Optional: browser tools / docs site dependencies. -npm install +source ./activate +python hermes --version ``` -After that, create branches and run tests from that checkout: +PowerShell: + +```powershell +. .\activate.ps1 +python hermes --version +``` + +Run `python hermes` for this checkout, not a global `hermes` alias. PM activation +adds installed tools and the selected dependency tree. It does not install +packages or JS workspaces. `deactivate` restores the prior shell environment. + +### Manual development and test environment {#manual-development-and-test-environment} + +Use Python 3.14 (`>=3.14,<3.15`). Keep a development environment outside the +source tree if an agent will operate on that checkout. Leave PM activation +before this sequence. Keep the same development `HERMES_HOME` when running it. + +This environment is for tests and editor tools. Its own interpreter includes +pytest without relying on PM's `PYTHONPATH`. On Windows, initialize the native +C++ build environment for your architecture before building source dependencies. + +POSIX: ```bash -git checkout -b fix/description -scripts/run_tests.sh +uv venv "$HOME/.hermes/venvs/hermes-dev" --python 3.14 +export UV_PROJECT_ENVIRONMENT="$HOME/.hermes/venvs/hermes-dev" +uv sync --locked --extra all --extra dev +export HERMES_PYTHON="$UV_PROJECT_ENVIRONMENT/bin/python" +"$HERMES_PYTHON" hermes --version ``` -You can also run a fully isolated Hermes instance (throwaway HERMES_HOME, separate Electron -userData, distinct Electron app name to avoid the single-instance lock): +PowerShell: + +```powershell +$devEnv = Join-Path $env:LOCALAPPDATA 'hermes-dev-env' +uv venv $devEnv --python 3.14 +$env:UV_PROJECT_ENVIRONMENT = $devEnv +uv sync --locked --extra all --extra dev +$env:HERMES_PYTHON = Join-Path $devEnv 'Scripts/python.exe' +& $env:HERMES_PYTHON hermes --version +``` + +This environment is for source development and tests. It does not replace +PM's tool store or a packaged app's dependency selection. +Run `uv pip check --python` with this environment's interpreter to check its dependencies. +Do not install into an MSIX payload or point a bundled app at this environment. + +For an isolated development instance, select a disposable `HERMES_HOME` before +starting the source command. Use `python hermes setup` to configure it rather +than copying production credentials into the checkout. + +### JavaScript workspaces and website + +From the repository root, run `npm ci` for the desktop, TUI, dashboard, and +shared JS workspaces. The website is separate: ```bash -scripts/dev-sandbox.sh python -m hermes_cli.main -scripts/dev-sandbox.sh --persistent python -m hermes_cli.main desktop # state survives restarts, but lives in the worktree :) +npm ci --prefix website +npm run build:fast --prefix website ``` -### Manual clone fallback +Use a Node/npm version accepted by the corresponding `package.json` engines. +Native desktop dependencies can also require the platform build toolchain. -Use this only if you intentionally do not want Hermes' managed install layout -(for example, a throwaway clone inside a container or CI job). If you install -this way, make sure you run the `hermes` entrypoint from this venv; running the -system `python3 -m hermes_cli.main` can pick up unrelated system Python -packages. +Logos and icons are generated from `assets/nous-girl-*.svg` and +`assets/backgrounds/`. `node scripts/generate-icons.mjs` uses the locked, +isolated `icon-build` dependency group. Do not commit generated PNG/ICO/ICNS +outputs or add icon renderers to production dependencies. -Create the venv **outside** the cloned source tree. A venv that lives inside -the directory the agent operates from can be wiped by a relative-path command -the agent runs against its own checkout (`rm -rf venv`, `uv venv venv`, etc.), -which silently destroys the running runtime mid-session. Keeping it outside the -tree means no relative path from the workspace resolves to it. +### Run tests -```bash -git clone https://github.com/NousResearch/hermes-agent.git -cd hermes-agent - -# Create venv with Python 3.14, OUTSIDE the source tree -uv venv ~/.hermes/venvs/hermes-dev --python 3.14 -export VIRTUAL_ENV="$HOME/.hermes/venvs/hermes-dev" -export PATH="$VIRTUAL_ENV/bin:$PATH" - -# Install with all extras (messaging, cron, CLI menus, dev tools) -uv pip install -e ".[all,dev]" - -# Optional: browser tools -npm install -``` - -### Configure for Development - -```bash -mkdir -p ~/.hermes/{cron,sessions,logs,memories,skills} -cp cli-config.yaml.example ~/.hermes/config.yaml -touch ~/.hermes/.env - -# Add at minimum an LLM provider key: -echo 'OPENROUTER_API_KEY=sk-or-v1-your-key' >> ~/.hermes/.env -``` - -### Run - -```bash -# The standard installer already put `hermes` on PATH. -hermes doctor -hermes chat -q "Hello" -``` - -If you used the manual clone fallback, run `./hermes` from the checkout or -symlink this clone's venv explicitly: - -```bash -mkdir -p ~/.local/bin -ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes -``` - -### Run Tests +Use the canonical runner on every host: ```bash scripts/run_tests.sh +scripts/run_tests.sh tests/agent/ -v ``` +On Windows, run the script through Bash. When no local `.venv` or `venv` +contains pytest, the runner accepts the explicit `HERMES_PYTHON` above. It +clears credentials, isolates `HERMES_HOME`, and runs each test file in a separate +subprocess through `scripts/run_tests_parallel.py`. It does not use xdist. + +Run the relevant JS workspace checks for JS changes. Native install/update +E2E runs on disposable CI hosts, never against the developer's live app. +See [Package management](../reference/package-management.md) for PM commands and runtime ownership. + ## Code Style - **PEP 8** with practical exceptions (no strict line length enforcement) @@ -147,14 +150,14 @@ scripts/run_tests.sh ## Cross-Platform Compatibility -See **[Platform Support](../getting-started/platform-support.md)**. Native Windows uses Git Bash (from [Git for Windows](https://git-scm.com/download/win)) for shell commands. A few features require POSIX kernel primitives and are gated: the dashboard's embedded PTY terminal pane (`/chat` tab) needs a POSIX PTY (Linux, macOS, or WSL2). If you're doing Windows-heavy dev, run the Windows-footgun lint (`scripts/check-windows-footguns.py`) before pushing. +See **[Platform Support](../getting-started/platform-support.md)**. Native Windows uses Git Bash (from [Git for Windows](https://git-scm.com/download/win)) for shell commands. The dashboard uses POSIX PTYs on Unix and the `pywinpty`/ConPTY bridge on Windows. Availability depends on that host's native dependency support. If you're doing Windows-heavy dev, run the Windows-footgun lint (`scripts/check-windows-footguns.py`) before pushing. When contributing code, keep these rules in mind: - **Don't add unguarded `signal.SIGKILL` references.** It's not defined on Windows. Either route through `gateway.status.terminate_pid(pid, force=True)` (the centralized primitive that does `taskkill /T /F` on Windows and SIGKILL on POSIX), or fall back with `getattr(signal, "SIGKILL", signal.SIGTERM)`. -- **Catch `OSError` alongside `ProcessLookupError` on `os.kill(pid, 0)` probes.** Windows raises `OSError` (WinError 87, "parameter is incorrect") for an already-gone PID instead of `ProcessLookupError`. +- **Use `psutil.pid_exists()` for process liveness.** Do not use `os.kill(pid, 0)` on Windows; it is not a safe probe. - **Don't force the terminal to POSIX semantics.** `os.setsid`, `os.killpg`, `os.getpgid`, `os.fork` all raise on Windows — gate them with `if sys.platform != "win32":` or `if os.name != "nt":`. -- **Open files with an explicit `encoding="utf-8"`.** The Python default on Windows is the system locale (often cp1252), which mojibakes or crashes on non-Latin text. +- **Use explicit text encodings.** User-authored UTF-8 reads use `utf-8-sig` to accept a leading BOM. Writes use `utf-8` without adding a BOM. - **Use `pathlib.Path` / `os.path.join` — never manually concat with `/`.** This matters less for strings the OS gives us back and more for strings we construct to hand to subprocesses. Key patterns: diff --git a/website/docs/developer-guide/memory-provider-plugin.md b/website/docs/developer-guide/memory-provider-plugin.md index 75ceb2eb6a..99bef87a1f 100644 --- a/website/docs/developer-guide/memory-provider-plugin.md +++ b/website/docs/developer-guide/memory-provider-plugin.md @@ -418,7 +418,7 @@ Only **one** external memory provider can be active at a time. If a user tries t For wrapper-style providers that keep their runtime in a sidecar venv outside Hermes-managed Python (no dependency surface — no `pyproject.toml`, `pip_dependencies`, or `python_dependencies` — at the scanned plugin root; a `pyproject.toml` belonging solely to an external or nested sidecar is not scanned): - **Location.** `$HERMES_HOME/plugins//` is the profile-scoped plugin location, and `HERMES_HOME` follows the active context override, then `$HERMES_HOME`, then the platform default. Propagate `HERMES_HOME` when launching the wrapper or sidecar so profile isolation holds; `MemoryManager.initialize_all` injects the active `hermes_home` into every provider. -- **Survival.** Ordinary Hermes updates — including managed-venv rebuild/replacement by pm — do not delete or rewrite `$HERMES_HOME/plugins/**`. An installed wrapper directory and its marker file (e.g. `mnemosyne-wrapper.json`) survive. Explicit deletion flows (`hermes uninstall`, `hermes plugins remove`, profile deletion, user deletion) are excluded from this guarantee. +- **Survival.** Ordinary Hermes updates — including managed-venv rebuild/replacement by pm — do not delete or rewrite `$HERMES_HOME/plugins/**`. An installed wrapper directory and its marker file (e.g. `mnemosyne-wrapper.json`) survive. Explicit plugin updates and deletion flows (`hermes uninstall`, `hermes plugins remove`, profile deletion, user deletion) are excluded from this guarantee. - **Sidecar isolation.** A plugin root with no dependency surface never joins the pm workspace dependency union; a resync or venv rebuild neither provisions deps for it nor touches its tree. -- **Conflicts.** For native shared-venv plugins, an unsatisfiable dependency union fails loudly: the candidate plugin stays unenabled and unimported (the admission authority refuses before publishing config, reporting the plugin identity plus the resolver's reason, with a re-enable/retry path and a machine-readable pm receipt). Nothing disables plugins automatically today — there is no automatic bisect. A future automatic-disable decision, if one is added, must prefer the active memory provider over ordinary plugins. +- **Conflicts.** For native shared-venv plugins, an unsatisfiable dependency union fails loudly: the candidate plugin stays unenabled and unimported (the admission authority refuses before publishing config, reporting the plugin identity plus the resolver's reason, with a re-enable/retry path and a machine-readable pm receipt). Dependency resolution does not automatically disable other plugins or run a bisect. Explicit plugin updates, removal, and independent security gates are separate operations. diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index a8d056c77c..51ab6d7a35 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -778,32 +778,48 @@ Both formats can be mixed in the same list. Already-set variables are skipped si ### Lazy-install optional Python dependencies -If your plugin wraps an SDK that not every user will have installed (a vendor SDK, a heavy ML lib, a platform-specific package), don't `import` it at the top of the module. Use `pm.ensure_import()` inside the tool handler — Hermes syncs the venv with that extra on first use, gated by the user's `security.allow_lazy_installs` config. +For an SDK covered by a Hermes project extra, use `pm.ensure_import` at the +operation that needs it. Use `pm.available` for a read-only availability check. +Do not install dependencies from a frequently polled `check_fn`. + +This example requests the existing `bedrock` extra: ```python -# tools.py -from pm import ensure_import as ensure, InstallError as FeatureUnavailable +from pm import InstallError, ensure_import def my_tool_handler(args, **kwargs): try: - ensure("my-plugin.my-backend") # key must be in LAZY_DEPS - except FeatureUnavailable as exc: + ensure_import("bedrock") + except InstallError as exc: return {"error": str(exc)} - import my_backend_sdk # safe now - ... + import boto3 + # Use the SDK here. ``` -Two rules from the security model in `pm`: +The argument is a `pyproject.toml` extra name. It is not an arbitrary package +specification or a plugin-qualified key. The old `LAZY_DEPS` registry and +`FeatureUnavailable` exception no longer exist. -| Rule | Why | -|---|---| -| Your feature key must appear in the in-tree `LAZY_DEPS` allowlist | Prevents a malicious config from coaxing Hermes into installing arbitrary packages — only specs Hermes itself ships are eligible | -| Specs are PyPI-by-name only | No `--index-url`, `git+https://`, or file: paths. Pin versions with PEP 440 (`"my-sdk>=1.2,<2"`) inside the allowlist entry | +If a new environment is selected, the helper can report a required restart. +Return that error instead of importing from a second environment inside the +running process. Already available dependencies need no installation, even +when `security.allow_lazy_installs` is false. -For third-party plugins distributed via pip, declare the optional deps as `[project.optional-dependencies]` extras in your own `pyproject.toml` and tell users to `pip install your-plugin[backend]` — that path doesn't go through pm. The on-demand install is most useful for **bundled** plugins where shipping a hard dependency on every install would bloat the base Hermes footprint. +For a directory plugin's own Python dependencies, declare `dependencies` under +`[project]` in its `pyproject.toml`. Legacy `pip_dependencies` and +`python_dependencies` lists in `plugin.yaml` also join the PM workspace. +PM prepares their dependencies together with core requirements before enabling +the plugin. The generated workspace does not rewrite the plugin directory or +the shipped lockfile. Dependency conflicts refuse admission and preserve the +previous selection; PM does not automatically disable other plugins. -When `security.allow_lazy_installs: false` is set globally, `ensure()` raises `FeatureUnavailable` immediately with a remediation hint — your plugin should catch it and degrade gracefully (return an error result, not crash the tool loop). +Dependencies installed manually with pip are not durable PM declarations. +A later environment replacement need not retain them. Wrapper plugins whose +Python runtimes remain outside PM can use the +[memory-provider survival contract](../memory-provider-plugin.md#hermes_home-survival-contract-what-wrappers-can-rely-on). +See [Package management](../../reference/package-management.md) for the +runtime layout and lazy-install policy. @@ -1696,7 +1712,7 @@ NixOS users can install your plugin declaratively if you provide a `pyproject.to ```nix # User's configuration.nix services.hermes-agent.extraPythonPackages = [ - (pkgs.python312Packages.buildPythonPackage { + (config.services.hermes-agent.package.python.pkgs.buildPythonPackage { pname = "my-plugin"; version = "1.0.0"; src = pkgs.fetchFromGitHub { @@ -1706,7 +1722,7 @@ services.hermes-agent.extraPythonPackages = [ hash = "sha256-..."; # nix-prefetch-url --unpack }; format = "pyproject"; - build-system = [ pkgs.python312Packages.setuptools ]; + build-system = [ config.services.hermes-agent.package.python.pkgs.setuptools ]; }) ]; ``` diff --git a/website/docs/developer-guide/web-search-provider-plugin.md b/website/docs/developer-guide/web-search-provider-plugin.md index 9f710b727c..5d297f1a96 100644 --- a/website/docs/developer-guide/web-search-provider-plugin.md +++ b/website/docs/developer-guide/web-search-provider-plugin.md @@ -233,7 +233,14 @@ Errors surface as the tool result; the LLM decides how to explain them. If no pr ## Lazy-installing optional dependencies -If your provider wraps a third-party SDK (like DDGS does with the `ddgs` package), don't `import` it at module top level. Use `pm.ensure_import()` inside `is_available()` or `search()` — Hermes installs the extra on first use, gated by `security.allow_lazy_installs`. See [Build a Hermes Plugin → Lazy-install](/developer-guide/plugins#lazy-install-optional-python-dependencies) for the security model. +Keep availability checks read-only. For an SDK covered by a Hermes extra, +use `pm.available("extra-name")` in `is_available()`. Request +`pm.ensure_import("extra-name")` from the operation that needs it. Report +`InstallError`, including a required restart, to the caller. + +Declare a third-party plugin's own dependencies in its manifest or +`pyproject.toml` rather than inventing a Hermes extra. See +[Build a Hermes Plugin → Lazy-install](/developer-guide/plugins#lazy-install-optional-python-dependencies). ## Reference implementations diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index 51c077ff27..2ca4b90c24 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -1,7 +1,7 @@ --- sidebar_position: 2 title: "Installation" -description: "Install Hermes Agent on Linux, macOS, WSL2, or native Windows" +description: "Install Hermes Agent with desktop bundles, source installers, Docker, Nix, or the Termux APT package" --- # Installation @@ -14,8 +14,24 @@ platform-gated features are supported), see **[Platform Support](./platform-supp ::: ## Quick Install -### With the Hermes Desktop installer on macOS or Windows (recommended) -To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/) from our website and run it. +### Desktop packages on macOS or Windows + +Download the package for your platform from the +[Hermes website](https://hermes-agent.nousresearch.com/). + +- **Windows:** open the `.appinstaller` download with Windows App Installer. + It installs the signed MSIX bundle and records its update source. + Microsoft Store packages have separate Store ownership. +- **macOS:** open the DMG, then copy `Hermes.app` to Applications. The ZIP + artifact carries the signed app used by the automatic updater. + +Bundled packages contain the agent, Python, supported dependencies, and prebuilt +interfaces. First launch does not build that base runtime. Provider access and +optional integrations can still require network access. + +A `Hermes-Setup` bootstrap installer is different: it downloads a source +installation and builds the desktop app. Light is a remote-only build variant, +not a bundled local runtime. See [Hermes Desktop](../user-guide/desktop.md). ### Without Hermes Desktop: For a command-line only install without Hermes Desktop, run: @@ -37,20 +53,42 @@ If you want to install & run Hermes Desktop after a command-line only install, s hermes desktop ``` -### What the Installer Does +### Android / Termux -The installer handles everything automatically — all dependencies (Python, Node.js, ripgrep, ffmpeg), the repo clone, virtual environment, global `hermes` command setup, and LLM provider configuration. By the end, you're ready to chat. +Use the [Termux APT package](./termux.md) on aarch64 Android devices. +Configure its signed repository before running `pkg install hermes-agent`. +The desktop/server scripts are not the Termux installation path. -#### Install Layout +### What the source installer does -Where the installer puts things depends on whether you're installing as a normal user or as root: +The scripts clone the source, bootstrap uv, and delegate dependency preparation +to PM. PM provides pinned Python, Node.js, npm, ripgrep, and FFmpeg. The source +installation selects the `all` Python extra, not every optional extra. +Browsers and other optional tools use their feature-specific installation paths. -| Installer | Code lives at | `hermes` binary | Data directory | -| -------------------------------------- | ------------------------------ | --------------------------------------- | ------------------------------------ | -| Per-user (git installer) | `~/.hermes/hermes-agent/` | `~/.local/bin/hermes` (symlink) | `~/.hermes/` | -| Root-mode (`sudo curl … \| sudo bash`) | `/usr/local/lib/hermes-agent/` | `/usr/local/bin/hermes` | `/root/.hermes/` (or `$HERMES_HOME`) | +The scripts create a launcher and prepare the data directory. Interactive runs +also invoke setup and gateway configuration. `--non-interactive` on POSIX, or +`-NonInteractive` on Windows, skips stages that need input. The optional +`--include-desktop` / `-IncludeDesktop` stage builds the desktop from source. -The root-mode **FHS layout** (`/usr/local/lib/…`, `/usr/local/bin/hermes`) matches where other system-wide developer tools land on Linux. It's useful for shared-machine deployments where one system install should serve every user. Per-user config (auth, skills, sessions) still lives under each user's `~/.hermes/` or explicit `HERMES_HOME`. +#### Install layout + +| Method | Code | CLI entry point | Default user data | +|---|---|---|---| +| POSIX source script | `~/.hermes/hermes-agent/` | `~/.local/bin/hermes` wrapper | `~/.hermes/` | +| Windows source script | `%LOCALAPPDATA%\hermes\hermes-agent\` | `%LOCALAPPDATA%\hermes\bin\` | `%LOCALAPPDATA%\hermes\` | +| Desktop bundle | Inside the installed app package | Packaged launchers; Windows execution aliases | Platform default Hermes data directory | +| Docker | `/opt/hermes/` | Image entrypoint and `hermes` shim | Mounted `/opt/data/` | +| Termux APT | `$PREFIX/lib/hermes-agent/` | Symlinks in `$PREFIX/bin/` | `~/.hermes/` | + +`HERMES_HOME` selects user data. The POSIX script's `--dir` selects its source +checkout independently. Windows provides `-HermesHome` and `-InstallDir`. +Running the POSIX script as root does not select an automatic FHS layout: +it uses root's home unless you provide an explicit source path. + +PM's tool store and per-install Python generations have separate lifetimes. +See [Package management](../reference/package-management.md) for their locations. +Do not remove the data root to repair an application installation. ### After Installation @@ -90,17 +128,18 @@ You don't need to rebuild your setup from scratch. Restore a full backup with `h ## Prerequisites -**Installer:** On non-Windows platforms, the only prerequisite is **Git**. On Linux, also make sure `curl` and `xz-utils` are available (the installer downloads Node.js as a `.tar.xz` archive). The desktop app additionally requires `g++` (or `build-essential` on Debian/Ubuntu) to compile native modules. The installer automatically handles everything else: +For the POSIX source script, provide Git, curl, tar, and SHA-256 utilities. +Windows can bootstrap its pinned Git for Windows archive when Git is absent. +An existing uv can bootstrap PM; otherwise the script downloads its verified pin. -- **uv** (fast Python package manager) -- **Python 3.14** (via uv, no sudo needed) -- **Node.js v26** (for browser automation and WhatsApp bridge; existing system Node 22.22+, 24.11+, or 26+ is used as-is) -- **ripgrep** (fast file search) -- **ffmpeg** (audio format conversion for TTS) +Hermes requires **Python 3.14** (`>=3.14,<3.15`). PM selects the managed tool +versions from `pm/lock.json`; it does not adopt arbitrary system Node versions +as the installed runtime. -:::info -You do **not** need to install Python, Node.js, ripgrep, or ffmpeg manually. The installer detects what's missing and installs it for you. Just make sure `git` is available (`git --version`). On Linux, ensure `curl` and `xz-utils` are installed (`sudo apt install curl xz-utils` on Debian/Ubuntu). For the desktop app, also install `build-essential` (`sudo apt install build-essential`). -::: +Source builds can require a native compiler and platform development libraries. +Building Electron from source adds Node native-module requirements. These +build prerequisites do not apply to installing a complete desktop package. +Linux Chromium also requires system libraries supplied by the distribution. :::tip Nix users Nix is **no longer an explicitly supported install path** (best-effort only). If you already use Nix (on NixOS, macOS, or Linux), there's a dedicated setup path with a Nix flake, declarative NixOS module, and optional container mode. See the **[Nix & NixOS Setup](./nix-setup.md)** guide. @@ -110,51 +149,45 @@ Nix is **no longer an explicitly supported install path** (best-effort only). If ## Manual / Developer Installation -If you want to clone the repo and install from source — for contributing, running from a specific branch, or having full control over the virtual environment — see the [Development Setup](../developer-guide/contributing.md#development-setup) section in the Contributing guide. +For a source checkout, start with the +[PM developer workflow](/reference/package-management#developer-workflow). +It covers activation, daily commands, dependency refresh, and current bootstrap limits. +[Development Setup](../developer-guide/contributing.md#development-setup) covers the separate test environment and checks. --- ## Non-Sudo / System Service User Installs -Running Hermes as a dedicated unprivileged user (e.g. a `hermes` systemd service account, or any user without `sudo` access) is supported. The only thing on the install path that genuinely needs root is Playwright's `--with-deps` step, which `apt`-installs shared libraries (`libnss3`, `libxkbcommon`, etc.) used by Chromium. The installer detects whether sudo is available and gracefully degrades when it isn't — it will install the Chromium binary into the service user's own Playwright cache and print the exact command an administrator needs to run separately. +Run the source installer as the intended service user. Its home, tool store, +configuration, and launcher must belong to that user. -**Recommended split (Debian/Ubuntu):** +1. As an administrator, install the source-build prerequisites and any Linux + libraries needed by the selected browser backend. +2. As the service user, run the regular installer: -1. **One time, as an admin user with sudo**, install the system libraries Chromium needs: - ```bash - sudo npx playwright install-deps chromium - ``` - (You can run this from anywhere — `npx` will fetch Playwright on the fly.) - -2. **As the unprivileged service user**, run the regular installer. It will detect the missing sudo, skip `--with-deps`, and install Chromium into the user's local Playwright cache: ```bash curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` - Browsers, node, and other tool binaries are not part of the bootstrap: - pm installs them on demand the first time a feature needs them, or all at - once with `hermes pm install`. - -3. **Make `hermes` available to the service user's shells.** The installer writes the launcher to `~/.local/bin/hermes`. System service accounts often have a minimal PATH that doesn't include `~/.local/bin`. Either add it to the user's environment, or symlink the launcher into a system location: - ```bash - # Option A — add to the service user's profile - echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc - - # Option B — symlink system-wide (run as an admin) - sudo ln -s /home/hermes/.hermes/hermes-agent/venv/bin/hermes /usr/local/bin/hermes - ``` - -4. **Verify:** `hermes doctor` should now run cleanly. If you get `ModuleNotFoundError: No module named 'dotenv'`, you're invoking the repo source `hermes` file (`~/.hermes/hermes-agent/hermes`) with system Python instead of the venv launcher (`~/.hermes/hermes-agent/venv/bin/hermes`) — fix step 3. - -5. **Running the messaging gateway from this account?** A user-level service stops at logout and does not start at boot until you enable lingering for the service user: +3. Add the actual launcher directory to the service user's shell environment: ```bash - sudo loginctl enable-linger + export PATH="$HOME/.local/bin:$PATH" ``` - See [Messaging Gateway](/user-guide/messaging/) for the service setup itself. +4. Run `hermes doctor` from that account. Use the installed wrapper, not a + hardcoded `venv/bin/hermes` path. +5. For a Linux user service that must survive logout, enable lingering as an administrator: -The same pattern works on Arch (the installer uses pacman with the same sudo-detection logic), Fedora/RHEL, and openSUSE — those distros don't support `--with-deps` at all, so an administrator always installs the system libraries separately. The relevant `dnf`/`zypper` commands are printed by the installer. + ```bash + sudo loginctl enable-linger SERVICE_USER + ``` + +The current source installer does not run Playwright's `--with-deps` step or +provide a package-manager-specific sudo fallback. PM manages tool binaries; +the administrator supplies system libraries. See +[Browser automation](../user-guide/features/browser.md) and +[Messaging Gateway](../user-guide/messaging/index.md). --- @@ -170,4 +203,8 @@ For more diagnostics, run `hermes doctor` — it will tell you exactly what's mi ## Install method auto-detection -Hermes auto-detects whether it was installed via the git installer, Docker, or NixOS, and `hermes update` prints the matching update command for that path. There's no env var to set — the detection is based on the install layout (`~/.hermes/hermes-agent/` checkout, Docker image stamp, or Nix store path). `hermes doctor` also surfaces the detected method under its environment summary. +The update owner depends on the running installation, not only its data home. +Source checkouts use the managed Git update path. Desktop bundles, Docker, +Nix, and Termux packages retain their package owner's update mechanism. +`hermes doctor` reports installation provenance. See +[Updating & Uninstalling](./updating.md) before changing package-owned files. diff --git a/website/docs/getting-started/nix-setup.md b/website/docs/getting-started/nix-setup.md index 3041696e5d..4b561d2241 100644 --- a/website/docs/getting-started/nix-setup.md +++ b/website/docs/getting-started/nix-setup.md @@ -29,6 +29,27 @@ The `curl | bash` installer manages Python, Node, and dependencies itself. The N **For NixOS module users**, the entire lifecycle is different: configuration lives in `configuration.nix`, secrets go through sops-nix/agenix, the service is a systemd unit, and CLI config commands are blocked. You manage hermes the same way you manage any other NixOS service. ::: +## Runtime pins + +PM's tool lock is also a Nix build input. `nix/npm-pinned.nix` reads the npm +pin, and `nix/pm-packages.nix` exposes matching archives as `pm-NAME` derivations: + +```bash +nix build .#pm-ripgrep +``` + +These outputs unpack the pinned archives. They are not the complete Hermes +wrapper or a guarantee that each archive runs without platform integration. +The application still uses the uv2nix environment and Nix wrapper. + +`nix/pythonLock.nix` reads Python's major/minor from `pm/lock.json`. +The uv2nix environment, package overrides, plugin packages, and developer shell +use that interpreter family. If the pinned nixpkgs lacks that family, evaluation +stops instead of selecting a different Python. + +Native Nix evaluation and builds remain CI gates. Update through Nix. Do not +repair a Nix store path with pip. + ## Prerequisites - **Nix with flakes enabled** — [Determinate Nix](https://install.determinate.systems) recommended (enables flakes by default) @@ -775,7 +796,7 @@ For pip-packaged plugins that register via `[project.entry-points."hermes_agent. ```nix services.hermes-agent.extraPythonPackages = [ - (pkgs.python312Packages.buildPythonPackage { + (config.services.hermes-agent.package.python.pkgs.buildPythonPackage { pname = "rtk-hermes"; version = "1.0.0"; src = pkgs.fetchFromGitHub { @@ -785,7 +806,7 @@ services.hermes-agent.extraPythonPackages = [ hash = "sha256-..."; }; format = "pyproject"; - build-system = [ pkgs.python312Packages.setuptools ]; + build-system = [ config.services.hermes-agent.package.python.pkgs.setuptools ]; }) ]; ``` @@ -809,7 +830,9 @@ services.hermes-agent = { }; ``` -This is resolved by uv alongside core dependencies — no PYTHONPATH patching, no collision risk. Available groups: +These groups join the core dependency resolution at build time. Conflicting +requirements can still fail that resolution. The table lists common groups; +`pyproject.toml` is authoritative for the complete list and platform markers. | Group | What it enables | |-------|-----------------| @@ -849,7 +872,7 @@ A directory plugin with third-party Python dependencies needs both options: ```nix services.hermes-agent = { extraPlugins = [ my-plugin-src ]; # plugin source - extraPythonPackages = [ pkgs.python312Packages.redis ]; # its Python dep + extraPythonPackages = [ config.services.hermes-agent.package.python.pkgs.redis ]; # its Python dep extraPackages = [ pkgs.redis ]; # system binary it needs }; ``` @@ -891,17 +914,15 @@ A build-time collision check prevents plugin packages from shadowing core hermes ### Dev Shell -The flake provides a development shell with Python 3.12, uv, Node.js, and all runtime tools: +The flake provides an editable Python environment with the lock-derived interpreter +and the `dev` extra. `HERMES_PYTHON` points to its interpreter. It does not install +Python dependencies into a repository-local `.venv`. The shell also provides +Node.js and runtime tools. Its npm hook refreshes JS workspaces when their inputs change. ```bash cd hermes-agent nix develop - -# Shell provides: -# - Python 3.12 + uv (deps installed into .venv on first entry) -# - Node.js 26, ripgrep, git, openssh, ffmpeg on PATH -# - Stamp-file optimization: re-entry is near-instant if deps haven't changed - +"$HERMES_PYTHON" -c "import sys; print(sys.executable); print(sys.version)" hermes setup hermes chat ``` @@ -913,7 +934,7 @@ The included `.envrc` activates the dev shell automatically: ```bash cd hermes-agent direnv allow # one-time -# Subsequent entries are near-instant (stamp file skips dep install) +# Nix reuses its built Python environment; the npm hook checks JS inputs. ``` ### Flake Checks @@ -1011,7 +1032,7 @@ nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves use | `extraArgs` | `listOf str` | `[]` | Extra args for `hermes gateway` | | `extraPackages` | `listOf package` | `[]` | Extra packages available to the agent. Added to the hermes user's per-user profile so terminal commands, skills, and cron jobs all see them | | `extraPlugins` | `listOf package` | `[]` | Directory plugin packages to symlink into `$HERMES_HOME/plugins/`. Each must contain `plugin.yaml` | -| `extraPythonPackages` | `listOf package` | `[]` | Python packages added to PYTHONPATH for entry-point plugin discovery. Build with `python312Packages` | +| `extraPythonPackages` | `listOf package` | `[]` | Python packages added to PYTHONPATH for entry-point plugin discovery. Use the selected package’s `python.pkgs` | | `extraDependencyGroups` | `listOf str` | `[]` | pyproject.toml optional extras to include in the sealed venv (e.g. `["hindsight"]`). Resolved by uv — no collisions | | `restart` | `str` | `"always"` | The systemd `Restart=` policy. macOS does not use it. | | `restartSec` | `int` | `5` | The systemd `RestartSec=` value. macOS does not use it. | @@ -1220,7 +1241,7 @@ nix-store --query --roots $(docker exec hermes-agent readlink /data/current-pack | Symptom | Cause | Fix | |---|---|---| | `Cannot save configuration: managed by NixOS` | CLI guards active | Edit `configuration.nix` and `nixos-rebuild switch` | -| `No adapter available for discord` (or telegram/slack) | Messaging deps missing from the sealed Nix venv | Install `#messaging` variant: `nix profile install ...#messaging`. For NixOS module: `extraDependencyGroups = [ "messaging" ]`. Check `journalctl -u hermes-agent` for `FeatureUnavailable` or `requirements not met` for the underlying error. | +| `No adapter available for discord` (or telegram/slack) | Messaging deps missing from the sealed Nix venv | Install `#messaging` variant: `nix profile install ...#messaging`. For NixOS module: `extraDependencyGroups = [ "messaging" ]`. Read `journalctl -u hermes-agent` for `InstallError` or `requirements not met` and the underlying cause. | | Container recreated unexpectedly | `extraVolumes`, `extraOptions`, or `image` changed | Expected — writable layer resets. Reinstall packages or use a custom image | | `hermes --version` shows old version | Container not restarted | `systemctl restart hermes-agent` | | Permission denied on `/var/lib/hermes` | State dir is `0750 hermes:hermes` | Use `docker exec` or `sudo -u hermes` | diff --git a/website/docs/getting-started/platform-support.md b/website/docs/getting-started/platform-support.md index 10d19b1c6f..c8a035ae84 100644 --- a/website/docs/getting-started/platform-support.md +++ b/website/docs/getting-started/platform-support.md @@ -16,10 +16,10 @@ We strive to never break installations and updates for these. Issues & regressio | OS / Architecture | Installation methods | Notes | | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **macOS** (Apple Silicon) | [Hermes Desktop](https://hermes-agent.nousresearch.com/), [`install.sh`](./installation.md#linux--macos--wsl2) | -| [**Windows 10 / 11**](../user-guide/windows-native.md) (x86_64, aarch64) | [Hermes Desktop](https://hermes-agent.nousresearch.com/), [`install.ps1`](./installation.md#windows-native) | A few features are [not available](../user-guide/windows-native.md#feature-matrix). | -| **Linux / [WSL2](../user-guide/windows-wsl-quickstart.md)** (x86_64, aarch64) | [`install.sh`](./installation.md#linux--macos--wsl2) | We test on the latest Ubuntu and WSL2. If your distro has glibc, systemd, and follows the Filesystem Hierarchy Standard, it's likely to work pretty well. | -| [**Docker Container**](../user-guide/docker.md#quick-start) (x86_64, aarch64) | [`docker pull`](../user-guide/docker.md#quick-start) | Docker installs do not support `hermes update`. Updating is done by running a new image. | +| **macOS** (Apple Silicon) | [Hermes Desktop](https://hermes-agent.nousresearch.com/), [`install.sh`](/getting-started/installation) | +| [**Windows 10 / 11**](/user-guide/windows-native) (x86_64, aarch64) | [`install.ps1`](/getting-started/installation), [MSIX desktop](/user-guide/windows-native) | The MSIX package requires Windows 11 22H2 or later. Optional dependencies have [architecture limits](/user-guide/windows-native). | +| **Linux / [WSL2](/user-guide/windows-wsl-quickstart)** (x86_64, aarch64) | [`install.sh`](/getting-started/installation) | We test on the latest Ubuntu and WSL2. If your distro has glibc, systemd, and follows the Filesystem Hierarchy Standard, it's likely to work pretty well. | +| [**Docker Container**](/user-guide/docker) (x86_64, aarch64) | [`docker pull`](/user-guide/docker) | Docker installs do not support `hermes update`. Updating is done by running a new image. | --- @@ -32,8 +32,16 @@ PRs will be accepted to fix issues with them, but they will take precedence belo | OS / Architecture | Installation methods | Notes | | ------------------------------ | -------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| **Nix** (MacOS, Linux, NixOS) | [`install.sh`](./nix-setup.md) | Breaks often due to node.js packaging woes. Best of luck~! <3 | -| **Android / [Termux](./termux.md)** (aarch64) | [APT package](./termux.md) (`pkg install hermes-agent`) | Self-contained package — bundles its own Python and Node. No service manager: run the gateway in a Termux session. | +| **Nix** (macOS, Linux, NixOS) | [Nix flake and modules](/getting-started/nix-setup) | Nix owns runtime installation and updates. | +| **Android / [Termux](/getting-started/termux)** (aarch64) | [Signed APT repository](/getting-started/termux), then `pkg install hermes-agent` | Prerelease package with Python, Node, and TUI. Run the gateway in a Termux session; Android can terminate background processes. | + +### Build targets and support priority + +The native bundle pipeline includes Intel macOS (`x64`) as well as Apple Silicon. +It also defines signed-package update acceptance for both architectures. +That coverage does not change the Tier 1 priority assigned to Apple Silicon. +Linux desktop packaging is disabled in the release workflow, although local +AppImage builds and native Linux PM bundle checks exist. ## Unsupported @@ -42,10 +50,10 @@ We suggest that you migrate to a supported distribution method or platform. They may be broken right now, they may break more in the future. PRs to fix them will _not_ be accepted, and any code that keeps compatibility with them may be removed at any point. -- Android / Termux on non-aarch64 devices (aarch64 is [supported](./termux.md) via our APT package) +- Android / Termux on non-aarch64 devices (aarch64 is [supported](/getting-started/termux) via our APT package) - installs via the AUR (we might upstream patches if it helps out <3) -- macOS on x86 (Intel) processors +- 32-bit x86 macOS. Intel x86_64 has native bundle build and package-update acceptance lanes; this does not change the Tier 1 priority for Apple Silicon. - installs via `pypi` (e.g. `uv tool install hermes-agent`, `pip install hermes-agent`, etc.) - installs via `brew` (`brew install hermes-agent`) -If you are using an unsupported distribution method, please read the [the installation guide](./installation.md) to learn how to switch to a supported one. +If you are using an unsupported distribution method, please read the [the installation guide](/getting-started/installation) to learn how to switch to a supported one. diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index b62d3f7f20..095be6822f 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -53,6 +53,8 @@ To easily install the command-line and desktop applications, [download the Herme ### Without Hermes Desktop: For a command-line only install without Hermes Desktop, run: +For aarch64 Android devices, use the separate [Termux APT guide](./termux.md). + #### Linux / macOS / WSL2 ```bash curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash @@ -279,15 +281,10 @@ For Docker sandboxes, you can also enable the **egress credential-injection prox ### Voice mode -```bash -# From the Hermes install directory (the curl installer placed it at -# ~/.hermes/hermes-agent on Linux/macOS or %LOCALAPPDATA%\hermes\hermes-agent on Windows): -cd ~/.hermes/hermes-agent -uv pip install --python ./venv/bin/python -e ".[voice]" -# Includes faster-whisper for free local speech-to-text -``` - -Then in the CLI: `/voice on`. Press `Ctrl+B` to record. See [Voice Mode](../user-guide/features/voice-mode.md). +Run `hermes tools` and configure the Voice providers. Then enable `/voice on` +in the CLI and press `Ctrl+B` to record. PM handles missing supported +requirements; a dependency change can require a restart. Local Faster-Whisper +is not available on every architecture. See [Voice Mode](../user-guide/features/voice-mode.md). ### Skills diff --git a/website/docs/getting-started/termux.md b/website/docs/getting-started/termux.md index 08da37f469..62b47ec03f 100644 --- a/website/docs/getting-started/termux.md +++ b/website/docs/getting-started/termux.md @@ -1,7 +1,7 @@ --- sidebar_position: 3 title: "Android / Termux" -description: "Install Hermes Agent on Android with the signed Termux package" +description: "Install Hermes Agent on Android from its signed Termux APT repository" --- # Hermes on Android with Termux @@ -11,13 +11,18 @@ This package is in prerelease testing. The package includes Python, Node.js, npm, uv, ripgrep, ffmpeg, and their runtime libraries. CI builds the native Python wheels and the TUI before it creates the package. -The device does not compile dependencies or assemble a Python environment during installation. +The device does not compile core dependencies or assemble its base Python +environment during installation. The package uses Python 3.14 with the bionic +interpreter pin; it does not require the same patch version as desktop CPython. +The wheel closure is core plus `acp`, not all desktop extras. ## Install Use the standard [Termux](https://termux.dev/) application. The package requires its standard prefix, `/data/data/com.termux/files/usr`. Other architectures and renamed Termux application packages are not supported. +The wheels target Android API 24 (`android_24_arm64_v8a`). +Do not use the desktop/server `install.sh` or a glibc Linux archive on this target. 1. Install the tools for repository setup: @@ -84,6 +89,7 @@ They do not require Termux's `python` or `nodejs` packages. Update through APT: ```bash +pkg update pkg upgrade hermes-agent ``` @@ -93,7 +99,8 @@ Canary versions contain `~canary.` and sort before the corresponding ## Gateway -Termux has no system service manager. Run the gateway in a Termux session: +This APT installation does not use systemd, launchd, or Windows Scheduled Tasks. +Run the gateway in a Termux session: ```bash hermes gateway run @@ -113,9 +120,20 @@ Battery optimization exemptions and `termux-wake-lock` can help, but do not guar ## Limits -The package does not include the `nemo-relay` exporter because its build does not support this target. -Optional integrations can require additional dependencies or services. -A prebuilt core runtime does not guarantee that every third-party plugin supports Android. +The package does not include the `nemo-relay` exporter. Its vendored build +toolchain does not support this target. + +The package does not include Electron, local Chromium, or desktop computer-use +tools. A local Docker daemon is not part of the Termux environment. Remote +services have their own requirements and connectivity limits. + +Phone-native Termux:API microphone and clipboard adapters are not provided by +this package path. The prebuilt CLI/TUI is not proof of local voice or wake-word +support. Optional integrations and third-party plugins can require dependencies +that do not support Android. + +Python 3.14 on this target reports `sys.platform == "android"`. A dependency +or skill gated only to `linux` is not automatically available on Android. ## Uninstall diff --git a/website/docs/getting-started/updating.md b/website/docs/getting-started/updating.md index 8c20bc1558..e6d70fcbfe 100644 --- a/website/docs/getting-started/updating.md +++ b/website/docs/getting-started/updating.md @@ -8,13 +8,72 @@ description: "How to update Hermes Agent to the latest version or uninstall it" ## Updating -Update to the latest version with a single command: +Choose the update method for the installation that is running: + +| Installation | Update method | +|---|---| +| Managed source checkout | `hermes update`, or the source-built desktop's update handoff. | +| Windows sideload MSIX | The desktop Update control and Windows App Installer. | +| Microsoft Store package | Microsoft Store updates. | +| macOS bundled app | The desktop Update control, through `electron-updater`. | +| Docker image | Pull the chosen image and recreate the container with the same data mount. | +| Nix | Update the flake/profile and rebuild. | +| Termux APT | `pkg update`, then `pkg upgrade hermes-agent`. | + +`hermes update` does not rewrite package-owned application files. PM manages +dependencies, not application distribution: `hermes pm update` is a maintainer +pin-update command, not an alternative application updater. + +For a managed source installation: ```bash hermes update ``` -This pulls the latest code from `main`, updates dependencies, and prompts you to configure any new options that were added since your last update. +The default source channel tracks `main`. A configured stable channel tracks +final release tags. The update prepares dependencies through PM and reports +configuration changes and process-restart results. + +### Bundled desktop updates + +On Windows, the sideload app checks the registered App Installer source. Apply +downloads the `.appinstaller` descriptor before stopping app-owned backends, +opens that local file, and quits for Windows to replace the package. +A detached waiter attempts automatic relaunch after the package version changes. +A waiter-registration failure produces a manual-reopen warning. + +On macOS, apply downloads the ZIP update and waits for Squirrel.Mac to accept +the signed app before stopping backends. Only that apply flow requests install +and relaunch. An ordinary quit does not install a pending download. +Download or verification failures leave backends running. + +Store packages use the Store instead of either sideload feed. A broken or +unavailable update check must not be interpreted as "already up to date." +The application and its bundled base runtime update together; user data stays +outside the package. See [Desktop installation](../user-guide/desktop.md#install). + +### Source channels and install identity + +```bash +hermes update --install-id +hermes update --set-channel stable +hermes update --channel stable --check +``` + +`--install-id` prints the installation identity and path. `--set-channel` +changes only that installation's configuration, then exits without applying an +update. `--channel` is a one-run override. An explicit `--branch` takes precedence +for a source checkout. + +Source `main` tracks the branch tip. Source `stable` tracks the newest final +`vMAJOR.MINOR.PATCH` tag. Source `canary` normalizes to `main`; it does not +install a desktop package. Per-install records live under `update.installs` +in configuration, so one checkout's choice does not change another app's feed. + +Packaged desktop feed channels derive from their build tag and package owner. +Changing a source channel is not an MSIX or Store channel switch. Canary builds +can advance stored data formats; switching back is not a schema rollback. +Back up data before changing release channels. :::tip `hermes update` automatically detects new configuration options and prompts you to add them. If you skipped that prompt, you can manually run `hermes config check` to see missing options, then `hermes config migrate` to interactively add them. @@ -22,19 +81,20 @@ This pulls the latest code from `main`, updates dependencies, and prompts you to ### What happens during an update -When you run `hermes update`, the following steps occur: +For an admitted source checkout, `hermes update` runs these phases: 1. **Pre-update snapshot** — a lightweight state snapshot is saved by default (covers pairing data, cron jobs, `config.yaml`, `.env`, `auth.json`, and other state files that get modified at runtime; individual files over 1 GiB are skipped so a large sessions DB never slows the update down). Because the code swap and gateway restarts touch every profile, the same snapshot is taken for **every profile** on the install — each into its own `state-snapshots/` directory — and the post-update cron-jobs safety net checks each profile against its own snapshot. Controlled by `updates.pre_update_backup` (`quick` by default, `full` for a zip of all of `HERMES_HOME`, `off` to disable). Recoverable via the snapshot restore flow described under [Snapshots and rollback](../user-guide/checkpoints-and-rollback.md). Quick snapshots are file-loss recovery, not code-rollback insurance — for a coherent point-in-time rollback use `--backup` (full mode). -2. **Git pull** — pulls the latest code from the `main` branch and updates submodules +2. **Code update** — applies the configured source branch or stable release tag and updates submodules. 3. **Post-pull syntax validation + auto-rollback** — after the pull, Hermes compiles the nine critical files every `hermes` invocation imports at startup. If any fails to parse (e.g. an orphan merge-conflict marker, an accidentally truncated file), Hermes runs `git reset --hard ` to roll the install back so your shell stays bootable. Re-run `hermes update` once the upstream fix lands. -4. **Dependency install** — runs `uv pip install -e ".[all]"` to pick up new or changed dependencies +4. **Dependency preparation** — PM provisions required tools and prepares a complete Python environment from the new lock, existing extras, and enabled plugin requirements. It validates that environment before publishing its selection. 5. **Config migration** — detects new config options added since your version and prompts you to set them 6. **Desktop rebuild (stage-and-swap)** — if the Hermes Desktop app was built from this checkout, it is rebuilt so the GUI matches the new code. The rebuild packs into a temporary staging directory next to `apps/desktop/release/`, verifies the staged app, and only then renames it over the previous build. A rebuild that fails at any point — corrupt Electron download, missing dependency, disk full — leaves the previous app untouched and launchable; the update reports `⚠ Update partially complete` and `hermes desktop` retries the rebuild. 7. **Gateway auto-restart** — running gateways are refreshed after the update completes so the new code takes effect immediately. Service-managed gateways (systemd on Linux, launchd on macOS) are restarted through the service manager. Manual gateways are relaunched automatically when Hermes can map the running PID back to a profile. Manually-launched `hermes serve` / `hermes dashboard` backends (for example a network-bound serve powering a remote Desktop) are handled the same way: each backend records its bind address in the install's spawn ledger at startup, so the update stops it before the code swap and relaunches it afterward on the **same host and port** — a remote Desktop pointed at that endpoint reconnects instead of stranding. Backends owned by a running Desktop app are left to the app's own respawn. ### Updating against a non-default branch: `--branch` -By default `hermes update` tracks `origin/main`. Pass `--branch ` to update against a different branch — useful for QA channels, feature branches, or release-candidate testing: +On the default source channel, `hermes update` tracks `origin/main`. Use +`--branch NAME` for a one-run branch override: ```bash hermes update --branch release-candidate @@ -84,7 +144,10 @@ You can pass `--keep-stash` to a terminal `hermes update` too if you want the sa ### Preview-only: `hermes update --check` -Want to know if an update is available before pulling? Run `hermes update --check` — it fetches and compares commits against `origin/main`. No files are modified, no gateway is restarted. Useful in scripts and cron jobs that gate on "is there an update". +`hermes update --check` compares the checkout with its source-channel target +without applying code, installing dependencies, or restarting gateways. The +comparison can fetch Git metadata; it is not a promise of zero filesystem writes. +Package-owned installs report their external update method. ### Fleet preview: `hermes update --plan` @@ -118,61 +181,32 @@ updates: Update backups protect an in-place update. If you're migrating your whole setup to different hardware, use `hermes backup` + `hermes import` instead — see [Exporting Hermes to another machine](/reference/faq#exporting-hermes-to-another-machine) and [`hermes backup` vs `hermes profile export`](/reference/faq#hermes-backup-vs-hermes-profile-export). ::: -### Windows: another `hermes.exe` is running +### Windows process ownership and dependency changes -On Windows, `hermes update` will refuse to run if it detects another `hermes.exe` process holding the venv's entry-point executable open — most commonly the Hermes Desktop app's spawned backend, an open `hermes` REPL in another terminal, or a running gateway: +Windows can hold executable and native-extension files open while processes run. +PM therefore prepares dependency generations separately instead of replacing +imported libraries in place. A running process keeps its current imports until +it restarts. -``` -$ hermes update -✗ Another hermes.exe is running: - PID 12345 hermes.exe +Follow any blocker diagnostic for the specific process and installation. Do +not kill every process named Python or Hermes. Source updates and MSIX package +replacement have different owners and shutdown requirements. - Updating now would fail to overwrite ...\venv\Scripts\hermes.exe because - Windows blocks REPLACE on a running executable. - - Close Hermes Desktop, exit any open `hermes` REPLs, and - stop the gateway (`hermes gateway stop`) before retrying. - Override with `hermes update --force` if you've already - confirmed those processes will not write to the venv. -``` - -Close the listed processes and re-run. If you're sure the concurrent process won't interfere (rare — usually only useful when an antivirus shim is mis-attributed), pass `--force` to skip the check. In that case the updater will still retry the `.exe` rename with exponential backoff and, on stubborn locks, schedule the replacement for next reboot via `MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT)` so the update can complete. - -A second, separate guard refuses to touch the venv while any process is running from its Python interpreter (the Desktop app's backend, a gateway, a Python REPL). Those processes keep native extension files (`.pyd`) locked, and a dependency sync that dies partway on an access-denied error strands the install between versions. This guard is **not** bypassed by `--force`; if you're certain the detected holders are false positives, use the explicit `hermes update --force-venv`. - -#### Windows venv recreation is transactional - -When the Windows installer must recreate an existing `venv`, it first moves the old directory to a unique `venv.stale.*` name, then creates and verifies the replacement. The old tree is deleted only after the dependency install completes and the baseline imports pass in the new tree — until then it is the rollback source (recorded in `venv.pending-backup`). - -If the move cannot be completed, the installer stops and leaves the live `venv` untouched. If `uv` fails or reports success without creating the interpreter, any partial replacement is moved to `venv.failed.*` and the previous venv is restored. This keeps the health and blocker checks usable after a failed install. - -A `venv.stale.*` or `venv.failed.*` directory can remain when another process still owns a file handle. Close Hermes Desktop, gateways, and Python processes using the install, then retry the install/update; parked directories are cleaned up best-effort after a successful recreation. - -Expected output looks like: - -``` -$ hermes update -Updating Hermes Agent... -📥 Pulling latest code... -Already up to date. (or: Updating abc1234..def5678) -📦 Updating dependencies... -✅ Dependencies updated -🔍 Checking for new config options... -✅ Config is up to date (or: Found 2 new options — running migration...) -🔄 Restarting gateways... -✅ Gateway restarted -✅ Hermes Agent updated successfully! -``` +The parser retains `--force` and `--force-venv` for Windows compatibility. They +are not normal update instructions or a guarantee that an OS file lock can be +bypassed. Inspect `hermes update --plan`, the update log, and `hermes pm status` +before retrying a failed update. ### Recommended Post-Update Validation -`hermes update` handles the main update path, but a quick validation confirms everything landed cleanly: +After a source update, run the following diagnostics. For a bundled app, use +its About page and backend health instead of Git commands inside the package. 1. `git status --short` — if the tree is unexpectedly dirty, inspect before continuing 2. `hermes doctor` — checks config, dependencies, and service health 3. `hermes --version` — confirm the version bumped as expected 4. If you use the gateway: `hermes gateway status` -5. If `doctor` reports npm audit issues: run `npm audit fix` in the flagged directory +5. `hermes pm status` — inspect dependency preparation and any failed steps. :::warning Dirty working tree after update If `git status --short` shows unexpected changes after `hermes update`, stop and inspect them before continuing. This usually means local modifications were reapplied on top of the updated code, or a dependency step refreshed lockfiles. @@ -211,55 +245,25 @@ You can also update directly from Telegram, Discord, Slack, WhatsApp, or Teams b This pulls the latest code, updates dependencies, and restarts running gateways. The bot will briefly go offline during the restart (typically 5–15 seconds) and then resume. -### Manual Update +### Manual source maintenance and rollback -If you installed manually (not via the quick installer): +Use the [source-install guide](../user-guide/switching-to-source.md) for an +independent development checkout. Commit or otherwise preserve local work +before changing its Git revision. Stop the services you own before replacing +the code that those services run. -```bash -cd /path/to/hermes-agent -# Activate the venv you created during install (outside the source tree) -export VIRTUAL_ENV="$HOME/.hermes/venvs/hermes-dev" -export PATH="$VIRTUAL_ENV/bin:$PATH" +After changing the source revision, run that revision's dependency/bootstrap +procedure. PM-managed checkouts use `python -m pm.cli install` from the selected +source environment. A bare pip install is not equivalent: it does not prepare +the managed tool store or PM's runtime selection. -# Pull latest code -git pull origin main +A code checkout alone is not a data rollback. Newer releases can migrate +configuration or databases in ways older code cannot read. Use a coherent +backup from before the update if an older release requires the earlier data +format. Preserve the current data before attempting a restore. -# Reinstall (picks up new dependencies) -uv pip install -e ".[all]" - -# Check for new config options -hermes config check -hermes config migrate # Interactively add any missing options -``` - -### Rollback instructions - -If an update introduces a problem, you can roll back to a previous version: - -```bash -cd /path/to/hermes-agent - -# List recent versions -git log --oneline -10 - -# Roll back to a specific commit -git checkout -uv pip install -e ".[all]" - -# Restart the gateway if running -hermes gateway restart -``` - -To roll back to a specific release tag (substitute your previous tag — e.g. a recent release like `v2026.5.16`, or any earlier tag from `git tag --sort=-version:refname`): - -```bash -git checkout vX.Y.Z -uv pip install -e ".[all]" -``` - -:::warning -Rolling back may cause config incompatibilities if new options were added. Run `hermes config check` after rolling back and remove any unrecognized options from `config.yaml` if you encounter errors. -::: +Package-owned installations use their package manager's rollback or reinstall +procedure. Do not run Git or pip inside a signed app or an immutable image. ### Image-managed installs (Docker): the provenance marker @@ -293,7 +297,15 @@ See [Nix Setup](./nix-setup.md) for more details. hermes uninstall ``` -The uninstaller gives you the option to keep your configuration files (`~/.hermes/`) for a future reinstall. +For source installs, review `hermes uninstall --dry-run` before removal. +The default removal can preserve configuration and user data. `--full` also +removes data. `--data` removes user data without deleting package-owned code. +These modes are destructive; make a backup first. + +For MSIX/Store, use Windows Settings → Apps → Installed apps. For macOS, +quit Hermes and move its app bundle to Trash. Docker, Nix, and Termux use the +same manager that installed them. Their package files are not removed by the +source uninstaller. Data deletion is separate from package removal. :::tip Moving to a new machine rather than leaving? Take your setup with you before removing anything: `hermes backup` captures the entire `~/.hermes` directory including credentials, while `hermes profile export` packs a single profile with credentials excluded by design (so an export alone is not a full backup). See [`hermes backup` vs `hermes profile export`](/reference/faq#hermes-backup-vs-hermes-profile-export). diff --git a/website/docs/index.mdx b/website/docs/index.mdx index 3b7ad423f4..c75f265002 100644 --- a/website/docs/index.mdx +++ b/website/docs/index.mdx @@ -83,6 +83,8 @@ To easily install the command-line and desktop applications, [download the Herme For a command-line only install without Hermes Desktop, run: +For aarch64 Android devices, use the separate [Termux APT guide](./getting-started/termux.md). + #### Linux / macOS / WSL2 ```bash @@ -97,7 +99,7 @@ Run in powershell: iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` -See the full **[Installation Guide](/getting-started/installation)** for what the installer does, the per-user vs root layout, and Windows-specific notes. For the complete platform support matrix, see **[Platform Support](/getting-started/platform-support)**. +See the full **[Installation Guide](/getting-started/installation)** for what the installer does, installation ownership and data locations, and Windows-specific notes. For the complete platform support matrix, see **[Platform Support](/getting-started/platform-support)**. :::tip Fastest path to a working agent After installing, run `hermes setup --portal` — one OAuth covers a model plus all four Tool Gateway tools (web search, image generation, TTS, browser). See [Nous Portal](/integrations/nous-portal). diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 6cad7bcebf..af51feba8d 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -243,7 +243,7 @@ Subcommands: | Subcommand | Description | |------------|-------------| -| `run` | Run the gateway in the foreground. Recommended for WSL and Docker. | +| `run` | Run the gateway in the foreground. Recommended for WSL, Docker, and Termux. | | `start` | Start the installed systemd/launchd background service. | | `stop` | Stop the service (or foreground process). | | `restart` | Restart the service. | @@ -1774,18 +1774,47 @@ hermes completion zsh >> ~/.zshrc hermes completion fish > ~/.config/fish/completions/hermes.fish ``` +## `hermes pm` + +Manage pinned tools, Python dependency environments, and their diagnostics. +This command does not update the Hermes application itself. + +```bash +hermes pm --help +hermes pm doctor +hermes pm status +hermes pm install +hermes pm install chromium +``` + +For source development, run the setup script once, then activate the installed +environment with `source ./activate` or PowerShell `. .\activate.ps1`. +Use `deactivate` to restore the previous shell environment. See the +[developer workflow](/reference/package-management#developer-workflow) for preparation, +daily commands, dependency refresh, and test environments. + +See [Package management](./package-management.md) for every subcommand, +source-versus-bundle behavior, lazy-install policy, and maintainer commands. + ## `hermes update` ```bash hermes update [--gateway] [--check] [--plan] [--no-backup] [--backup] [--yes] ``` -Pulls the latest `hermes-agent` code and reinstalls dependencies in the managed venv, then re-runs the post-install hooks (MCP servers, skills sync, completion install). Safe to run on a live install. Use `--check` to see whether your checkout is behind `origin/main` without installing. +Updates an admitted source checkout and prepares dependencies through PM. +Use `--check` to compare with its configured source target without applying +the update. Desktop bundles, Docker, Nix, and Termux packages retain their +external update owner. See [Updating & Uninstalling](../getting-started/updating.md). `hermes update` pulls the configured update branch (default: `main`). If your checkout is on another branch, Hermes may check out the update branch before pulling. Commit branch work before updating when you want to keep it outside the update autostash flow. | Option | Description | |--------|-------------| +| `--install-id` | Print this installation's identity and path, then exit. | +| `--set-channel CHANNEL` | Persist `main`, `stable`, or `canary` for this installation without applying an update. External owners can refuse the change. | +| `--channel CHANNEL` | Select a source channel for this invocation only. | +| `--branch NAME` | Select a source branch for this invocation; takes precedence over source channel selection. | | `--gateway` | Internal mode used by the messaging `/update` command. Uses file-based IPC for prompts and progress streaming instead of reading from terminal stdin. Not a gateway restart flag. | | `--check` | Check whether an update is available without pulling, installing dependencies, or restarting anything. | | `--plan` | Print the update plan and exit without changing anything: install kind (git/Docker/Nix/apt), every running Hermes service across all profiles with its supervisor and running code version, and how each will be restarted. On image- or package-managed installs, reports the correct external update command instead. Read-only. | @@ -1811,7 +1840,7 @@ Additional behavior: | `hermes --version` | Print version information. | | `hermes update` | Pull latest changes and reinstall dependencies. | -| `hermes uninstall [--full] [--gui] [--dry-run] [--yes]` | Remove Hermes, optionally deleting all config/data. `--gui` removes only the desktop Chat GUI, leaving the agent intact; `--full` also deletes config/data; `--dry-run` prints what would be removed without changing anything; `--yes` skips prompts. | +| `hermes uninstall [--full] [--gui] [--data] [--dry-run] [--yes]` | Remove owned source-install files. `--gui` selects source-built desktop removal; `--full` also removes data. `--data` removes user data without deleting package-owned code. Sealed installs use their package owner for application removal. `--dry-run` previews the scope; `--yes` skips confirmation. | ## See also diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 50e29f47bf..5c1d0a69c4 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -123,8 +123,7 @@ Hermes reads environment variables from the process environment and, for user-ma | `VOICE_TOOLS_OPENAI_KEY` | Preferred OpenAI key for OpenAI speech-to-text and text-to-speech providers | | `HERMES_LOCAL_STT_COMMAND` | Optional local speech-to-text command template. Supports `{input_path}`, `{output_dir}`, `{language}`, and `{model}` placeholders | | `HERMES_LOCAL_STT_LANGUAGE` | Default language hint for STT. Used by the `local` (faster-whisper) provider, `HERMES_LOCAL_STT_COMMAND`, the local `whisper` CLI fallback (default: `en`), Groq, and xAI when no per-provider `language` is set in `config.yaml` | -| `HERMES_HOME` | Override Hermes config directory (default: `~/.hermes`). Also scopes the gateway PID file and systemd service name, so multiple installations can run concurrently | -| `HERMES_GIT_BASH_PATH` | **Windows only.** Override `bash.exe` discovery for the terminal tool. Points at any bash — full Git-for-Windows install, WSL bash via symlink, MSYS2, Cygwin. The installer sets this automatically to the PortableGit it provisioned. See the [Windows (Native) Guide](../user-guide/windows-native.md#how-hermes-runs-shell-commands-on-windows) | +| `HERMES_HOME` | Select the configuration and user-data home. Defaults to `~/.hermes` on POSIX and `%LOCALAPPDATA%\hermes` on Windows; the official Docker image uses `/opt/data`. Profile/runtime context can select a more specific home. | | `HERMES_DISABLE_WINDOWS_UTF8` | **Windows only.** Set to `1` to disable the UTF-8 stdio shim (`configure_windows_stdio()`) and fall back to the console's locale code page. Useful for bisecting encoding bugs; rarely the right setting in normal operation | | `HERMES_KANBAN_HOME` | Override the shared Hermes root that anchors the kanban board (db + workspaces + worker logs). Falls back to `get_default_hermes_root()` (the parent of any active profile). Useful for tests and unusual deployments | | `HERMES_KANBAN_BOARD` | Pin the active kanban board for this process. Takes precedence over `~/.hermes/kanban/current`; the dispatcher injects this into worker subprocess env so workers physically cannot see tasks on other boards. Defaults to `default`. Slug validation: lowercase alphanumerics + hyphens + underscores, 1-64 chars | @@ -841,7 +840,7 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us | `HERMES_ALLOW_PRIVATE_URLS` | `true`/`false` — allow tools to fetch localhost/private-network URLs. Off by default in gateway mode. | | `HERMES_REDACT_SECRETS` | `true`/`false` — control secret redaction in tool output, logs, and chat responses (default: `true`). | | `HERMES_WRITE_SAFE_ROOT` | Optional directory prefix that **hard-blocks** `write_file`/`patch` writes outside the listed roots (no approval prompt). Supports multiple directories separated by `os.pathsep` (`:` on Unix, `;` on Windows). See [HERMES_WRITE_SAFE_ROOT](#hermes_write_safe_root) below. | -| `HERMES_DISABLE_LAZY_INSTALLS` | Internal bridge var set automatically in the official Docker image to prevent runtime dependency installs into the immutable `/opt/hermes` tree. The user-facing equivalent is `security.allow_lazy_installs: false` in `config.yaml`; do not set this in `.env`. | +| `HERMES_DISABLE_LAZY_INSTALLS` | Internal PM policy used by the official Docker image and tests. Truthy values refuse on-demand installation rather than redirecting it to a lazy-packages overlay. It overrides the user-facing `security.allow_lazy_installs` setting. Do not put it in `.env`. | | `HERMES_DISABLE_FILE_STATE_GUARD` | Set to `1` to turn off the "file changed since you read it" guard on `patch`/`write_file`. | | `HERMES_BUNDLED_SKILLS` | Comma-separated override for the list of bundled skills loaded at startup. | | `HERMES_OPTIONAL_SKILLS` | Comma-separated list of optional-skill names to auto-install on first run. | @@ -889,6 +888,10 @@ Unset the variable or remove it from `.env` to restore normal writes (still subj | `AI_AGENT` | **Set to `hermes-agent` by the CLI and gateway entry points** (only when not already set by an outer harness), and exported into every terminal-tool shell — including remote backends (Docker, SSH, Modal, Daytona, Singularity, Vercel). The emerging cross-agent standard for child-process attribution — generic tooling (e.g. huggingface_hub's agent detection) reads it to know it runs under an AI agent. The value matches Hermes' id in the public agent-harness registry. Don't set manually. | | `HERMES_AGENT` | **Set to `true` by the CLI and gateway entry points** and exported into every terminal-tool shell so child processes can detect they run inside Hermes specifically. Don't set manually. | +Terminal session snapshots do not persist injected session/agent attribution +variables. Hermes supplies the current values for each command; an export +inside a previous terminal command does not redefine the next session identity. + ## Context Compression (config.yaml only) Context compression is configured exclusively through `config.yaml` — there are no environment variables for it. Threshold settings live in the `compression:` block, while the summarization model/provider lives under `auxiliary.compression:`. diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md index 6c35aa410a..e015aa7193 100644 --- a/website/docs/reference/faq.md +++ b/website/docs/reference/faq.md @@ -144,20 +144,16 @@ ls ~/.local/bin/hermes The installer adds `~/.local/bin` to your PATH. If you use a non-standard shell config, add `export PATH="$HOME/.local/bin:$PATH"` manually. ::: -#### Python version too old +#### Unsupported Python version -**Cause:** Hermes requires Python 3.14 or newer. +Hermes requires **Python 3.14** (`>=3.14,<3.15`), not an arbitrary newer version. +The installer and packaged distributions provide their pinned interpreter. -**Solution:** -```bash -python3 --version # Check current version - -# Install a newer Python -sudo apt install python3.12 # Ubuntu/Debian -brew install python@3.12 # macOS -``` - -The installer handles this automatically — if you see this error during manual installation, upgrade Python first. +For a manual source environment, use the +[development setup](../developer-guide/contributing.md#development-setup). +Do not replace the interpreter inside an installed app or container. +For a managed-install error, run `hermes doctor` and use that installation's +[update method](../getting-started/updating.md). #### Terminal commands say `node: command not found` (or `nvm`, `pyenv`, `asdf`, …) diff --git a/website/docs/reference/package-management.md b/website/docs/reference/package-management.md new file mode 100644 index 0000000000..94deab7eaf --- /dev/null +++ b/website/docs/reference/package-management.md @@ -0,0 +1,328 @@ +--- +title: "Package Management" +description: "PM tool pins, Python environments, optional dependencies, and installation ownership" +--- + +# Package management + +`hermes pm` manages Hermes tool binaries and Python dependency environments. +It is not the application updater. Use the installation's +[update method](/getting-started/updating) to update Hermes itself. + +## Pins, installed state, and runtime selection + +Each file has a separate role: + +| File | Role | +|---|---| +| `pm/lock.json` | Exact managed-tool versions, target-specific URLs, and SHA-256 hashes. | +| `pyproject.toml` and `uv.lock` | Python requirements, extras, platform markers, and the committed Python resolution. | +| Tool-store `facts.json` | Installed tool entries, their identities, environment exports, and realized-file digests. | +| Per-install `facts.json` | The selected Python environment, its input stamp, and enabled extras. | +| Payload `manifest.json` | Relative payload layout and the completed launch contract from the bundle builder. | +| `install-stamp.json` | Build provenance and the declared distribution/update owner. | + +A lockfile entry does not prove that a package is installed. `hermes pm doctor` +compares the installed state with the lock and checks the realized bytes. +Startup uses a cheaper check. It does not query upstream versions on every launch. + +## Source installs and packaged builds + +Source installers provision the required tools plus Python. They select the +`all` Python extra. Named optional tools install when requested. + +Native desktop bundles stage the supported tool set and all target-compatible +Python extras before packaging. `--extra all` and `--all-extras` are not +synonyms. Platform markers still exclude dependencies that cannot run on a target. + +A packaged application's base payload is immutable. Hermes runs its backend +from that payload, rather than copying a source checkout on first launch. +The bundle builder checks its files and writes the launch paths into the desktop +build stamp. Electron uses those paths without probing or repairing the payload. +Additional pinned tools can use the writable tool store. Python additions use +a complete writable environment outside the signed package. + +Termux uses a separate bionic build and a sealed APT package. Docker bakes its +runtime into the image and disables on-demand dependency installation. Nix +provides its runtime through derivations. See the +[Termux](/getting-started/termux), [Docker](/user-guide/docker), and +[Nix](/getting-started/nix-setup) guides for their limits. + +## Writable state + +The platform default data root is `~/.hermes` on POSIX and +`%LOCALAPPDATA%\hermes` on Windows. `HERMES_HOME` and profiles can change +which data root a process uses. + +| State | Default location | +|---|---| +| Shared writable tool entries | `tools/` under the resolved default Hermes root. | +| Resumable downloads | `cache/partials/` under that root, not inside a signed payload. | +| Per-install selection and journal | `installs/INSTALL_KEY/` under the dependency-state root. | +| Python generations | `installs/INSTALL_KEY/environments/`. | +| Sync and update receipts | `logs/update_receipts/` under the active home. | + +The install key derives from the canonical source or payload-repository path. +Separate checkouts therefore have separate Python selections. Profiles that +share an installation can contribute dependencies to the same environment. +Their configuration and credentials remain profile-scoped. + +Do not edit facts or generation paths manually. Launchers resolve the selected +environment before third-party imports. Processes retain their existing imports +until they restart. Garbage collection preserves selected generations and +lease-managed generations with live readers. + +## Optional Python dependencies and plugins + +A built-in feature requests a project extra through `pm.ensure_import`. +Directory plugins declare Python requirements in `pyproject.toml`, or through +legacy `pip_dependencies` or `python_dependencies` lists in `plugin.yaml`. + +PM prepares core requirements, enabled extras, and enabled plugin requirements +together. It seeds resolution from the existing lock. Compatible transitive +versions can change, but declared constraints and exact pins remain binding. +The generated workspace and extended lock remain outside shipped source. + +A failed candidate does not replace the selected environment or silently +disable other plugins. If preparation succeeds, a restart can still be required +to activate the new environment in a running Hermes process. + +Ordinary Hermes application updates preserve user plugin directories. Explicit +plugin updates can change the selected plugin's files. A wrapper with no Python +dependency declaration does not join the shared environment. Its external +sidecar remains separately owned. See the +[plugin guide](/developer-guide/plugins). + +### Lazy-install policy + +`security.allow_lazy_installs` controls on-demand installation. Already installed +dependencies remain usable when this setting is false. + +```bash +hermes config set security.allow_lazy_installs false +``` + +Explicit install commands are distinct from on-demand installation. However, +a bundle's frozen feature list still restricts requested Python extra names +when lazy installs are disabled. Explicit plugin admission is a separate +operation, not an on-demand feature request. Do not treat this setting as a +sandbox or a blanket prohibition on manual package installation. +Docker additionally sets the internal lazy-install disable flag in the image. + +PM is a dependency manager, not a sandbox for plugin code. Installing a plugin +requires trust in that plugin and its dependencies. + +## Developer workflow {#developer-workflow} + +PM prepares the toolchain for a source checkout. Activation makes that installed +toolchain available in a shell. Neither operation selects your editor's Python +interpreter or redirects an installed desktop app to this checkout. + +### Prepare a checkout + +Use an ordinary terminal outside the packaged Hermes app. Leave any existing +Python virtual environment first. On Windows, use native PowerShell with Git +and the target architecture's C++ build tools available. Dependencies without +wheels can also require Rust/Cargo and native libraries. PM does not install +those compiler toolchains. POSIX source builds have native build requirements too. + +Clone the repository and select your branch before preparing dependencies: + +```bash +git clone https://github.com/NousResearch/hermes-agent.git +cd hermes-agent +``` + +For isolated development, select a separate data home before the first PM +command. Keep the same values when returning to this checkout. + +Bash, from the repository root: + +```bash +export HERMES_HOME="$HOME/hermes-dev-data" +export HERMES_RUNTIME_DIR="$HERMES_HOME/tools" +bash setup-hermes.sh +source ./activate +``` + +PowerShell, from the repository root: + +```powershell +$env:HERMES_HOME = Join-Path $HOME 'hermes-dev-data' +$env:HERMES_RUNTIME_DIR = Join-Path $env:HERMES_HOME 'tools' +.\setup-hermes.ps1 +. .\activate.ps1 +``` + +`HERMES_RUNTIME_DIR` in these examples is a process-local development override. +It makes the bootstrap and PM use the same writable store. Do not persist a +path into an installed MSIX or macOS bundle. The setup scripts provision tools +and the `all` Python extra. They do not select `dev` or install JS workspaces. + +The bootstrap uses uv to install and locate Python, then waits for uv to exit. +That Python runs PM directly. PM can then replace its uv entry without a running +bootstrap process holding the old executable. PM writes failure receipts without +PyYAML, including when dependency installation fails. + +### Activate an existing installation + +In each new shell, restore your development-home values and enter the checkout. +Then activate it without running installation again: + +| Shell | Enter | Leave | +|---|---|---| +| Bash | `source ./activate` | `deactivate` | +| PowerShell | `. .\activate.ps1` | `deactivate` | + +The leading dot and space in PowerShell are required. Executing +`.\activate.ps1` without dot-sourcing does not provide the same session scope. +The POSIX script uses Bash syntax. Use Bash for this recipe rather than `sh`, +fish, or assuming that a Zsh startup file has Bash semantics. + +Activation prepends installed PM tools to `PATH`. It sets `PYTHONPATH` to this +checkout and its selected dependency tree. It does not download packages, +change an OS-wide PATH, or activate a conventional venv prompt. +Start in a clean shell rather than nesting this inside another venv. +`deactivate` restores the environment values captured by the activation script. +It does not uninstall packages or stop processes that you started. + +Verify the interpreter and source before doing work: + +```bash +python -c "import sys, pm; print(sys.executable); print(pm.__file__)" +python -c "import httpx; print(httpx.__file__)" +node --version +npm --version +python hermes --version +``` + +`python` must resolve to the PM store interpreter. `pm.__file__` must point +into this checkout. Dependencies come from the selected environment, which can +live outside the repository. A missing import means setup or selection needs +attention, even if `source ./activate` itself returned successfully. + +### Work on this source tree + +Use checkout-qualified commands so a global `hermes` command or MSIX alias +cannot run a different installation: + +```bash +python hermes setup +python hermes +python hermes --tui +python -m pm.cli status +``` + +These commands use the selected development home. A source-file edit is visible +to the next process. Restart the affected CLI, gateway, or backend after edits. +Reinstalling every dependency is unnecessary for a Python-only source change. + +For the JavaScript workspaces, run `npm ci` once at the repository root, then +run the relevant workspace command. For example: + +```bash +npm run build --workspace ui-tui +npm run dev --workspace apps/desktop +``` + +The website is separate: `npm ci --prefix website`, then +`npm run build:fast --prefix website`. PM activation supplies tools, not these +`node_modules` directories or built assets. Native desktop builds have additional +requirements in the [desktop build guide](https://github.com/NousResearch/hermes-agent/blob/main/apps/desktop/BUILDING.md). + +### Refresh dependencies without changing branches + +After a branch or lockfile change, prepare dependencies with this checkout's PM: + +```bash +python -m pm.cli install +``` + +Then leave and reactivate the environment, and restart affected processes. +Use `python -m pm.cli doctor` for tool diagnostics and `python -m pm.cli status` +for the latest sync receipt. Do not run `hermes update` just to refresh a +feature branch: it is an application update and can change the source branch. + +Managed tool names and Python extra names are different interfaces: + +```bash +python -m pm.cli install chromium +python -c "from pm import sync_venv; sync_venv(['dev'], explicit=True)" +``` + +The first command installs a tool. The second adds the declared `dev` extra +to this installation's existing Python selection. Extras accumulate through PM +sync. `pm install dev` is not a supported command: `dev` is an extra, not a tool. +After changing extras, reactivate before starting another Python process. + +For a new project dependency, edit `pyproject.toml` and regenerate `uv.lock` +with `uv lock`. For JS dependencies, update the owning package manifest and +lock. Do not edit PM facts or generated workspaces. Unrecorded pip installs +are not durable and can disappear when PM selects a new environment. + +### Test and editor environments + +PM's `dev` extra does not make a bare store Python suitable for the canonical +test runner. The runner clears `PYTHONPATH` and needs an interpreter with pytest +installed in its own environment. Use the contributor guide's +[independent test environment](/developer-guide/contributing#manual-development-and-test-environment), +then run `scripts/run_tests.sh` (through Bash on Windows). + +The runner checks repository `.venv`, repository `venv`, and the standard +source-install venv before using `HERMES_PYTHON` as a fallback. Read its startup +message to confirm which interpreter it selected. A worktree without a local +venv can use the independent test interpreter through that variable. + +For editor debugging, select that independent interpreter, set the working +directory to this checkout, and launch `hermes` as the script. Keep its +`HERMES_HOME` separate from production. Terminal activation does not configure +an editor that was already running. Do not point an editor at a transient PM +generation or a signed application's Python executable. + +## Commands + +```bash +hermes pm --help +hermes pm doctor +hermes pm status +hermes pm install +hermes pm install chromium +``` + +| Command | Effect | +|---|---| +| `pm install [names...]` | Install named packages. With no names, provision required tools plus Python and sync the `all` extra. | +| `pm env [names...]` | Print the composed environment of installed packages as JSON. It does not install missing packages. | +| `pm doctor` | Check installed tool identities, files, and digests against the lock. | +| `pm status` | Print the latest sync/update receipt as JSON, or report that no receipt exists. | +| `pm gc` | Remove unreferenced tool-store entries, eligible download partials, and unused lease-managed Python generations. | + +`pm env` can include inherited environment values. Do not publish its output +without removing credentials. + +### Maintainer commands + +These commands change dependency inputs or stage build artifacts. They are +not substitutes for an installed application's update mechanism. + +| Command | Effect | +|---|---| +| `pm lock --bump NAME VERSION` | Resolve and hash supported target artifacts, then write the tool pin. | +| `pm update [names...]` | Query upstream versions, change tool pins, and install changed tools. | +| `pm update --check` | Query without writing. Exit 1 can mean updates exist; inspect output to distinguish an error. | +| `pm update --target TARGET` | Resolve versions for the specified target. | +| `pm update --uv` / `--npm` | Also refresh the Python or npm dependency resolution. | +| `pm install --target TARGET NAME...` | Stage explicit cross-target packages without recording them as the host's installed runtime. | +| `pm bundle --out DIR [--ref REF]` | Stage a source snapshot, native tools, facts, and Python dependencies. It does not produce a signed desktop installer. | + +The complete desktop builder also builds the JavaScript surfaces, generates +launchers, and invokes native packaging. Maintainers can read +[Building the Desktop Installers](https://github.com/NousResearch/hermes-agent/blob/main/apps/desktop/BUILDING.md). + +## Diagnostics + +- **Missing or outdated tool:** read `hermes pm doctor`, then use an explicit PM install on a writable installation. +- **New environment requires restart:** restart the affected Hermes process. Do not add a second site-packages tree to its live imports. +- **Dependency conflict:** read `hermes pm status`. Correct the plugin requirements before retrying admission. +- **Damaged packaged base:** repair or reinstall through the package owner. Do not alter signed files to suppress the diagnostic. +- **Unknown package or extra:** use the declared name. `pm install` takes package names, not Python extra names or pip specifications. diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index f569daebfc..89b4b9b7c1 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -14,15 +14,15 @@ It runs on **macOS, Windows, and Linux**. Hermes has several front ends that all talk to the same agent: - **Desktop App** (this page) — a native application with a purpose-built UI for chat, configuration, and management. -- **CLI** (`hermes`) and **[TUI](./tui.md)** (`hermes --tui`) — terminal interfaces. -- **[Web Dashboard](./features/web-dashboard.md)** (`hermes dashboard`) — a browser admin panel; its optional **Chat** tab embeds the TUI through a pseudo-terminal. +- **CLI** (`hermes`) and **[TUI](/user-guide/tui)** (`hermes --tui`) — terminal interfaces. +- **[Web Dashboard](/user-guide/features/web-dashboard)** (`hermes dashboard`) — a browser admin panel; its optional **Chat** tab embeds the TUI through a pseudo-terminal. Pick whichever fits the moment. They share state, so you can start a session in one and resume it in another. ::: ## Install -Download the app from the [Hermes Desktop product page](https://hermes-agent.nousresearch.com/desktop), or follow the [installation instructions for Hermes Desktop](../getting-started/installation.md). +Download the app from the [Hermes Desktop product page](https://hermes-agent.nousresearch.com/desktop), or follow the [installation instructions for Hermes Desktop](/getting-started/installation). If you already have Hermes installed, simply run @@ -30,7 +30,58 @@ If you already have Hermes installed, simply run hermes desktop ``` -That uses your current config, keys, sessions, and skills. +That uses the selected installation's configuration and data home. + +:::warning Current source-branch package limit +The Python 3.14 migration still needs a POSIX payload-resolver correction: +Electron currently defaults to a Python 3.11 dependency path. Do not treat +new macOS/Linux bundles from this branch as accepted until that correction +and native startup/update checks pass. This does not describe the status of +an older published package. +::: + +### Package variants and CLI commands + +The bundled app includes a local agent, Python, supported dependencies, and +prebuilt interfaces. It runs from app resources, not a first-launch source clone. +A `Hermes-Setup` bootstrap installer instead provisions a source checkout. +Light is a remote-only variant with no local Python runtime; it has no current +release matrix leg. + +- **Windows MSIX:** the package requires Windows 11 22H2 or later. Open its + `.appinstaller` descriptor to register the sideload update source. Windows + execution aliases expose `hermes`, `hermes-agent`, and `hermes-acp`. +- **macOS:** copy the app from its DMG into Applications before opening it. + Bundled startup attempts to link those CLI commands into `~/.local/bin`. + Add that directory to your shell's PATH. Existing entries, including stale + symlinks, are left untouched; inspect them if the wrong command runs. +- **Linux:** source development and AppImage build support exist, but the + bundled desktop release legs are disabled. Do not assume a published Linux + package from the local build target alone. + +A bundled app does not switch to a separate checkout just because `hermes` is +on PATH. To run modified code, use a source-built app and the +[source-install guide](/user-guide/switching-to-source). + +### Updates, provenance, and removal + +Settings → About shows the app build, runtime, distribution, and install +identity. Keep these facts separate: a shell version and backend version can +differ on a source or remote connection. + +Sideload Windows updates use Windows App Installer. Store packages use the +Microsoft Store. macOS bundled updates use `electron-updater` and Squirrel.Mac. +Source-built apps retain the checkout update handoff. An unavailable update +check is not proof that the app is current. + +The app's base runtime is immutable. PM additions and user data live outside +it. Updating the app replaces its bundled code and dependencies together. +It does not update the server behind a remote connection. + +Remove MSIX/Store packages through Windows Settings. On macOS, quit the app +and move it to Trash. Data removal is separate and can affect another install +that shares the same home. See +[Updating & Uninstalling](/getting-started/updating). ## What's in the app @@ -53,12 +104,12 @@ The center of the app. You get: The bar along the bottom of the chat shows live session state and exposes quick controls without opening Settings: -- **Per-session YOLO toggle** — flip YOLO on or off for just this session (matching the TUI). YOLO bypasses the dangerous-command approval prompts, so know what you're turning off — see [Security → YOLO Mode](./security.md#yolo-mode). +- **Per-session YOLO toggle** — flip YOLO on or off for just this session (matching the TUI). YOLO bypasses the dangerous-command approval prompts, so know what you're turning off — see [Security → YOLO Mode](/user-guide/security). - **Context-usage meter** — a live "% full" meter of the session's context window. Click it to open the **Context Usage** popover with a token breakdown by category (system prompt, tool definitions, skills, memory, rules, MCP, subagent definitions, and the conversation itself) so you can see exactly what's eating the window before compression kicks in. - **Cache hit rate and tokens per second** — off by default; turn them on from the right-click menu. Cache hit rate is the share of this session's prompt tokens served from the provider's prompt cache (cached tokens cost less, so higher is cheaper — you can watch a session get cheaper as it warms up). Tokens per second is output throughput averaged over the last 10 model calls. Both update live during a turn. - **Customizable items** — right-click the status bar (**Show in status bar**) to choose what appears: the context meter, cache hit rate, tokens per second, workspace, model, approvals, turn/session timers, terminal, Command Center, backend version, and more — or hide the bar entirely (**Cmd/Ctrl+Shift+S** toggles it). -Chatting against a Hermes instance on another machine instead of the bundled local backend? See [Connecting to a remote backend](#connecting-to-a-remote-backend) below — and for the full picture of how the remote-hosted dashboard connection works (the auth gate, the `/api/ws` chat socket, and WebSocket close-code triage), see [Web Dashboard → Connecting Hermes Desktop to a remote backend](./features/web-dashboard.md#connecting-hermes-desktop-to-a-remote-backend). +Chatting against a Hermes instance on another machine instead of the bundled local backend? See [Connecting to a remote backend](#connecting-to-a-remote-backend) below — and for the full picture of how the remote-hosted dashboard connection works (the auth gate, the `/api/ws` chat socket, and WebSocket close-code triage), see [Web Dashboard → Connecting Hermes Desktop to a remote backend](/user-guide/features/web-dashboard). #### Repository discovery @@ -127,7 +178,7 @@ Quick Entry is a small always-available composer summoned by a **global hotkey f ### Voice -Talk to Hermes and hear it back, the same [voice mode](./features/voice-mode.md) available elsewhere. On macOS the OS will prompt once for microphone access. +Talk to Hermes and hear it back, the same [voice mode](/user-guide/features/voice-mode) available elsewhere. On macOS the OS will prompt once for microphone access. ### HUD mode @@ -172,7 +223,7 @@ First-run onboarding has been redesigned on a unified overlay design system, and #### Per-profile settings: the "Applies to" scope -When you have two or more [profiles](./profiles.md), the config-backed settings pages — **Model, Workspace, Safety, Memory & Context, Voice, Chat, Advanced, and Tools & Keys** — and the **Messaging** overlay show a shared **Applies to** chip row at the top. It selects which profile your edits target: +When you have two or more [profiles](/user-guide/profiles), the config-backed settings pages — **Model, Workspace, Safety, Memory & Context, Voice, Chat, Advanced, and Tools & Keys** — and the **Messaging** overlay show a shared **Applies to** chip row at the top. It selects which profile your edits target: - The default selection **follows the active profile**, which behaves exactly as before — edit the profile you're using. - Pick another profile to view and edit *its* settings without switching the whole app; the selection persists as you move between settings pages. @@ -185,17 +236,17 @@ When you have two or more [profiles](./profiles.md), the config-backed settings The app also surfaces the broader Hermes management surface so you don't have to drop to a terminal: -- **Skills** — browse, install, and manage [skills](./features/skills.md). The Skills tab lists your installed skills with enable/disable toggles, and below them the full built-in optional-skills catalog that ships with Hermes — each entry has a one-click **Install** button that flips the row into the installed list once it finishes. -- **Memory graph (Star Map)** — type `/journey` (aliases `/learning`, `/memory-graph`) in chat to open an interactive constellation of learned skills and memories over time, with a playback scrubber. Nodes can be edited or deleted right from the panel (skills are archived, memories removed). See [Learning Journey](./features/memory.md#learning-journey-journey). -- **Cron** — view and manage [scheduled jobs](../reference/cli-commands.md#hermes-cron). -- **Profiles** — switch between [Hermes profiles](./profiles.md) (isolated config/skills/sessions). +- **Skills** — browse, install, and manage [skills](/user-guide/features/skills). The Skills tab lists your installed skills with enable/disable toggles, and below them the full built-in optional-skills catalog that ships with Hermes — each entry has a one-click **Install** button that flips the row into the installed list once it finishes. +- **Memory graph (Star Map)** — type `/journey` (aliases `/learning`, `/memory-graph`) in chat to open an interactive constellation of learned skills and memories over time, with a playback scrubber. Nodes can be edited or deleted right from the panel (skills are archived, memories removed). See [Learning Journey](/user-guide/features/memory). +- **Cron** — view and manage [scheduled jobs](/reference/cli-commands). +- **Profiles** — switch between [Hermes profiles](/user-guide/profiles) (isolated config/skills/sessions). - **Messaging** — set up gateway channels. - **Agents** and **Command Center** — orchestration surfaces for multi-agent work. ### Bot Mode (built in) **Bot Mode** ships with the app and is on by default: a "one chat per agent" -roster where every [Hermes profile](./profiles.md) appears as a bot with its +roster where every [Hermes profile](/user-guide/profiles) appears as a bot with its own avatar (geometric face, uploaded image, AI-generated portrait, or a pixel pet), its own canonical **Bot Chat** conversation, and its own **Routines** (recurring tasks backed by Hermes cron). The roster lives in the left @@ -246,7 +297,7 @@ routines pane, and composer middleware unregister live, no restart needed. Full guide — creating agents (including the multi-machine **Create on** picker), the roster across connections, bot-to-bot mentions, and how group -chats decide who replies: [Bot Mode: A Roster of Agents](./bot-mode.md). +chats decide who replies: [Bot Mode: A Roster of Agents](/user-guide/bot-mode). ### Keyboard & navigation @@ -259,8 +310,8 @@ chats decide who replies: [Bot Mode: A Roster of Agents](./bot-mode.md). - **Session-list overhaul** — a reworked session list with archiving and general session hygiene to keep the list manageable as it grows. - **Search sessions by id** — find a specific session directly by its id. -- **Concurrent multi-profile sessions** — run sessions across multiple [profiles](./profiles.md) at the same time, and reference a session in another profile with cross-profile `@session` links. -- **Export / import a profile** — share a whole setup as a single file. **⌘K → Export profile…** (or right-click a profile square in the rail) writes a `.tar.gz` with skills, memory, persona, crons, plugins, and settings; API keys are stripped. Exporting from the desktop also bundles your appearance and interface — skin, light/dark mode, custom themes, the profile's rail color, and your window layout — so an imported profile arrives looking the way the sender had it. Import via **⌘K → Import profile…** or the button beside the rail's **+**; it applies the overlay and drops you into the new profile. The same archive works with `/export` / `/import` in chat and `hermes profile export` / `import` from a shell. See [Export and import a profile file](./profile-distributions.md#export-and-import-a-profile-file). +- **Concurrent multi-profile sessions** — run sessions across multiple [profiles](/user-guide/profiles) at the same time, and reference a session in another profile with cross-profile `@session` links. +- **Export / import a profile** — share a whole setup as a single file. **⌘K → Export profile…** (or right-click a profile square in the rail) writes a `.tar.gz` with skills, memory, persona, crons, plugins, and settings; API keys are stripped. Exporting from the desktop also bundles your appearance and interface — skin, light/dark mode, custom themes, the profile's rail color, and your window layout — so an imported profile arrives looking the way the sender had it. Import via **⌘K → Import profile…** or the button beside the rail's **+**; it applies the overlay and drops you into the new profile. The same archive works with `/export` / `/import` in chat and `hermes profile export` / `import` from a shell. See [Export and import a profile file](/user-guide/profile-distributions). ## Updating @@ -305,7 +356,7 @@ To launch via the CLI, simply run `hermes desktop`. By default it installs works ## How it works -The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — **the same layout a CLI install uses**, which is why the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `--ignore-existing` / `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers such as Nix. The React renderer talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — and reuses the agent runtime rather than embedding `hermes --tui`. The desktop app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the [web dashboard](./features/web-dashboard.md). (Runtimes older than the `serve` command fall back to a headless `dashboard --no-open` automatically, so an app update never outruns its backend.) Install, backend-resolution, and self-update logic live in the Electron main process. +The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — **the same layout a CLI install uses**, which is why the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `--ignore-existing` / `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers such as Nix. The React renderer talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — and reuses the agent runtime rather than embedding `hermes --tui`. The desktop app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the [web dashboard](/user-guide/features/web-dashboard). (Runtimes older than the `serve` command fall back to a headless `dashboard --no-open` automatically, so an app update never outruns its backend.) Install, backend-resolution, and self-update logic live in the Electron main process. ## Connecting to a remote backend @@ -322,7 +373,7 @@ Gateway connections are **machine-level**: the Gateways page manages which gatew ### The multi-connection registry -Further down the same **Settings → Gateways** page, **Registered gateways** manages a named list of every Hermes gateway the app knows about — the local runtime, any number of remote gateways (LAN, Tailscale, internet), Hermes Cloud instances, and SSH hosts — all persisted together in one place. You can jump there from the plug button at the right end of the sidebar profile rail (**Connect another Hermes gateway…**) or via **⌘K → Gateways**. The full guide, including the union agent roster, `@name-device` handles, fleet-wide updates, and the plugin SDK surface, is at [Connecting Desktop to Many Hermes Instances](./multi-connection-desktop.md). +Further down the same **Settings → Gateways** page, **Registered gateways** manages a named list of every Hermes gateway the app knows about — the local runtime, any number of remote gateways (LAN, Tailscale, internet), Hermes Cloud instances, and SSH hosts — all persisted together in one place. You can jump there from the plug button at the right end of the sidebar profile rail (**Connect another Hermes gateway…**) or via **⌘K → Gateways**. The full guide, including the union agent roster, `@name-device` handles, fleet-wide updates, and the plugin SDK surface, is at [Connecting Desktop to Many Hermes Instances](/user-guide/multi-connection-desktop). - **Every connection needs a unique name** (a device name such as "Homelab" or "Work laptop"). When the same profile name exists on several registered gateways, surfaces disambiguate it as `@profile-device` (e.g. `@research-homelab`). - **Switch gateways from the Sessions sidebar.** A named gateway selector appears when more than one gateway is registered and handles any registry size without making gateways look like profiles. The adjacent profile rail then shows only that gateway's agents and remembers the last profile used there; large profile sets condense independently. @@ -347,7 +398,7 @@ The connection has two halves: on the backend you protect it with an **auth prov - **OAuth (Nous Portal) — preferred for anything reachable beyond your own machine.** Logins are verified against your Nous account, so this is the option suitable for a VPS, a public host, or any remote backend. Register the dashboard with `hermes dashboard register` (or the Portal [`/local-dashboards`](https://portal.nousresearch.com/local-dashboards) page) to provision its OAuth client, then sign in from the app with **Sign in with Nous Research**. A self-hosted OIDC provider works the same way if you run your own identity provider. - **Username/password — local / trusted-network use only.** The simplest option when the backend is on the same trusted LAN or reachable only over a VPN (e.g. Tailscale). It protects a single shared credential with no external identity provider, so **do not use it for a dashboard exposed to the public internet** — reach for OAuth there instead. -The rest of this section shows the username/password path because it's the quickest to stand up on a trusted network; for the OAuth path see [Web Dashboard → Default provider: Nous Research](./features/web-dashboard.md#default-provider-nous-research). +The rest of this section shows the username/password path because it's the quickest to stand up on a trusted network; for the OAuth path see [Web Dashboard → Default provider: Nous Research](/user-guide/features/web-dashboard). ### On the backend (the remote machine) @@ -372,9 +423,9 @@ hermes serve --host 0.0.0.0 --port 9119 Keep that `hermes serve` process running for as long as you want the desktop app to be able to connect — if it stops, the app can no longer reach the backend. Run it under `systemd`, `tmux`, or your process manager of choice so it survives logout and reboots. -Separately, make sure the **gateway is running** on the remote host if you rely on messaging channels — the `hermes serve` backend is what the desktop app talks to, but your Telegram/Discord/Slack gateway sessions are a different process that you start and keep running on their own. See [Messaging](./messaging/index.md) for gateway setup. +Separately, make sure the **gateway is running** on the remote host if you rely on messaging channels — the `hermes serve` backend is what the desktop app talks to, but your Telegram/Discord/Slack gateway sessions are a different process that you start and keep running on their own. See [Messaging](/user-guide/messaging) for gateway setup. -Prefer not to keep a plaintext password at rest? Set `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH` to a scrypt hash instead — compute it with `python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"`. Full configuration surface (config.yaml keys, every env var, the rate limiter): [Web Dashboard → Username/password provider](./features/web-dashboard.md#usernamepassword-provider-no-oauth-idp). +Prefer not to keep a plaintext password at rest? Set `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH` to a scrypt hash instead — compute it with `python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"`. Full configuration surface (config.yaml keys, every env var, the rate limiter): [Web Dashboard → Username/password provider](/user-guide/features/web-dashboard). Running the backend as a systemd service? Give the unit `EnvironmentFile=%h/.hermes/.env` so the credentials are in the environment at boot. @@ -393,7 +444,7 @@ The backend reads and writes your `.env` (API keys, secrets) and can run agent c You can also set the backend URL without the UI via the `HERMES_DESKTOP_REMOTE_URL` environment variable before launching the app (it overrides the in-app setting); you still sign in from the Gateways settings panel. :::note Per-profile remote hosts -The remote gateway host is configured per [profile](./profiles.md), so each profile can point at its own remote backend (or stay on its local one). Switching profiles switches which remote host the app connects to. +The remote gateway host is configured per [profile](/user-guide/profiles), so each profile can point at its own remote backend (or stay on its local one). Switching profiles switches which remote host the app connects to. ::: ### Troubleshooting @@ -403,7 +454,7 @@ The remote gateway host is configured per [profile](./profiles.md), so each prof - **Signed out on every restart** — set `HERMES_DASHBOARD_BASIC_AUTH_SECRET` to a stable value. Without it the token-signing key is regenerated per boot, invalidating all sessions. - **Connection refused / times out** — the backend bound to `127.0.0.1` (the default) or a firewall/VPN is blocking the port. Bind to `0.0.0.0` or the tailscale IP and open the port to your trusted network. -For the same setup from the web-dashboard angle, see [Web Dashboard → Connecting Hermes Desktop to a remote backend](./features/web-dashboard.md#connecting-hermes-desktop-to-a-remote-backend); the env vars are catalogued under [Environment Variables → Web Dashboard & Hermes Desktop](../reference/environment-variables.md#web-dashboard--hermes-desktop). +For the same setup from the web-dashboard angle, see [Web Dashboard → Connecting Hermes Desktop to a remote backend](/user-guide/features/web-dashboard); the env vars are catalogued under [Environment Variables → Web Dashboard & Hermes Desktop](/reference/environment-variables). ## Extending the desktop app @@ -413,11 +464,11 @@ you can add your own. A plugin is a single ESM file dropped in `$HERMES_HOME/desktop-plugins//plugin.js`; the app loads it within seconds and hot-reloads every save. Manage installed plugins live in **Settings → Plugins**. -See [Desktop Plugin SDK](../developer-guide/desktop-plugin-sdk.md) for the full -reference. (This is separate from the [web dashboard plugin system](./features/extending-the-dashboard.md).) +See [Desktop Plugin SDK](/developer-guide/desktop-plugin-sdk) for the full +reference. (This is separate from the [web dashboard plugin system](/user-guide/features/extending-the-dashboard).) The **Agent plugins** section on the same Settings → Plugins page manages -backend (agent-side) [plugins](./features/plugins.md) you installed — user, +backend (agent-side) [plugins](/user-guide/features/plugins) you installed — user, git, project, pip, and portable installs. Repo-bundled built-ins (platform adapters, provider plugins, and similar) are not listed there: they ship enabled by default and are configured from their own surfaces, so the section @@ -539,7 +590,10 @@ npm run dist:linux # AppImage + deb + rpm npm run pack # unpacked app under release/ (no installer) ``` -macOS/Windows signing and notarization run automatically when the relevant credentials are present in the environment (`CSC_LINK` / `CSC_KEY_PASSWORD` / `APPLE_*` for macOS, `WIN_CSC_*` for Windows). +These commands package the current desktop build, not a complete tagged runtime. +The [bundle build guide](https://github.com/NousResearch/hermes-agent/blob/main/apps/desktop/BUILDING.md) +explains the complete builder, Azure Trusted Signing, Apple notarization, +and R2 publication. A package build is not a stable-release acceptance result. ### macOS permissions and local rebuilds (TCC) @@ -611,8 +665,8 @@ time. Grants are stable from then on. If a permission gets stuck, reset it with ## See also -- [CLI Guide](./cli.md) — the terminal interface -- [TUI](./tui.md) — the modern terminal UI used by `hermes --tui` and the dashboard chat tab -- [Web Dashboard](./features/web-dashboard.md) — browser admin panel with an embedded chat tab -- [Configuration](./configuration.md) — config that the desktop app reads and writes -- [Windows (Native)](./windows-native.md) — native Windows install path +- [CLI Guide](/user-guide/cli) — the terminal interface +- [TUI](/user-guide/tui) — the modern terminal UI used by `hermes --tui` and the dashboard chat tab +- [Web Dashboard](/user-guide/features/web-dashboard) — browser admin panel with an embedded chat tab +- [Configuration](/user-guide/configuration) — config that the desktop app reads and writes +- [Windows (Native)](/user-guide/windows-native) — native Windows install path diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md index dbdb77cb21..6a1c315e79 100644 --- a/website/docs/user-guide/docker.md +++ b/website/docs/user-guide/docker.md @@ -13,6 +13,22 @@ There are two distinct ways Docker intersects with Hermes Agent: This page covers option 1. The container stores all user data (config, API keys, sessions, skills, memories) in a single directory mounted from the host at `/opt/data`. The image itself is stateless and can be upgraded by pulling a new version without losing any configuration. +## Image channels and runtime ownership + +| Image tag | Meaning | +|---|---| +| `latest` / `stable` | The image accepted by the stable release gate. | +| `main` | The development image published from main-branch builds. | +| `X.Y.Z` | A versioned stable image. Use a digest for an exact deployment pin. | + +The workflow builds and tests amd64 and arm64 images. Stable publication uses +the tested image archives rather than rebuilding them. Only the full release +promotion moves `stable` and `latest`; a main push does not advance those tags. + +The image's Python environment follows `pyproject.toml` and `uv.lock` (currently +Python 3.14). Its curated extras are not the native desktop bundle's +`--all-extras` set. It does not include an Electron desktop app. + ## Quick start If this is your first time running Hermes Agent, create a data directory on the host and start the container interactively to run the setup wizard: @@ -484,18 +500,33 @@ docker run -d \ ## What the Dockerfile does -The official image is based on `debian:13.4` and includes: +The image uses Debian 13.4 and includes: -- Python 3.13 with dependencies synced from the lockfile via `uv sync --frozen --no-install-project` for the baked extras (`all`, `messaging`, Anthropic/Bedrock/Azure identity, Hindsight, Matrix), followed by a no-dependency editable install of Hermes itself. -- Node.js 26 + npm (for browser automation, WhatsApp bridge, TUI/Desktop bundles, and workspace build tooling) -- Playwright with Chromium (`npx playwright install --with-deps chromium --only-shell`) -- ripgrep, ffmpeg, git, and `xz-utils` as system utilities -- **`docker-cli`** — so agents running inside the container can drive the host's Docker daemon (bind-mount `/var/run/docker.sock` to opt in) for `docker build`, `docker run`, container inspection, etc. -- **`openssh-client`** — enables the [SSH terminal backend](/user-guide/configuration#ssh-backend) from inside the container. The SSH backend shells out to the system `ssh` binary; without this, it failed silently in containerized installs. -- The WhatsApp bridge (`scripts/whatsapp-bridge/`) -- **[`s6-overlay`](https://github.com/just-containers/s6-overlay) v3** as PID 1 (replaces the older `tini`) — supervises the dashboard and per-profile gateways with auto-restart on crash, reaps zombie subprocesses, and forwards signals. +- A Python 3.14 environment synchronized from the committed `uv.lock`, followed + by a no-dependency editable install of Hermes. +- The curated extras `all`, `messaging`, `otlp`, `anthropic`, `bedrock`, + `azure-identity`, `hindsight`, and `matrix`. This is not `--all-extras`. +- Node.js 26 and npm from the digest-pinned Node source image. +- PM-pinned uv, Chromium, and Chromium headless shell in `/opt/hermes/tools`. +- System Git, ripgrep, FFmpeg, OpenSSH, Docker CLI, and Chromium shared libraries. +- Prebuilt TUI/dashboard assets and baked Photon sidecar dependencies. +- s6-overlay for supervision and zombie-process cleanup. -The image treats `/opt/hermes` as an immutable install tree at runtime. Optional Python extras, Node workspaces, and TUI assets that must be available inside Docker need to be baked during the image build; runtime lazy installs are disabled so supervised gateways and `docker exec hermes …` commands do not try to write dependency artifacts back into the read-only source tree. +Chromium is staged through PM, not `npx playwright install`. The build records +its resolved executable in `/etc/hermes/agent-browser-executable-path`. +`PLAYWRIGHT_BROWSERS_PATH` names `/opt/hermes/tools`, outside the data mount. + +The image disables on-demand dependency installation with its internal +`HERMES_DISABLE_LAZY_INSTALLS` policy. Changing `security.allow_lazy_installs` +alone does not override that image policy. The old `lazy-packages` overlay is +not used. Build additional required dependencies into a derived image, or run +an independent tool in a separate environment/service. + +Image provenance lives at `/etc/hermes/image-provenance.json`, outside both the +source and data mounts. The build stamp lives at `/opt/hermes/install-stamp.json`. +A local build without a supplied stamp reports an unknown revision rather than +inventing a commit. `hermes update` refuses image-owned code changes; replace +the image to update the application. The container's `ENTRYPOINT` is a small dispatcher (`docker/entrypoint-dispatch.sh`). When the container owns PID 1 (normal Docker / Podman), it exec's s6-overlay's `/init` and you get the full supervision tree described below. When a platform wraps the image entrypoint under its own PID-1 init (Fly.io Machines, `docker run --init`, some Nomad/Kubernetes setups), `/init` would abort with `s6-overlay-suexec: fatal: can only run as pid 1` — so the dispatcher instead runs the stage2 bootstrap directly and exec's the main wrapper without s6. On that fallback path the requested command still runs, but supervised services (dashboard, per-profile gateways) are unavailable. @@ -519,7 +550,7 @@ Do not override the image entrypoint unless you keep `/init` (or, equivalently, ### `docker exec` automatically drops to the `hermes` user -`docker exec hermes ` defaults to running as root inside the container, but the image ships a thin shim at `/opt/hermes/bin/hermes` (earliest on PATH) that detects root callers and transparently re-execs through `s6-setuidgid hermes`. So `docker exec hermes login`, `docker exec hermes profile create …`, `docker exec hermes setup`, etc. all write files owned by UID 10000 — i.e. readable by the supervised gateway — with no extra `--user` flag needed. Non-root callers (the supervised processes themselves, `docker exec --user hermes`, kanban subagents inside the container) hit a short-circuit that exec's the venv binary directly, so there's no overhead on the hot paths. +`docker exec hermes ` defaults to running as root inside the container, but the image ships a thin shim at `/opt/hermes/bin/hermes` (earliest on PATH) that detects root callers and transparently re-execs through `s6-setuidgid hermes`. So `docker exec hermes hermes login`, `docker exec hermes hermes profile create …`, `docker exec hermes hermes setup`, etc. all write files owned by UID 10000 — i.e. readable by the supervised gateway — with no extra `--user` flag needed. Non-root callers (the supervised processes themselves, `docker exec --user hermes`, kanban subagents inside the container) hit a short-circuit that exec's the venv binary directly, so there's no overhead on the hot paths. If you specifically need a `docker exec` that retains root semantics (diagnostic sessions, inspecting root-only state, files outside `/opt/data` that root happens to own), opt out per invocation: @@ -588,7 +619,11 @@ Dependencies are fetched on demand and cached for the life of the container. Con ### Other tools (apt packages, binaries) — install and remember -For anything outside npm or PyPI — `apt` packages, prebuilt binaries, language runtimes not already in the image — instruct Hermes how to install it (e.g. `apt-get update && apt-get install -y `) and tell it to remember the install command. The tool persists for the rest of the container's lifetime, and Hermes will re-run the install command after a container restart when it next needs the tool. +The runtime user cannot install system APT packages. For occasional tools, an +operator can deliberately use a root shell; those changes last only until the +container is replaced. A container restart retains its writable layer, while +recreation does not. Memory of an install command is not automatic provisioning. +Use a derived image for repeatable system dependencies. This is a good fit for tools that are quick to install and used occasionally. For tools used constantly, prefer the next approach. @@ -603,7 +638,7 @@ USER root RUN apt-get update \ && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* -USER hermes +# Keep the root entrypoint; s6 drops privileges for the runtime. ``` Build it and use it in place of the official image: @@ -801,9 +836,8 @@ Check logs: `docker logs hermes`. Common causes: The container's stage2 hook drops privileges to the non-root `hermes` user (UID 10000) via `s6-setuidgid` inside each supervised service. If your host `~/.hermes/` is owned by a different UID, set `HERMES_UID`/`HERMES_GID` — or their `PUID`/`PGID` aliases, for parity with LinuxServer.io and NAS images — to match your host user, or ensure the data directory is writable: -```sh -chmod -R 755 ~/.hermes -``` +Do not make the whole data tree world-readable. It contains credentials. +Match the container UID/GID to the bind mount's owner instead. On a NAS (UGOS, Synology, unRAID) the data directory is typically a **bind mount** owned by a host UID the container cannot `chown`. Set `PUID`/`PGID` (or `HERMES_UID`/`HERMES_GID`) to that host user so the runtime runs as the owner of the mount rather than UID 10000: @@ -851,6 +885,6 @@ docker restart hermes ```sh docker logs --tail 50 hermes # Recent logs -docker run -it --rm nousresearch/hermes-agent:latest version # Verify version +docker run -it --rm nousresearch/hermes-agent:latest --version # Verify version docker stats hermes # Resource usage ``` diff --git a/website/docs/user-guide/features/code-execution.md b/website/docs/user-guide/features/code-execution.md index 1585f415a2..1a590a716b 100644 --- a/website/docs/user-guide/features/code-execution.md +++ b/website/docs/user-guide/features/code-execution.md @@ -155,6 +155,17 @@ Security-critical invariants are identical across both modes: Switching mode changes where scripts run and which interpreter runs them, not what credentials they can see or which tools they can call. +## Persistent session kernel + +Calls reuse a Python child for the same session, execution mode, interpreter, +working directory, and tool set. Imports, variables, and loaded data can persist +between cells. The child environment is fixed when the kernel starts. + +Pass `reset: true` to discard that kernel state. A timeout or interrupted kernel +can also lose it. Do not assume that a later terminal environment change is +already visible inside an existing kernel. The old `code_execution.kernel_mode` +setting is no longer a separate switch. + ## Resource Limits | Resource | Limit | Notes | diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index 1c35b07589..2fbeef7e52 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -360,6 +360,10 @@ Server-side LLM fact extraction with semantic search, reranking, and automatic d | **Data storage** | Mem0 Cloud (platform), your own Mem0 server (self-hosted dashboard), or in-process (OSS) | | **Cost** | Mem0 pricing (platform) / free (self-hosted or OSS) | +The `mem0` SDK extra is excluded on native Windows ARM64. An external Mem0 +server over HTTP is a separate mode; a remote service does not imply that the +in-process SDK runs on that target. + **Tools (4):** `mem0_search` (semantic search; optional reranking in platform mode, off by default), `mem0_add` (store verbatim facts), `mem0_update` (update by ID), `mem0_delete` (delete by ID) **Setup (Platform):** @@ -448,15 +452,22 @@ hermes config set memory.provider hindsight echo "HINDSIGHT_API_KEY=your-key" >> ~/.hermes/.env ``` -The setup wizard installs dependencies automatically and only installs what's needed for the selected mode (`hindsight-client` for cloud, `hindsight-all` for local). Requires `hindsight-client >= 0.4.22` (auto-upgraded on session start if outdated). +The client uses the locked `hindsight` extra through PM. Local Embedded adds +a separately resolved runtime for `hindsight-embed` and `hindsight-api-slim`, +outside Hermes' shared Python environment. Its generations and `active.json` +selection live under `$HERMES_HOME/profiles/Hindsight/env/`. The daemon uses that +interpreter, and Hermes connects through the HTTP client. Re-run +`hermes memory setup` to install or repair the local runtime. A client dependency +change can require restarting Hermes. -**Local mode UI:** `hindsight-embed -p hermes ui start` +The local Hindsight CLI belongs to the selected side environment, not the +global PATH. Its environment is independent of the Hermes application payload. **Config:** `$HERMES_HOME/hindsight/config.json` | Key | Default | Description | |-----|---------|-------------| -| `mode` | `cloud` | `cloud` or `local` | +| `mode` | `cloud` | `cloud`, `local_embedded`, or `local_external` | | `bank_id` | `hermes` | Memory bank identifier | | `recall_budget` | `mid` | Recall thoroughness: `low` / `mid` / `high` | | `memory_mode` | `hybrid` | `hybrid` (context + tools), `context` (auto-inject only), `tools` (tools only) | diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index fa6de4d2d6..4cb0df3d50 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -327,7 +327,7 @@ services.hermes-agent = { # Directory plugin (source tree with plugin.yaml) extraPlugins = [ (pkgs.fetchFromGitHub { ... }) ]; # Entry-point plugin (pip package) - extraPythonPackages = [ (pkgs.python312Packages.buildPythonPackage { ... }) ]; + extraPythonPackages = [ (config.services.hermes-agent.package.python.pkgs.buildPythonPackage { ... }) ]; # Enable in config settings.plugins.enabled = [ "my-plugin" ]; }; @@ -343,7 +343,7 @@ hermes plugins list # table: enabled / disabled / not e hermes plugins search # search the community plugin index hermes plugins install # install by index name (resolved to repo @ pinned ref) hermes plugins install user/repo # install from Git, then prompt Enable? [y/N] -hermes plugins install user/repo --enable # install AND enable (no prompt) +hermes plugins install user/repo --enable # request enable; dependency consent still applies hermes plugins install user/repo --no-enable # install but leave disabled (no prompt) hermes plugins update my-plugin # pull latest (local edits are autostashed and re-applied) hermes plugins remove my-plugin # uninstall @@ -357,29 +357,45 @@ hermes plugins trust-update-url my-plugin # confirm a changed update_url afte ### Update checks and provenance -Hermes records where every `hermes plugins install` came from (the git -source and exact revision, in `.install-metadata.json`), and -`hermes plugins check-updates` uses that provenance to answer "is it -outdated?" without touching your working tree: git-installed plugins are -compared against their remote's HEAD via `git ls-remote`, and plugins -whose manifest declares an `update_url` (a small update-feed file) are -checked against that feed — the standard way for non-github-hosted -plugins to be update-checkable. Plugins you cloned yourself (no install -record) are offered `hermes plugins adopt` to become tracked installs. +Hermes records Git install source and revision in `.install-metadata.json`. +Unpinned tracked installs compare the saved source's remote HEAD, or a matching +saved `update_url` feed. Pinned installs remain pinned. Self-cloned directories +need `hermes plugins adopt NAME` before they become tracked installations. +Manually copied or provenance-drifted directories receive diagnostic guidance. +Pip entry-point plugins can report an owning distribution's available version; +that check does not turn them into Git-managed installs. -The check is read-only; applying updates stays explicit via -`hermes plugins update`, which re-runs the security scan on the pulled -code. A background check runs at most once a day (config key -`plugins.auto_update_check_hours`, default 24, `0` disables) and writes -its results where the desktop and `hermes pm status` read them; set -`plugins.auto_apply: true` to also apply git-plugin updates -unattended — the security scan still gates every apply. +`hermes plugins check-updates` leaves plugin files unchanged. A scheduled gateway +check runs when `plugins.auto_update_check_hours` is due: default 24 hours, +`0` disables it. Its receipt is available through `hermes pm status` and the +desktop sync-status view. This is not a hard once-per-day limit if you configure +a different interval. -If a plugin's manifest changes its `update_url` after install (visible -as a *needs fixing* warning in check-updates and `hermes doctor`), -Hermes refuses to fetch from the new address until you confirm it with -`hermes plugins trust-update-url` — an update can never silently -redirect where its code comes from. +By default, updates require `hermes plugins update NAME`. Setting +`plugins.auto_apply: true` opts tracked Git plugins into unattended updates. +Both routes use the update security scan. Auto-apply does not manage pinned, +manual, drifted, or pip-distribution rows. + +If a manifest changes or introduces `update_url`, Hermes refuses the new address +until you approve it with `hermes plugins trust-update-url NAME`. This is a +feed-source check, not a sandbox against already trusted plugin code. + +### Dependency preparation and preservation + +Python dependency installation has a separate consent/admission step. +`plugins install --enable` does not bypass that step. A declined or +non-interactive dependency install can leave the plugin installed but disabled. +Node sidecar dependencies have a separate prompt and remain plugin-local. + +PM prepares Python dependencies with core and the enabled plugin set before +publishing the new environment and configuration. A resolution failure preserves +the previous selection. Restart Hermes when a new selected environment is not +yet active in the running process. + +Ordinary Hermes application updates preserve user plugin directories, including +wrapper files and external sidecar links. Explicit plugin updates or removals +can change those files. See [Package management](../../reference/package-management.md) +and the [plugin authoring guide](../../developer-guide/plugins/index.md#lazy-install-optional-python-dependencies). ### One-click install links (Desktop) diff --git a/website/docs/user-guide/features/voice-mode.md b/website/docs/user-guide/features/voice-mode.md index 349b8936d2..bebdb5887f 100644 --- a/website/docs/user-guide/features/voice-mode.md +++ b/website/docs/user-guide/features/voice-mode.md @@ -40,30 +40,30 @@ A paid [Nous Portal](/user-guide/features/tool-gateway) subscription supplies th ### Python Packages -```bash -# CLI voice mode (microphone + audio playback) -cd ~/.hermes/hermes-agent && uv pip install -e ".[voice]" +Use `hermes tools` to configure voice providers. Missing built-in feature +requirements go through PM, subject to `security.allow_lazy_installs` and the +target's dependency support. Restart Hermes if the selected dependency +environment changes. -# Discord + Telegram messaging (includes discord.py[voice] for VC support) -cd ~/.hermes/hermes-agent && uv pip install -e ".[messaging]" - -# Premium TTS (ElevenLabs) -cd ~/.hermes/hermes-agent && uv pip install -e ".[tts-premium]" - -# Local TTS (NeuTTS, optional) -python -m pip install -U neutts[all] - -# Everything at once -cd ~/.hermes/hermes-agent && uv pip install -e ".[all]" -``` +A bundled app includes its supported engine dependencies. Docker includes a +curated subset and disables on-demand installs. Do not use pip to modify a +signed payload or the system Python. For a manual development environment, +select the required extras in the +[development setup](../../developer-guide/contributing.md#development-setup). | Extra | Packages | Required For | |-------|----------|-------------| -| `voice` | `sounddevice`, `numpy` | CLI voice mode | +| `voice` | `sounddevice`, `numpy`, and Faster-Whisper where supported | CLI audio and optional local STT | | `messaging` | `discord.py[voice]`, `python-telegram-bot`, `aiohttp` | Discord & Telegram bots | | `tts-premium` | `elevenlabs` | ElevenLabs TTS provider | -Optional local TTS provider: install `neutts` separately with `python -m pip install -U neutts[all]`. On first use it downloads the model automatically. +Local Faster-Whisper is excluded on native Windows ARM64 and Intel macOS. +Use a cloud or command-based STT provider on those targets. `audio-io` contains +the microphone/playback dependencies without local STT. The `all` extra does +not mean every voice or wake engine. + +NeuTTS is a separate optional runtime and downloads models on first use. +Do not install its dependencies into a signed app or system Python. :::info `discord.py[voice]` installs **PyNaCl** (for voice encryption) and **opus bindings** automatically. This is required for Discord voice channel support. @@ -94,7 +94,7 @@ Add to `~/.hermes/.env`: ```bash # Speech-to-Text — local provider needs NO key at all -# pip install faster-whisper # Free, runs locally, recommended +# PM prepares local Faster-Whisper on supported targets; no STT API key is needed. GROQ_API_KEY=your-key # Groq Whisper — fast, free tier (cloud) VOICE_TOOLS_OPENAI_KEY=your-key # OpenAI Whisper — paid (cloud) @@ -356,7 +356,7 @@ The bot auto-loads the codec from: DISCORD_BOT_TOKEN=your-bot-token DISCORD_ALLOWED_USERS=your-user-id -# STT — local provider needs no key (pip install faster-whisper) +# PM prepares local Faster-Whisper on supported targets; no STT API key is needed. # GROQ_API_KEY=your-key # Alternative: cloud-based, fast, free tier # TTS — optional. Edge TTS and NeuTTS need no key. @@ -478,7 +478,7 @@ tts: ```bash # Speech-to-Text providers (local needs no key) -# pip install faster-whisper # Free local STT — no API key needed +# PM prepares local Faster-Whisper on supported targets; no STT API key is needed. GROQ_API_KEY=... # Groq Whisper (fast, free tier) VOICE_TOOLS_OPENAI_KEY=... # OpenAI Whisper (paid) diff --git a/website/docs/user-guide/features/wake-word.md b/website/docs/user-guide/features/wake-word.md index d5bcab5097..6c2c6efbb2 100644 --- a/website/docs/user-guide/features/wake-word.md +++ b/website/docs/user-guide/features/wake-word.md @@ -76,21 +76,26 @@ backend process. | Engine | Cost | API key | Notes | |--------|------|---------|-------| -| **openWakeWord** (default) | Free | None | Local ONNX models. Ships a bundled **"hey hermes"** model (default); also supports `hey_jarvis`, `alexa`, `hey_mycroft`, … and custom models | -| **sherpa** | Free | None | **Open vocabulary** — detects ANY typed phrase with zero training. Small English model auto-downloads on first use (~13 MB) | +| **openWakeWord** (default) | Free | None | TFLite through `pyopen-wakeword`. Includes the **"hey hermes"** model. Custom models require a `.tflite` file. Not available on native Windows ARM64. | +| **sherpa** | Free | None | Open-vocabulary detection for typed phrases. Downloads an English model on first use. Not available on native Windows ARM64. | | **Porcupine** | Free tier / paid | `PORCUPINE_ACCESS_KEY` | Picovoice engine; built-in keywords + custom `.ppn` files | -By default the phrase is **"hey hermes"** — a model for it ships with Hermes, so -it works out of the box with no training. (On first use, openWakeWord downloads -its shared feature-extraction models — a small one-time fetch.) +The default phrase is **"hey hermes"**. Hermes includes its trained TFLite model. +The `pyopen-wakeword` package includes the shared feature-extraction models, so +this engine does not download models when it starts. -Both are lazy-installed the first time you enable the wake word (desktop -installs made with `--include-desktop` pre-install them, so the ear works -instantly). To install ahead of time: +If the selected engine is missing, Hermes requests its PM extra when you enable +wake-word detection. `security.allow_lazy_installs` controls this installation. +A new dependency environment can require a Hermes restart before the engine loads. +Packaged builds include the engine dependencies supported by their target. -```bash -cd ~/.hermes/hermes-agent && uv pip install -e ".[wake]" -``` +On native Windows ARM64, use Porcupine with an access key. The default +openWakeWord engine and sherpa are excluded from that target. + +The published `pyopen-wakeword` wheels target macOS 15 or later, glibc Linux +2.35 or later, and Windows x64. These are engine-wheel requirements, not a +blanket support statement for every Hermes feature. Termux's core/ACP package +does not include this wake stack. ## Quick start @@ -126,13 +131,14 @@ wake_word: confirmation_frames: 3 # openWakeWord only — consecutive over-threshold frames required to fire start_new_session: true # start a fresh session on wake vs. continue the current one openwakeword: - model: hey_hermes # bundled default; OR a built-in name OR a path to a custom .tflite + model: hey_hermes # bundled default, or an absolute path to a custom .tflite porcupine: keyword: jarvis # built-in keyword OR path to a custom .ppn ``` -`sensitivity`, `phrase`, and `start_new_session` apply to both engines. The -`openwakeword` and `porcupine` blocks select the actual detection model. +`sensitivity` and `start_new_session` apply to all three engines. For sherpa, +`phrase` selects the detection phrase. For openWakeWord and Porcupine, `phrase` +is a display label; their model or keyword selects the detection phrase. `input_device` is passed directly to the wake listener's PortAudio (`sounddevice`) stream. Use either a numeric device index or an unambiguous @@ -164,12 +170,11 @@ The `sherpa` and `porcupine` engines decode the whole phrase internally, so they don't have the single-frame-spike problem and ignore `confirmation_frames` (but they still honor `sensitivity`). -The `openwakeword` engine is [pyopen-wakeword](https://github.com/rhasspy/pyopen-wakeword) -(rhasspy's maintained fork of openWakeWord): it runs TFLite via a library -bundled in its wheel and ships the same shared feature models openWakeWord -downloaded at runtime (byte-identical, verified by hash), so the shipped -`hey_hermes.tflite` scores exactly as before — with no runtime download and no -backend to pick. There is no `inference_framework` setting anymore. +The `openwakeword` provider name now selects +[pyopen-wakeword](https://github.com/rhasspy/pyopen-wakeword). Its wheel includes +the TFLite library and shared feature models. Hermes uses the bundled +`hey_hermes.tflite` model by default. ONNX wake models and the +`inference_framework` setting are no longer supported. ### Surfaces (CLI, TUI, GUI) @@ -237,9 +242,9 @@ degrade accuracy — tune per-profile `sensitivity` if needed. ### Option B — openWakeWord (free, trained model) -Name a built-in model (`hey_jarvis`, `alexa`, `hey_mycroft`, …), or train a -custom model (≈75–90 min on a free/Colab GPU) for maximum robustness, drop -the `.onnx` file somewhere, and reference it: +For a different phrase, obtain or train a compatible openWakeWord TFLite model. +Set its absolute path in the configuration. Hermes does not resolve built-in +names such as `hey_jarvis` or download their models for you. ```yaml wake_word: @@ -247,7 +252,7 @@ wake_word: provider: openwakeword phrase: "computer" openwakeword: - model: ~/.hermes/wakewords/computer.onnx # or a built-in name like hey_jarvis + model: /absolute/path/to/computer.tflite ``` Training references: diff --git a/website/docs/user-guide/local-models.md b/website/docs/user-guide/local-models.md index 8919bd875e..58bbdf422a 100644 --- a/website/docs/user-guide/local-models.md +++ b/website/docs/user-guide/local-models.md @@ -14,6 +14,17 @@ sizes, GPU layers, or quantization. You pick a model; Hermes does the rest. Nothing leaves your computer: no account, no API key, and no network access after a model is downloaded. +## Desktop availability and downloads + +The desktop Local Models interface is enabled for canary builds. Other desktop +builds require the `--local` launch flag. A runtime can already be bundled; +its absence triggers the managed-tool install path, not an arbitrary latest +llama.cpp download. + +Model downloads support pause and resume. Partial downloads use PM's writable +`cache/partials` area, outside a signed app package. Model weights and managed +engine binaries are separate downloads. + ## Getting started 1. Open **Settings → Providers → Local Models** (or choose **Run models diff --git a/website/docs/user-guide/messaging/matrix.md b/website/docs/user-guide/messaging/matrix.md index 39124393ed..351838508e 100644 --- a/website/docs/user-guide/messaging/matrix.md +++ b/website/docs/user-guide/messaging/matrix.md @@ -722,7 +722,10 @@ history, so other clients trust it immediately. ## Proxy Mode (E2EE on macOS) -Matrix E2EE requires `libolm`, which doesn't compile on macOS ARM64 (Apple Silicon). The `hermes-agent[matrix]` extra is gated to Linux only. If you're on macOS, proxy mode lets you run E2EE in a Docker container on a Linux VM while the actual agent runs natively on macOS with full access to your local files, memory, and skills. +The `matrix` extra is gated to Linux. On macOS or Windows, run the Matrix +adapter and encryption dependencies in a Linux container and forward requests +to the native agent. The example below uses a macOS host; the same separation +applies to Windows with the corresponding host address and authentication. ### How It Works diff --git a/website/docs/user-guide/messaging/photon.md b/website/docs/user-guide/messaging/photon.md index 39184922c8..9d70a0aae1 100644 --- a/website/docs/user-guide/messaging/photon.md +++ b/website/docs/user-guide/messaging/photon.md @@ -42,7 +42,8 @@ automatically. ## Prerequisites - A Photon account — sign up at [app.photon.codes][app] -- **Node.js 18.17 or newer** on PATH (`node --version`) +- Node.js: Hermes uses its managed Node when available. + `hermes pm install node` provisions the pin; the adapter can fall back to PATH. - A phone number that can receive iMessage (used to bind your account) That's it — there is no public URL or tunnel to set up. diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index 16931353c0..30a483e921 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -768,6 +768,23 @@ TERMINAL_SSH_KEY=~/.ssh/hermes_agent_key The SSH connection details live in `.env` (not `config.yaml`) so they aren't checked in or shared along with profile exports. This keeps the gateway's messaging connections separate from the agent's command execution. +## TLS certificate trust + +Hermes initializes the platform verifier through `truststore`. Windows uses +its certificate store, macOS uses its system trust services, and Linux uses +the OpenSSL system trust paths. If initialization fails, Hermes logs the +failure and falls back to OpenSSL defaults. + +For a corporate TLS proxy, install its root through your organization's +operating-system trust procedure. Hermes' provider resolver no longer selects +trust through `HERMES_CA_BUNDLE` or the old CA-environment-variable ladder. +Sandboxed subprocesses can have their own separate CA configuration. + +A custom provider can declare `ssl_ca_cert` for its endpoint. A missing file +produces a warning and falls back to platform trust. `ssl_verify: false` +disables certificate verification and is unsafe for untrusted networks. +Do not use it as a permanent fix for a missing corporate root. + ## Supply-chain advisory checking Hermes ships with a built-in advisory scanner that flags Python packages in the active venv that match a curated catalog of known-compromised versions (supply-chain worms like the May 2026 `mistralai 2.4.6` poisoning). Implementation lives in `hermes_cli/security_advisories.py`. @@ -790,35 +807,47 @@ The check itself is stdlib-only and runs from one `importlib.metadata.version()` ### Lazy install of optional dependencies -Many features (Mistral TTS, ElevenLabs, Honcho memory, Bedrock, Slack, Matrix, …) depend on Python packages that not every user needs. Hermes installs these **on demand** at first use rather than eagerly under `hermes-agent[all]`. The implementation is the pm package system: each feature is a pyproject extra, and enabling one syncs the venv against the committed `uv.lock`. +PM manages optional Python features as extras from `pyproject.toml`. +Source installers select the `all` extra. Native bundles include all extras +supported by their target. These are different feature sets. -The trade-off this fixes: +When a backend requests an unavailable extra, `pm.ensure_import("extra-name")` +uses the same dependency transaction as plugin admission: -- **Fragility.** When one extra's transitive dependency becomes unavailable on PyPI (quarantined for malware, yanked, broken upload), the entire `[all]` resolve would fail and fresh installs would silently fall back to a stripped tier — losing 10+ unrelated extras at once. Lazy install isolates each backend so one poisoned dep can't break unrelated features. -- **Bloat.** A user who only ever talks to one provider no longer pulls hundreds of packages they will never import. +1. PM checks platform support and `security.allow_lazy_installs`. +2. PM prepares a complete environment with the existing extras and enabled plugin requirements. +3. Without plugin members, it uses the committed lock unchanged. With members, it resolves from the previous selection before a frozen workspace sync. +4. It validates the candidate before publishing its selection. A failed candidate leaves the previous environment selected. +5. If the current process uses the previous environment, PM reports that Hermes must restart. It does not replace imported libraries in place. -How it works: +Shipped source, locks, and signed payloads remain unchanged. Additional tools +and Python environments use writable storage outside the base artifact. +Plugin dependencies share the complete environment; they are not isolated +Python sandboxes. Compatible transitive dependencies can change, but declared +constraints and exact pins remain binding. -1. A backend module calls `ensure("feature.name")` at the top of its first-import path. -2. If the deps are missing, `ensure` checks `security.allow_lazy_installs` in `config.yaml` (default `true`) and runs a venv-scoped `pip install` for the allowlisted specs. -3. If the install fails or the user has disabled lazy installs, the call raises `FeatureUnavailable` with the actual pip stderr and a pointer at `hermes tools`. - -Security guarantees enforced by pm: - -| Guarantee | What it means | +| Control | Behavior | |---|---| -| Venv-scoped only | Installs target `sys.executable` in the active venv — never the system Python | -| PyPI by name only | Specs accept `"package>=1.0,<2"` syntax. No `--index-url`, `git+https://`, or file: paths — a malicious `config.yaml` cannot redirect the install | -| Allowlist | Only specs that appear in the in-tree `LAZY_DEPS` map can be installed via this path. A typo in a feature name does NOT get install-anything semantics | -| Opt-out | Set `security.allow_lazy_installs: false` to disable runtime installs entirely. Useful for restricted networks or strict security postures | -| No silent retries | Failures surface as `FeatureUnavailable` — no caching of bad state, no retry storms | +| Declared extras | The helper accepts project extra names, not arbitrary pip commands. The removed `LAZY_DEPS` feature-name registry is not used. | +| Verified tools | Managed tool archives have versions and SHA-256 hashes in `pm/lock.json`. | +| Atomic selection | Preparation and validation precede publication of the new runtime selection. | +| Failure reporting | Failures raise `pm.InstallError`. PM sync receipts include failed steps and policy refusals. | +| No automatic plugin removal | A failed dependency union does not silently disable or delete installed plugins. | -To disable runtime installs: +To disable on-demand installations, run: -```yaml -# ~/.hermes/config.yaml -security: - allow_lazy_installs: false +```bash +hermes config set security.allow_lazy_installs false ``` -When disabled, backends that need optional deps will tell the user to run the install manually (`pip install …`) or pick a different backend via `hermes tools`. +Already installed dependencies remain usable. Explicit PM install commands +are separate from on-demand installation. A bundle's frozen feature list, +when present with lazy installs disabled, restricts requested Python extra +names. This setting is not a blanket ban on explicit plugin admission or +manual package-manager commands. The official Docker image also disables +on-demand installs through its internal environment policy. + +For missing dependencies, use `hermes tools` and `hermes doctor` to identify +the requirement. Do not run pip against a signed payload or the system Python. +See [Package management](../reference/package-management.md) for installation +ownership, diagnostics, and command boundaries. diff --git a/website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md b/website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md index 491afaf9d7..5ef5b30d96 100644 --- a/website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md +++ b/website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md @@ -125,7 +125,7 @@ scripts/run_tests.sh tests/path/to/test_file.py::test_name --trace scripts/run_tests.sh tests/path/to/test_file.py --showlocals --tb=long ``` -Note: `scripts/run_tests.sh` runs pytest under xdist workers, so interactive pdb does NOT work under the wrapper. Run pytest directly for `--pdb`: +Note: `scripts/run_tests.sh` captures each test file in a separate subprocess through `scripts/run_tests_parallel.py`. Interactive pdb needs a terminal, so use direct pytest only for the interactive debugger: ```bash source .venv/bin/activate diff --git a/website/docs/user-guide/switching-to-source.md b/website/docs/user-guide/switching-to-source.md index f1a5d31d2f..828c0ea36d 100644 --- a/website/docs/user-guide/switching-to-source.md +++ b/website/docs/user-guide/switching-to-source.md @@ -1,117 +1,168 @@ -# Switching to a Source Install - -You started with the desktop app, the installer script, Docker, or Nix — -and now you want to run from a source checkout (to develop on Hermes, use -a branch, or escape a packaged-update issue). This page is that move, -without losing your sessions, memory, skills, or configuration. - -**The one invariant: your data lives in `HERMES_HOME` (default -`~/.hermes`), never inside the application.** A source install is just a -new code checkout pointing at the same home. Nothing is copied; nothing -is lost. Switching back later is the same procedure in reverse. - +--- +title: "Switching to a Source Install" +description: "Run a separate source checkout without overwriting packaged files or losing track of user data" --- -## Step 1 — back up (one command) +# Switching to a source install + +A source checkout and a packaged app are separate installations. A bundled +app continues to use its own payload; it does not adopt a nearby checkout. +Use a source-built desktop when you want the GUI to run modified code. + +User data normally lives outside the application: + +| Host | Default data location | +|---|---| +| Linux, macOS, WSL, Termux | `~/.hermes/` | +| Native Windows | `%LOCALAPPDATA%\hermes\` | +| Official Docker container | `/opt/data/`, mapped to host storage | + +`HERMES_HOME` and the selected profile can override these defaults. Record the +actual source and destination homes before changing installations. + +## 1. Back up and stop the old runtime + +From the existing installation, run: ```bash hermes backup +hermes gateway status ``` -This snapshots your home (config, sessions, memory, skills, cron jobs). -Not strictly required — the switch doesn't delete anything — but it's -the cheap insurance before any install change. +Keep the backup outside any directory you plan to remove. Backups can contain +credentials, so protect them accordingly. -## Step 2 — clone the checkout +Quit the desktop and stop gateways/services that you own before the handoff. +Desktop quit and `hermes gateway stop` are different operations: quitting the +app does not necessarily stop an independently managed messaging gateway. + +The per-profile gateway lock prevents duplicate gateways. Session locks and +SQLite concurrency are separate concerns; starting a second process does not +itself switch SQLite journal mode. For the handoff, avoid mixed code versions +writing the same home while either version performs migrations. + +## 2. Clone an independent checkout ```bash git clone https://github.com/NousResearch/hermes-agent.git cd hermes-agent ``` -For your own development: fork first, clone your fork, and add the -upstream remote: +For development, clone your fork instead and add the canonical repository as +`upstream`. Select the branch or commit before preparing dependencies. +Do not clone into a signed app package or overwrite the packaged runtime. + +## 3. Prepare the source runtime + +Read the [developer workflow](/reference/package-management#developer-workflow) +for native build prerequisites and current bootstrap limitations. Select your +intended `HERMES_HOME` before preparation, then use the checkout's PM bootstrap: ```bash -git remote add upstream https://github.com/NousResearch/hermes-agent.git +bash setup-hermes.sh +source ./activate +python hermes --version ``` -## Step 3 — create the venv and install +On native Windows, use PowerShell: + +```powershell +.\setup-hermes.ps1 +. .\activate.ps1 +python hermes --version +``` + +The bootstrap reads tool pins from `pm/lock.json` and delegates installation +to PM. Current first-party code requires Python 3.14 (`>=3.14,<3.15`). +The source default is the `all` extra, not the desktop bundle's `--all-extras`. + +Activation composes the installed tool environment. `python hermes` explicitly +runs this checkout and avoids an older `hermes` command or MSIX alias on PATH. +`deactivate` restores the shell environment when you finish. + +For test dependencies and manual environments, use the +[development setup](/developer-guide/contributing). +See [Package management](/reference/package-management) for selected Python +generations and writable tool storage. + +## 4. Select data deliberately + +For normal use on the same host, select the same `HERMES_HOME` and profile as +the previous installation. For development, a separate home is safer because +new code can migrate stored data. + +POSIX example: ```bash -uv venv -source .venv/bin/activate # Windows: .venv\Scripts\activate -uv sync --extra all +export HERMES_HOME="$HOME/hermes-source-data" +python hermes setup +python hermes ``` -This uses the committed `uv.lock` — the same dependency set the -packaged builds carry, with hashes. +PowerShell example: -## Step 4 — run from source - -```bash -hermes # or: hermes gateway / hermes --tui / python -m hermes_cli.main +```powershell +$env:HERMES_HOME = Join-Path $HOME 'hermes-source-data' +python hermes setup +python hermes ``` -The CLI resolves from your checkout (the venv's `hermes` entry point -points at the repo). Your `HERMES_HOME` is untouched — the source -install reads the same config, sessions, and memory the packaged install -did. +If you change the home after preparing PM state, run the bootstrap for that +home before relying on its selected dependencies. Do not assume that changing +the environment variable moves data or copies runtime state. -:::warning Windows App Installer / MSIX users -The desktop app's bundled payload and a source checkout are separate -installs that can coexist. If the desktop app is running, its backend -keeps its own payload — stop it (`hermes gateway stop` or quit the app) -before using the source CLI against the same home, so two writers never -share one `state.db` (the gateway uses WAL mode; a second writer flips -journal modes). -::: - -## Step 5 — switching back - -Just run the packaged command again (open the desktop app, or use the -installer script). Both installs read the same `HERMES_HOME`; the last -one to run owns the session locks. If you stop developing, delete the -checkout — your home survives it. - ---- +To build a source desktop, run `python hermes desktop` from the prepared +checkout. Opening the old packaged app still starts its packaged backend. ## Docker users -A source checkout replaces the image: run the checkout's `hermes` -directly, or build the image from the checkout -(`docker build -t hermes-agent .`). Your data volume (`/opt/data` by -default — the `HERMES_HOME` inside the container) is mounted, not -copied: point the source install's `HERMES_HOME` at the same volume and -it sees everything the container did. +`/opt/data` is a container path, not necessarily a usable host path. For a bind +mount, use the host-side directory as the source process's `HERMES_HOME`. +For a named volume or Docker Desktop VM storage, stop the old gateway first. +Then export/import a backup or copy data through a controlled mount. +Check ownership and permissions on the destination. -## Nix users +A local `docker build -t hermes-agent .` produces another image-managed install. +It does not turn the running container into a self-updating source checkout. +Recreate the container to use that image. See [Docker](/user-guide/docker). -Use the flake from the checkout (`nix run .` / `nix develop`). The Nix -store paths change per checkout; your `HERMES_HOME` does not. +## Nix and Termux users -## What moves where (reference) +A local `nix run .` still runs a Nix-owned derivation. Its package files remain +immutable and updates stay with Nix. Use `nix develop` for a development shell, +or the source procedure above where the host supports it. -| Thing | Location | Moves? | -|---|---|---| -| Sessions, memory, skills, config | `HERMES_HOME` (`~/.hermes`) | **No** — both installs read it | -| Cron jobs | `HERMES_HOME/cron` | No | -| Logs | `HERMES_HOME/logs` | No (both install kinds write the same logs) | -| Tool binaries (pm store) | machine-scoped tools dir | No — shared between installs | -| Python venv | inside the checkout | New — the source venv is the source install | -| Desktop payload | inside the app package | Untouched | +The Termux distribution is a bionic APT package. The desktop/server source +bootstrap is not its supported development or repair route. Use the +[Termux guide](/getting-started/termux) for its package and build boundaries. + +## Switch back without assuming a downgrade is safe + +Stop the source runtime, leave its activation, and open the packaged app. +Inspect which CLI command resolves before using `hermes` again: + +```bash +command -v hermes +``` + +On Windows, use `Get-Command hermes -All`. Do not replace an unrelated command +or execution alias without checking its owner. + +A newer source revision can change data formats. Returning to an older package +is not the reverse of a schema migration. Preserve current data and restore a +compatible pre-switch backup if the older package requires it. + +Deleting the source checkout does not remove the packaged app. It also does +not automatically collect every PM tool entry or Python generation. Use PM's +diagnostics and garbage collection rather than deleting the shared data root. ## Troubleshooting -**"ModuleNotFoundError" running `hermes`** — you're outside the venv, or -the venv was built from an old lock. Re-run `uv sync --extra all` inside -the activated venv. - -**Two gateways started** — the packaged install's gateway is still -running. `hermes gateway status` shows it; `hermes gateway stop` stops -it. The gateway lock (`gateway.lock`) prevents silent double-runs on -the same profile, but stop one anyway. - -**Different versions of the same skill** — skills live in `HERMES_HOME`, -not the checkout; both installs share them. Bundled skills re-sync on -first run of the newer code (the skills-sync step in boot bootstrap). +- **Wrong version:** inspect command resolution, then use `python hermes --version` + from the activated checkout. +- **Missing dependencies:** run `python -m pm.cli install` from the intended + source environment, then restart the affected Hermes process. +- **Gateway already running:** inspect `python hermes gateway status` for the + selected profile. Stop the identified owner; do not kill unrelated processes. +- **Different skills after first run:** newer code can sync bundled skills into + the data home. A source checkout is not a read-only view of that home. diff --git a/website/docs/user-guide/windows-native.md b/website/docs/user-guide/windows-native.md index 2d26106dc8..2fb1c05c41 100644 --- a/website/docs/user-guide/windows-native.md +++ b/website/docs/user-guide/windows-native.md @@ -12,7 +12,7 @@ Hermes runs natively on Windows 10 and Windows 11 — no WSL, no Cygwin, no Dock If you just want to install, the one-liner on the [landing page](/) or [Installation page](../getting-started/installation#windows-native) is all you need. Come back here when something surprises you. :::tip Want WSL instead? -If you prefer a real POSIX environment (for the dashboard's embedded terminal, `fork` semantics, Linux-style file watchers, etc.), see the **[Windows (WSL2) Guide](./windows-wsl-quickstart.md)**. Both coexist cleanly: native data lives under `%LOCALAPPDATA%\hermes`, WSL data lives under `~/.hermes`. +If you prefer a POSIX environment for `fork` semantics or Linux-style file watchers, see the **[Windows (WSL2) Guide](./windows-wsl-quickstart.md)**. Both coexist cleanly: native data lives under `%LOCALAPPDATA%\hermes`, WSL data lives under `~/.hermes`. ::: ## Quick install @@ -25,58 +25,82 @@ iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/script No admin rights required. The installer goes to `%LOCALAPPDATA%\hermes\` and adds `hermes` to your **User PATH** — open a new terminal after it finishes. -**Installer options** (requires the scriptblock form to pass parameters): +**Installer options** use a scriptblock: ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1))) -NoVenv -SkipSetup -Branch main +& ([scriptblock]::Create((irm https://hermes-agent.nousresearch.com/install.ps1))) -NonInteractive -Branch main ``` -| Parameter | Default | Purpose | -|---|---|---| -| `-Branch` | `main` | Clone a specific branch (useful for testing PRs) | -| `-Commit` | unset | Pin install to a specific commit SHA (overrides `-Branch`) | -| `-Tag` | unset | Pin install to a specific git tag (e.g. `v0.14.0`) | -| `-NoVenv` | off | Skip venv creation (advanced — you manage Python yourself) | -| `-SkipSetup` | off | Skip the post-install `hermes setup` wizard | -| `-HermesHome` | `%LOCALAPPDATA%\hermes` | Override data directory | -| `-InstallDir` | `%LOCALAPPDATA%\hermes\hermes-agent` | Override code location | - -The installer auto-retries flaky git fetches and strips BOM from any downloaded `install.ps1` payload, so a UTF-8 BOM picked up during HTTP transit no longer breaks the `[scriptblock]::Create((irm ...))` form. - -### Desktop installer (alternative) - -A thin GUI installer is also available — useful if you'd rather double-click an `.exe` than open PowerShell. Download Hermes Desktop, run the installer, and on first launch the GUI calls `install.ps1` under the hood to provision Python (via `uv`), Node, PortableGit, and the rest of the dependency bootstrap described below. After the first run, the desktop app and the PowerShell-installed `hermes` CLI share the same `%LOCALAPPDATA%\hermes\hermes-agent` install and `%LOCALAPPDATA%\hermes` data directory — switch between the GUI and the CLI freely. - -Use the desktop installer when you want a familiar Windows install experience or you're handing Hermes to a non-developer; use the PowerShell one-liner when you're already in a terminal. - -### Dependency bootstrap (`dep_ensure`) - -On first launch (and on demand when a missing tool is detected), Hermes runs a small Python bootstrapper — `hermes_cli/dep_ensure.py` — that checks for and lazily installs the non-Python dependencies it needs. On Windows, the relevant ones are: - -| Dependency | Why Hermes needs it | +| Parameter | Purpose | |---|---| -| **PortableGit** | Provides `bash.exe` for the terminal tool and `git` for in-session clones. Provisioned at install time, not by `dep_ensure`. | -| **Node.js 26** | Required for the browser tool (`agent-browser`), the TUI's web bridge, and the WhatsApp bridge. | -| **ffmpeg** | Audio format conversion for TTS / voice messages. | -| **ripgrep** | Fast file search — falls back to `grep` if unavailable. | -| **npm packages** | `agent-browser`, Playwright Chromium, and any per-toolset Node deps are installed once at first browser-tool use. | +| `-Branch NAME` | Select the source branch; default `main`. | +| `-Commit SHA` | Select a commit after the branch checkout. | +| `-HermesHome PATH` | Select the data directory. | +| `-InstallDir PATH` | Select the source checkout directory. | +| `-NonInteractive` | Skip setup and gateway stages that need input. | +| `-IncludeDesktop` | Build the desktop app and create shortcuts. | +| `-ShowResolvedPaths` | Print resolved paths as JSON without installing. | +| `-Manifest` / `-ProtocolVersion` | Inspect the stage protocol used by the bootstrap GUI. | +| `-Stage NAME -Json` | Run one stage and emit its result frame. | -Each dep has a `shutil.which(...)`-style check; if a binary is missing and the run is interactive, `dep_ensure` offers to install it (deferring to `scripts\install.ps1 -ensure ` for the actual install logic). Non-interactive runs (gateway, cron, headless desktop launches) skip the prompt and surface a clear `this feature needs ` error instead. +The current script does not accept `-NoVenv`, `-SkipSetup`, or `-Tag`. +To diagnose an unexpected short Windows path, use `-ShowResolvedPaths` first. -## What the installer actually does +### MSIX / App Installer and Microsoft Store -Top-to-bottom, in order: +The bundled desktop is separate from the source script. Its MSIX package +requires **Windows 11 22H2 or later**. Windows 10 source-script support does +not mean the MSIX package supports Windows 10. -1. **Bootstraps `uv`** — Astral's fast Python manager. Installed to `%USERPROFILE%\.local\bin`. -2. **Installs Python 3.14** via `uv`. No existing Python needed. -3. **Installs Node.js 26** (winget if available, else a portable Node tarball unpacked under `%LOCALAPPDATA%\hermes\node`). Used for the browser tool and the WhatsApp bridge. -4. **Installs portable Git** — if `git` is already on PATH the installer uses it; otherwise it downloads a trimmed, self-contained **PortableGit** (~45 MB, from the official `git-for-windows` release) to `%LOCALAPPDATA%\hermes\git`. No admin, no Windows installer registry, no interference with anything else on the box. -5. **Clones the repo** to `%LOCALAPPDATA%\hermes\hermes-agent` and creates a virtualenv inside it. -6. **Tiered `uv pip install`** — tries `.[all]` first, falls back to progressively smaller sets (`[messaging,dashboard,ext]` → `[messaging]` → `.`) if a `git+https` dep flakes on rate-limited GitHub. Prevents "single flake drops you to a bare install" failure mode. -7. **Auto-installs messaging SDKs** keyed off `.env` — if `TELEGRAM_BOT_TOKEN` / `DISCORD_BOT_TOKEN` / `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` / `WHATSAPP_ENABLED` are present, runs `python -m ensurepip --upgrade` and targeted `pip install` calls so each platform's SDK is actually importable. -8. **Sets `HERMES_GIT_BASH_PATH`** to the resolved `bash.exe` so Hermes finds it deterministically in fresh shells. -9. **Adds `%LOCALAPPDATA%\hermes\bin` to User PATH and sets `HERMES_HOME=%LOCALAPPDATA%\hermes`** — exposes the `hermes` command (and points it at your data dir) after you open a new terminal. Only the `hermes.exe` / `hermes-acp.exe` launchers are copied into this `bin` directory; the full `venv\Scripts` is deliberately **not** placed on PATH so Hermes never shadows your own `python` command. -10. **Runs `hermes setup`** — the normal first-run wizard (model, provider, toolsets). Skip with `-SkipSetup`. +Open the downloaded `.appinstaller` file with Windows App Installer. It installs +a signed universal bundle and records the update source. The package includes +Python, Node, supported dependencies, and prebuilt interfaces. It does not clone +a checkout or build the base runtime on first launch. + +The MSIX execution aliases expose `hermes`, `hermes-agent`, and `hermes-acp`. +If another installation shadows an alias, inspect `Get-Command hermes -All`. +Windows Settings → Apps → Advanced app settings → App execution aliases +controls the aliases. + +Sideload updates use the app's Update control and Windows App Installer. +Hermes downloads a local descriptor before teardown and registers automatic +relaunch. It does not require the `ms-appinstaller:` URL protocol. An unknown +update-check result is not a claim that the package is current. + +The Microsoft Store variant uses its Partner Center package identity and Store +updates. It does not use the sideload feed. `hermes update` inside either +bundled runtime does not run Git against package files. + +`Hermes-Setup.exe` is a different, bootstrap installer. It provisions a source +checkout through the scripts. Do not confuse it with the self-contained MSIX +package. See [Updating & Uninstalling](../getting-started/updating.md). + +### Dependency bootstrap + +PM owns managed tools. `hermes_cli/dep_ensure.py` maps feature requirements to +PM packages instead of invoking `install.ps1 -Ensure`. Already installed tools +come from PM facts; missing optional tools follow the lazy-install policy. + +```powershell +hermes pm doctor +hermes pm install +``` + +## What the source installer does + +1. Locate Git, or stage the verified Git for Windows pin when Git is absent. +2. Clone the selected repository branch and apply an optional commit pin. +3. Bootstrap uv and create the initial Python environment. +4. Run PM to provision Python 3.14, required tools, and the `all` Python extra. +5. Mint CLI launchers in the data home's `bin` directory and add it to User PATH. +6. Prepare configuration and invoke the interactive setup/gateway stages unless skipped. +7. If requested, build the desktop and create Start Menu/Desktop shortcuts. +8. Write the bootstrap-completion marker. + +The runtime launcher executes PM's store Python and selects the dependency +environment before imports. PM can publish a new writable environment without +replacing libraries already loaded by a running process. There is no tiered +pip fallback to silently reduce the installed feature set. :::tip Skip provider hunting on Windows On Windows, per-tool API key setup (Firecrawl, FAL, Browser Use, OpenAI TTS) is the highest-friction part of getting a useful agent. A [Nous Portal](/user-guide/features/tool-gateway) subscription covers the model **and** all of those tools through one OAuth login. After the installer finishes, run `hermes setup --portal` to wire everything up. @@ -84,7 +108,8 @@ On Windows, per-tool API key setup (Firecrawl, FAL, Browser Use, OpenAI TTS) is ## Feature matrix -Everything except the dashboard's embedded terminal pane runs natively on Windows. +Windows support is feature- and architecture-specific. The base interfaces run +natively, but some optional SDKs are excluded from particular targets. | Feature | Native Windows | WSL2 | |---|---|---| @@ -96,26 +121,40 @@ Everything except the dashboard's embedded terminal pane runs natively on Window | MCP servers (stdio and HTTP) | ✓ | ✓ | | Local Ollama / LM Studio / llama-server | ✓ | ✓ (via WSL networking) | | Web dashboard (sessions, jobs, metrics, config) | ✓ | ✓ | -| Dashboard `/chat` embedded terminal pane | ✗ (needs POSIX PTY) | ✓ | +| Dashboard `/chat` embedded terminal pane | ConPTY through `pywinpty` | POSIX PTY | | Auto-start at login | ✓ (schtasks) | ✓ (systemd) | -The dashboard's `/chat` tab embeds a real terminal via a POSIX PTY (`ptyprocess`). Native Windows has no equivalent primitive; Python's `pywinpty` / Windows ConPTY would work but is a separate implementation — treat as future work. **The rest of the dashboard works natively** — only that one tab shows a "use WSL2 for this" banner. +The dashboard uses its `pywinpty`/ConPTY bridge on Windows and `ptyprocess` +on POSIX. A missing or broken native dependency can make the terminal +unavailable; WSL is an alternative, not a requirement of the current design. + +### Optional dependency limits + +- Matrix's native encrypted adapter is Linux-only; use a supported proxy route + or a Linux backend on Windows. +- Native Windows ARM64 excludes the `mem0` and `google-chat` SDK extras, and + the openWakeWord and sherpa wake engines. +- Local Faster-Whisper STT is excluded on native Windows ARM64. Use a cloud + or command-based STT provider. Porcupine remains a wake-engine alternative. + +The platform markers in `pyproject.toml` define the packaged dependency set. +A general gateway or voice feature claim does not override those markers. ## How Hermes runs shell commands on Windows Hermes's terminal tool runs commands through **Git Bash**, same strategy Claude Code uses. This sidesteps the POSIX-vs-Windows gap without rewriting every tool. -Resolution order for `bash.exe`: +`pm.shell()` owns Bash resolution. It first checks the Git package recorded in +PM facts, then the provisioned `PATH`. If a PATH candidate belongs to a WindowsApps +package, the resolver prefers a conventional Git for Windows installation when available. -1. `HERMES_GIT_BASH_PATH` environment variable if set. -2. `%LOCALAPPDATA%\hermes\git\usr\bin\bash.exe` (installer-managed PortableGit). -3. `%LOCALAPPDATA%\hermes\git\bin\bash.exe` (older Git-for-Windows layout). -4. System Git-for-Windows install (`%ProgramFiles%\Git\bin\bash.exe`, etc.). -5. MSYS2, Cygwin, or any `bash.exe` on PATH as a last resort. +Packaged tools are not general-purpose host installations. An external Python +process can fail to start a WindowsApps payload executable with `WinError 5`. +Use the package's own launcher, or use conventional tools for a source checkout. +Do not disable Windows security controls to work around that boundary. -The installer sets `HERMES_GIT_BASH_PATH` explicitly so fresh PowerShell sessions don't have to re-discover. Override it if you want Hermes to use a specific bash — for example, your system Git Bash or a WSL-hosted bash via a symlink. - -**Pitfall:** MinGit's layout is different from the full Git-for-Windows installer — bash lives under `usr\bin\bash.exe`, not `bin\bash.exe`. Hermes checks both. If you're manually unpacking a MinGit zip, make sure you pick the **non-busybox** variant (`MinGit-*-64-bit.zip`, not `MinGit-*-busybox*.zip`) — busybox builds ship `ash` instead of `bash` and most coreutils are missing. +The current installer does not set `HERMES_GIT_BASH_PATH`. MinGit is not a +replacement for Git for Windows with Bash. ## UTF-8 console on Windows @@ -202,23 +241,27 @@ Services require admin rights to install and tie the gateway's lifecycle to mach | Path | Contents | |---|---| -| `%LOCALAPPDATA%\hermes\hermes-agent\` | Git checkout + venv. Safe to `Remove-Item -Recurse` and reinstall. | -| `%LOCALAPPDATA%\hermes\git\` | PortableGit (only if the installer provisioned it). | -| `%LOCALAPPDATA%\hermes\node\` | Portable Node.js (only if the installer provisioned it). | -| `%LOCALAPPDATA%\hermes\bin\` | The `hermes` / `hermes-acp` launchers and Hermes's managed `uv.exe` (the Python manager it uses for updates). | -| `%LOCALAPPDATA%\hermes\` (root) | Your config, auth, skills, sessions, logs (`config.yaml`, `.env`, `skills\`, `sessions\`, `logs\`, …). **Survives reinstalls.** | +| `%LOCALAPPDATA%\hermes\hermes-agent\` | Source checkout for the script installation; absent from an MSIX-only install. | +| `%LOCALAPPDATA%\hermes\tools\` | Writable managed-tool store. MSIX base tools remain inside the package. | +| `%LOCALAPPDATA%\hermes\installs\` | Per-install runtime selection, journals, and Python generations. | +| `%LOCALAPPDATA%\hermes\bin\` | Source-install CLI launchers. MSIX instead provides execution aliases. | +| `%LOCALAPPDATA%\hermes\` | User configuration, credentials, sessions, plugins, skills, and logs. | -On native Windows the installer sets `HERMES_HOME=%LOCALAPPDATA%\hermes`, so your data and the disposable install live under the **same** `%LOCALAPPDATA%\hermes` root: the install/runtime is the `hermes-agent\`, `git\`, `node\`, and `bin\` subdirectories, while your data files sit directly in `%LOCALAPPDATA%\hermes`. Reinstalling only replaces the `hermes-agent\` checkout, so your data survives — but because the two share a root, **don't** `Remove-Item -Recurse %LOCALAPPDATA%\hermes` if you want to keep your data; delete the `hermes-agent\` subdirectory instead. Your data directory is identical in shape to a Linux `~/.hermes`, so you can mirror it between machines. - -**Override `HERMES_HOME`:** set the environment variable to point at a different data dir (e.g. `%USERPROFILE%\.hermes` to match a Linux/WSL layout). Works the same as on Linux. +These are default paths. `HERMES_HOME` and installer path arguments can change +them. A full deletion of `%LOCALAPPDATA%\hermes` also deletes user data and +can affect other installations that share it. Use the uninstall command or +Windows package removal instead of deleting that root to repair an app. ## Browser tool -The browser tool uses `agent-browser` (a Node helper) to drive Chromium. On Windows: +Browser setup depends on the selected backend. PM supplies the pinned +`agent-browser` and Chromium packages for the built-in backend. Browser Use +has its own managed CLI installation through `hermes tools`. A self-contained +MSIX includes supported browser tools in its payload. -- The installer puts `agent-browser` on PATH via npm. -- `shutil.which("agent-browser", path=...)` picks up the `.cmd` shim automatically — `CreateProcessW` can't execute an extensionless shebang, so Hermes always resolves to the `.CMD` wrapper. Don't manually invoke the shebang script; always go through the `.cmd`. -- Playwright Chromium is auto-installed on first run (`npx playwright install chromium`). If installation fails, `hermes doctor` surfaces it with a fix-it hint. +On Windows ARM64, the pinned Chromium and `agent-browser` binaries can use +Windows' x64 emulation. This differs from the native ARM64 Python runtime. +See [Browser automation](./features/browser.md) for backend selection. ## Running Hermes on Windows — practical notes @@ -250,7 +293,6 @@ These only affect native Windows installs: | Variable | Effect | |---|---| -| `HERMES_GIT_BASH_PATH` | Override bash.exe discovery. Point at any bash — full Git-for-Windows, WSL bash via symlink, MSYS2, Cygwin. The installer sets this automatically. | | `HERMES_DISABLE_WINDOWS_UTF8` | Set to `1` to disable the UTF-8 stdio shim and fall back to the locale code page. Useful for bisecting an encoding bug. | | `EDITOR` / `VISUAL` | Your editor for `/edit` and `Ctrl-X Ctrl-E`. Hermes defaults to `notepad` if both are unset. | @@ -262,16 +304,18 @@ From PowerShell: hermes uninstall ``` -That's the clean path — removes the schtasks entry, Startup folder shortcut, `hermes.cmd` shim, deletes `%LOCALAPPDATA%\hermes\hermes-agent\`, and trims the User PATH. It leaves the rest of `%LOCALAPPDATA%\hermes\` alone (your config, auth, skills, sessions, logs) in case you're reinstalling. +For source installs, the uninstaller removes owned launchers, service entries, +and application files. Review `hermes uninstall --dry-run` before removal. +`--full` also removes data; `--data` removes data without removing packaged code. +For MSIX or Store installations, remove the app through Windows Settings → +Apps → Installed apps. The CLI refuses to delete package-owned code. -To nuke everything: - -```powershell -hermes uninstall -Remove-Item -Recurse -Force "$env:LOCALAPPDATA\hermes" -# Also remove a legacy CLI/WSL data dir if you ever used one: -Remove-Item -Recurse -Force "$env:USERPROFILE\.hermes" -``` +:::caution User-data deletion +Before deleting data, stop every Hermes process that uses the selected `HERMES_HOME` and make a backup. +Review `hermes uninstall --dry-run` before choosing a data-removal mode. +Do not recursively delete the default data root to repair one application or profile. +A custom `HERMES_HOME` can be elsewhere, and package removal does not remove that data. +::: The `hermes uninstall` CLI subcommand also handles the case where the schtasks entry was registered under a different task name (older installs) — it searches by install path rather than by hardcoded task name. @@ -303,10 +347,14 @@ Check `hermes gateway status` — it merges the schtasks entry, the Startup-fold You set it in the current process only; close and reopen the shell, or set it at User scope in System Properties → Environment Variables. Verify with `echo $env:EDITOR` in a new PowerShell window. **Browser tool launches but tools time out.** -Chromium is auto-installed on first run. If the install failed (rate-limited GitHub, Playwright CDN hiccup), run `hermes doctor` — it will surface the missing Chromium and print the exact `npx playwright install chromium` command to fix it. +Run `hermes doctor` and `hermes pm doctor`. Use `hermes tools` to inspect the +selected browser backend. Do not install an unrelated Playwright revision into +a signed app payload. -**`agent-browser` fails with a weird Node version error.** -The installer provisions Node 26 at `%LOCALAPPDATA%\hermes\node` but your PATH may have an older system Node 18 first. Either move Hermes's node dir earlier on PATH, or delete the system install if you don't use Node elsewhere. +**`agent-browser` reports a Node version error.** +Run `hermes pm doctor` and inspect which Hermes launcher started the process. +PM supplies the managed Node version. Do not delete an unrelated system Node +installation to repair Hermes. **Chinese / Japanese / Arabic characters show as `?` in the CLI.** The UTF-8 stdio shim didn't activate. Check that `HERMES_DISABLE_WINDOWS_UTF8` is NOT set (`Get-ChildItem env:HERMES_DISABLE_WINDOWS_UTF8`). If it's empty and you still see `?`, the console host (very old `cmd.exe`) may not support UTF-8 at all — switch to Windows Terminal. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/contributing.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/contributing.md index 23abbfd94e..f880385a84 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/contributing.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/contributing.md @@ -27,83 +27,81 @@ description: "如何为 Hermes Agent 做贡献 — 开发环境配置、代码 - 构建新的 skill?从 [创建 Skill](./creating-skills.md) 开始 - 构建新的推理提供商?从 [添加提供商](./adding-providers.md) 开始 -## 开发环境配置 +## 开发环境配置 {#development-setup} ### 前置要求 -| 要求 | 说明 | -|-------------|-------| -| **Git** | 需安装 `git-lfs` 扩展 | -| **Python 3.11–3.13** | 若未安装,uv 会自动安装 | -| **uv** | 高速 Python 包管理器([安装](https://docs.astral.sh/uv/)) | -| **Node.js 20+** | 可选 — 浏览器工具和 WhatsApp bridge 需要(与根目录 `package.json` engines 字段一致) | +项目要求 Python 3.14(`>=3.14,<3.15`)。PM 提供固定版本的解释器和工具。 +准备 Git、git-lfs 和 uv。JS 构建使用 PM 的 Node/npm,或满足相应 `package.json` engines 的版本。 -### 使用标准安装器 +### PM 开发环境 -对大多数贡献者来说,最好的开发启动方式和用户安装方式相同:运行标准安装器,然后在它克隆出的仓库里开发。安装器会创建 Hermes venv、配置 `hermes` 命令、为 `hermes update` 写入安装方式标记,并把完整 git 项目克隆到 `$HERMES_HOME/hermes-agent`(通常是 `~/.hermes/hermes-agent`)。这样你的开发环境会和 CLI、updater、lazy dependency installer、gateway、docs 默认假设的布局一致。 +[PM 开发工作流](/reference/package-management#developer-workflow) 包含首次准备、激活、日常使用、依赖更新和当前 bootstrap 限制。 +请在准备环境前选择独立的开发 `HERMES_HOME`,避免实验代码迁移生产数据。 + +成功准备后,每次在仓库根目录的新 shell 中激活已有环境。 + +Bash: ```bash -curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent" - -# 在标准安装基础上添加开发/测试 extras。 -uv pip install -e ".[all,dev]" - -# 可选:浏览器工具 / docs site dependencies。 -npm install +source ./activate +python hermes --version ``` -之后从这个 checkout 创建分支并运行测试: +PowerShell: + +```powershell +. .\activate.ps1 +python hermes --version +``` + +PowerShell 开头的点和空格用于 dot-source,不能省略。 +激活读取已安装工具和所选依赖,不下载、不创建 JS workspaces,也不设置常规 venv 提示符。 +使用 `python hermes` 明确运行当前 checkout,避免命中全局命令或 MSIX 别名。 +`deactivate` 恢复激活前的环境,不卸载依赖或停止已启动的进程。 + +### 独立开发和测试环境 {#manual-development-and-test-environment} + +托管 bootstrap 安装运行时依赖,不包含测试所需的 `dev` extra。 +先退出 PM 激活,再准备独立测试环境,保持同一个开发 `HERMES_HOME`。 +测试 runner 清除 `PYTHONPATH`,因此需要自身环境中安装了 pytest 的解释器。 +Windows 源码依赖需为目标架构初始化 C++ 编译环境。不要修改签名应用的载荷。 + +POSIX: ```bash -git checkout -b fix/description -scripts/run_tests.sh +uv venv "$HOME/.hermes/venvs/hermes-dev" --python 3.14 +export UV_PROJECT_ENVIRONMENT="$HOME/.hermes/venvs/hermes-dev" +uv sync --locked --extra all --extra dev +export HERMES_PYTHON="$UV_PROJECT_ENVIRONMENT/bin/python" +"$HERMES_PYTHON" hermes --version ``` -### 手动克隆备用路径 +PowerShell: -只有在你明确不想使用 Hermes managed install layout 时才使用这种方式(例如容器或 CI job 里的临时 clone)。如果这样安装,请确保运行的是这个 venv 里的 `hermes` entrypoint;运行系统 `python3 -m hermes_cli.main` 可能会加载无关的系统 Python 包。 +```powershell +$devEnv = Join-Path $env:LOCALAPPDATA 'hermes-dev-env' +uv venv $devEnv --python 3.14 +$env:UV_PROJECT_ENVIRONMENT = $devEnv +uv sync --locked --extra all --extra dev +$env:HERMES_PYTHON = Join-Path $devEnv 'Scripts/python.exe' +& $env:HERMES_PYTHON hermes --version +``` + +使用 `uv pip check --python` 和该环境的解释器检查依赖。 +独立测试环境不替代 PM 工具存储或应用的依赖选择。 +运行开发实例前,选择临时的 `HERMES_HOME`,再使用 `python hermes setup` 配置它。 +不要把生产凭据复制到 checkout。 + +从仓库根目录运行 `npm ci` 安装 JS workspaces。网站单独使用: ```bash -git clone https://github.com/NousResearch/hermes-agent.git -cd hermes-agent - -# 使用 Python 3.11 创建虚拟环境 -uv venv venv --python 3.11 -export VIRTUAL_ENV="$(pwd)/venv" - -# 安装所有扩展(messaging、cron、CLI 菜单、开发工具) -uv pip install -e ".[all,dev]" - -# 可选:浏览器工具 -npm install +npm ci --prefix website +npm run build:fast --prefix website ``` -### 配置开发环境 - -```bash -mkdir -p ~/.hermes/{cron,sessions,logs,memories,skills} -cp cli-config.yaml.example ~/.hermes/config.yaml -touch ~/.hermes/.env - -# 至少添加一个 LLM 提供商密钥: -echo 'OPENROUTER_API_KEY=sk-or-v1-your-key' >> ~/.hermes/.env -``` - -### 运行 - -```bash -# 标准安装器已经把 `hermes` 放到了 PATH 上。 -hermes doctor -hermes chat -q "Hello" -``` - -如果你使用了手动克隆备用路径,可以在 checkout 中运行 `./hermes`,或显式把这个 clone 的 venv 链接到 PATH: - -```bash -mkdir -p ~/.local/bin -ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes -``` +图标从 `assets/nous-girl-*.svg` 和 `assets/backgrounds/` 生成。 +`node scripts/generate-icons.mjs` 使用隔离的 `icon-build` 依赖组,不应将这些构建依赖加入生产包。 ### 运行测试 @@ -111,6 +109,9 @@ ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes scripts/run_tests.sh ``` +该脚本清除凭据环境、设置 UTC 和临时 `HERMES_HOME`,并使用独立子进程运行各测试文件。 +不同文件可并行,单个文件内的测试串行执行。不要绕过脚本直接运行 pytest。 + ## 代码风格 - **PEP 8**,允许合理例外(不强制限制行长度) @@ -121,12 +122,12 @@ scripts/run_tests.sh ## 跨平台兼容性 -Hermes 官方支持 **Linux、macOS、WSL2 以及原生 Windows(通过 PowerShell 安装)**。原生 Windows 使用 [Git for Windows](https://git-scm.com/download/win) 提供的 Git Bash 执行 shell 命令。部分功能依赖 POSIX 内核原语,已做条件限制:dashboard 内嵌的 PTY 终端面板(`/chat` 标签页)仅支持 WSL2。如果您主要在 Windows 上开发,推送前请运行 Windows 陷阱(footgun)lint(`scripts/check-windows-footguns.py`)。 +Hermes 支持 Linux、macOS、WSL2 和原生 Windows。Windows shell 由 PM 解析 Git Bash。Dashboard 聊天通过 pywinpty/ConPTY 支持原生 Windows,并非仅限 WSL2。平台和依赖限制见[平台支持](/getting-started/platform-support)。 贡献代码时,请遵守以下规则: - **不得添加未加保护的 `signal.SIGKILL` 引用。** Windows 上未定义该信号。请通过 `gateway.status.terminate_pid(pid, force=True)`(集中式原语,Windows 上执行 `taskkill /T /F`,POSIX 上发送 SIGKILL)路由,或使用 `getattr(signal, "SIGKILL", signal.SIGTERM)` 回退。 -- **在 `os.kill(pid, 0)` 探测时同时捕获 `OSError` 和 `ProcessLookupError`。** Windows 对已消失的 PID 抛出 `OSError`(WinError 87,"参数不正确"),而非 `ProcessLookupError`。 +- **不要在 Windows 上用 `os.kill(pid, 0)` 检查存活。** 使用 `psutil.pid_exists()`;信号调用不是安全的只读检查。 - **不得强制终端使用 POSIX 语义。** `os.setsid`、`os.killpg`、`os.getpgid`、`os.fork` 在 Windows 上均会抛出异常 — 使用 `if sys.platform != "win32":` 或 `if os.name != "nt":` 进行条件判断。 - **打开文件时显式指定 `encoding="utf-8"`。** Windows 上 Python 默认使用系统区域设置(通常为 cp1252),处理非拉丁字符时会出现乱码或崩溃。 - **使用 `pathlib.Path` / `os.path.join`,不得手动用 `/` 拼接路径。** 这对我们构造后传给子进程的字符串尤为重要,而非 OS 返回给我们的字符串。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md index 91759dbf55..b8d4e1dbad 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md @@ -23,16 +23,16 @@ Hermes 有多种不同的可插拔接口——有些使用 Python `register_*` A | **网页搜索/提取后端** | [网页搜索提供商插件](/developer-guide/web-search-provider-plugin) | | **云浏览器后端**(Browserbase 类 CDP 会话提供商) | [浏览器提供商插件](/developer-guide/browser-provider-plugin) | | **密钥管理器后端**(保险库 / 密码管理器 / 系统钥匙串) | [密钥源插件](/developer-guide/secret-source-plugin) | -| **仪表盘 OIDC/认证提供商** | [Web 仪表盘 — 自定义提供商](/user-guide/features/web-dashboard#custom-providers) — `ctx.register_dashboard_auth_provider()` | -| **TTS 后端**(任意 CLI——Piper、VoxCPM、Kokoro、声音克隆等) | [TTS 自定义命令提供商](/user-guide/features/tts#custom-command-providers)——配置驱动,无需 Python | -| **STT 后端**(自定义 whisper / ASR CLI) | [语音消息转录](/user-guide/features/tts#voice-message-transcription-stt)——将 `HERMES_LOCAL_STT_COMMAND` 设置为 shell 模板 | +| **仪表盘 OIDC/认证提供商** | [Web 仪表盘 — 自定义提供商](https://hermes-agent.nousresearch.com/docs/user-guide/features/web-dashboard#custom-providers) — `ctx.register_dashboard_auth_provider()` | +| **TTS 后端**(任意 CLI——Piper、VoxCPM、Kokoro、声音克隆等) | [TTS 自定义命令提供商](/user-guide/features/tts#自定义命令提供商)——配置驱动,无需 Python | +| **STT 后端**(自定义 whisper / ASR CLI) | [语音消息转录](/user-guide/features/tts#语音消息转录stt)——将 `HERMES_LOCAL_STT_COMMAND` 设置为 shell 模板 | | **通过 MCP 接入外部工具**(文件系统、GitHub、Linear、任意 MCP 服务器) | [MCP](/user-guide/features/mcp)——在 `config.yaml` 中声明 `mcp_servers.` | | **网关事件钩子**(在启动、会话事件、命令时触发) | [事件钩子](/user-guide/features/hooks#gateway-event-hooks)——将 `HOOK.yaml` + `handler.py` 放入 `~/.hermes/hooks//` | | **Shell 钩子**(在事件发生时运行 shell 命令) | [Shell 钩子](/user-guide/features/hooks#shell-hooks)——在 `config.yaml` 的 `hooks:` 下声明 | -| **额外技能来源**(自定义 GitHub 仓库、私有技能索引) | [技能](/user-guide/features/skills)——`hermes skills tap add ` · [发布 tap](/user-guide/features/skills#publishing-a-custom-skill-tap) | +| **额外技能来源**(自定义 GitHub 仓库、私有技能索引) | [技能](/user-guide/features/skills)——`hermes skills tap add ` · [发布 tap](/user-guide/features/skills#发布自定义-skill-tap) | | 一流的**核心**推理提供商(非插件) | [添加提供商](/developer-guide/adding-providers) | -查看完整的[可插拔接口表](/user-guide/features/plugins#pluggable-interfaces--where-to-go-for-each),获取每种扩展接口的汇总视图,包括配置驱动(TTS、STT、MCP、shell 钩子)和放入目录(网关钩子)两种方式。 +查看完整的[可插拔接口表](/user-guide/features/plugins#可插拔接口--各场景对应文档),获取每种扩展接口的汇总视图,包括配置驱动(TTS、STT、MCP、shell 钩子)和放入目录(网关钩子)两种方式。 ::: ## 你将构建什么 @@ -268,7 +268,7 @@ def register(ctx): - `ctx.register_tool()` 将你的工具放入注册表——模型立即可见 - `ctx.register_hook()` 订阅生命周期事件 - `ctx.register_cli_command()` 注册 CLI 子命令(例如 `hermes my-plugin `) -- `ctx.register_command()` 注册会话内斜杠命令(例如在 CLI / 网关聊天中输入 `/myplugin `)——详见下方[注册斜杠命令](#register-slash-commands) +- `ctx.register_command()` 注册会话内斜杠命令(例如在 CLI / 网关聊天中输入 `/myplugin `)——详见下方[注册斜杠命令](#注册斜杠命令) - `ctx.dispatch_tool(name, arguments)` ——以父代理的上下文(审批、凭证、task_id 自动连接)调用任意其他工具(内置或来自其他插件)。适用于需要直接调用 `terminal`、`read_file` 或其他工具的斜杠命令处理器,效果等同于模型直接调用。 - 如果此函数崩溃,插件将被禁用,但 Hermes 继续正常运行 @@ -455,34 +455,37 @@ requires_env: 两种格式可在同一列表中混用。已设置的变量会被静默跳过。 -### 懒加载可选 Python 依赖 +### 懒加载可选 Python 依赖 {#lazy-install-optional-python-dependencies} -如果你的插件封装了一个并非所有用户都会安装的 SDK(供应商 SDK、重型 ML 库、平台特定包),不要在模块顶部 `import` 它。在工具处理器内部使用 `tools.lazy_deps.ensure(...)` 辅助函数——Hermes 会在首次使用时安装该包,并受用户 `security.allow_lazy_installs` 配置的控制。 +对于 Hermes 已声明的项目 extra,在实际需要 SDK 的操作中使用 `pm.ensure_import`。 +可用性检查使用只读的 `pm.available`。不要从频繁调用的 `check_fn` 安装依赖。 + +以下示例请求现有的 `bedrock` extra: ```python -# tools.py -from tools.lazy_deps import ensure, FeatureUnavailable +from pm import InstallError, ensure_import def my_tool_handler(args, **kwargs): try: - ensure("my-plugin.my-backend") # key must be in LAZY_DEPS - except FeatureUnavailable as exc: + ensure_import("bedrock") + except InstallError as exc: return {"error": str(exc)} - import my_backend_sdk # safe now - ... + import boto3 + # Use the SDK here. ``` -来自 `tools/lazy_deps.py` 安全模型的两条规则: +参数是 `pyproject.toml` 中的 extra 名称,不是任意 pip 规格或插件限定键。 +旧的 `LAZY_DEPS` 注册表和 `FeatureUnavailable` 异常已移除。 +如果新环境需要重启,请返回该错误,不要向当前进程叠加另一个环境的导入路径。 +关闭 `security.allow_lazy_installs` 不影响已经可用的依赖。 -| 规则 | 原因 | -|---|---| -| 你的功能键必须出现在内置的 `LAZY_DEPS` 允许列表中 | 防止恶意配置诱使 Hermes 安装任意包——只有 Hermes 自身随附的规格才符合条件 | -| 规格仅限 PyPI 包名 | 不允许 `--index-url`、`git+https://` 或 `file:` 路径。在允许列表条目中使用 PEP 440 固定版本(`"my-sdk>=1.2,<2"`) | - -对于通过 pip 分发的第三方插件,在你自己的 `pyproject.toml` 中将可选依赖声明为 `[project.optional-dependencies]` extras,并告知用户执行 `pip install your-plugin[backend]`——该路径不经过 `lazy_deps`。懒加载安装最适合**内置**插件,因为对每次安装都强制依赖会增加 Hermes 基础安装的体积。 - -当全局设置 `security.allow_lazy_installs: false` 时,`ensure()` 会立即抛出 `FeatureUnavailable` 并附带修复提示——你的插件应捕获该异常并优雅降级(返回错误结果,而非让工具循环崩溃)。 +目录插件通过 `pyproject.toml` 的 `[project].dependencies` 声明自己的依赖。 +`plugin.yaml` 中的旧式 `pip_dependencies` 和 `python_dependencies` 列表也会加入 PM 工作区。 +PM 在启用插件前统一准备核心依赖和插件依赖,不改写已发布的源码或锁文件。 +解析冲突会拒绝准入并保留原环境,不会自动禁用其他插件。 +手动 pip 安装不等于持久的 PM 依赖声明,后续环境替换不保证保留它们。 +详见[包管理](/reference/package-management)。 ### 条件工具可用性 @@ -533,7 +536,7 @@ def register(ctx): |------|-----------|-------------------|---------| | [`pre_tool_call`](/user-guide/features/hooks#pre_tool_call) | 任意工具执行前 | `tool_name: str, args: dict, task_id: str` | 忽略 | | [`post_tool_call`](/user-guide/features/hooks#post_tool_call) | 任意工具返回后 | `tool_name: str, args: dict, result: str, task_id: str, duration_ms: int` | 忽略 | -| [`pre_llm_call`](/user-guide/features/hooks#pre_llm_call) | 每轮一次,工具调用循环前 | `session_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: str` | [上下文注入](#pre_llm_call-context-injection) | +| [`pre_llm_call`](/user-guide/features/hooks#pre_llm_call) | 每轮一次,工具调用循环前 | `session_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: str` | [上下文注入](#pre_llm_call-上下文注入) | | [`post_llm_call`](/user-guide/features/hooks#post_llm_call) | 每轮一次,工具调用循环后(仅成功轮次) | `session_id: str, user_message: str, assistant_response: str, conversation_history: list, model: str, platform: str` | 忽略 | | [`on_session_start`](/user-guide/features/hooks#on_session_start) | 新会话创建(仅第一轮) | `session_id: str, model: str, platform: str` | 忽略 | | [`on_session_end`](/user-guide/features/hooks#on_session_end) | 每次 `run_conversation` 调用结束 + CLI 退出 | `session_id: str, completed: bool, interrupted: bool, model: str, platform: str` | 忽略 | @@ -675,7 +678,7 @@ def register(ctx): 注册后,用户可以运行 `hermes my-plugin status`、`hermes my-plugin config` 等命令。 -**记忆提供商插件**使用基于约定的方式:在插件的 `cli.py` 文件中添加 `register_cli(subparser)` 函数。记忆插件发现系统会自动找到它——无需调用 `ctx.register_cli_command()`。详见[记忆提供商插件指南](/developer-guide/memory-provider-plugin#adding-cli-commands)。 +**记忆提供商插件**使用基于约定的方式:在插件的 `cli.py` 文件中添加 `register_cli(subparser)` 函数。记忆插件发现系统会自动找到它——无需调用 `ctx.register_cli_command()`。详见[记忆提供商插件指南](/developer-guide/memory-provider-plugin#添加-cli-命令)。 **活跃提供商限制:** 记忆插件 CLI 命令仅在其提供商是配置中活跃的 `memory.provider` 时才会出现。如果用户尚未设置你的提供商,你的 CLI 命令不会出现在帮助输出中。 @@ -960,7 +963,7 @@ description: Custom image generation backend ## 非 Python 扩展接口 -Hermes 也接受完全不是 Python 插件的扩展。这些在[可插拔接口表](/user-guide/features/plugins#pluggable-interfaces--where-to-go-for-each)中有所展示;以下各节简要介绍每种编写方式。 +Hermes 也接受完全不是 Python 插件的扩展。这些在[可插拔接口表](/user-guide/features/plugins#可插拔接口--各场景对应文档)中有所展示;以下各节简要介绍每种编写方式。 ### MCP 服务器——注册外部工具 @@ -1033,7 +1036,7 @@ hermes skills install myorg/skills-repo/my-workflow 发布你自己的 tap 只需一个包含 `skills//SKILL.md` 目录的 GitHub 仓库——无需服务器或注册表注册。 -**完整指南:** [技能中心](/user-guide/features/skills#skills-hub) · [发布自定义 tap](/user-guide/features/skills#publishing-a-custom-skill-tap)(仓库结构、最小示例、非默认路径、信任级别)。 +**完整指南:** [技能中心](/user-guide/features/skills#skills-hub) · [发布自定义 tap](/user-guide/features/skills#发布自定义-skill-tap)(仓库结构、最小示例、非默认路径、信任级别)。 ### 通过命令模板接入 TTS / STT @@ -1052,7 +1055,7 @@ tts: 对于 STT,将 `HERMES_LOCAL_STT_COMMAND` 指向一个 shell 模板。支持的占位符:`{input_path}`、`{output_path}`、`{format}`、`{voice}`、`{model}`、`{speed}`(TTS);`{input_path}`、`{output_dir}`、`{language}`、`{model}`(STT)。任何与路径交互的 CLI 都自动成为插件。 -**完整指南:** [TTS 自定义命令提供商](/user-guide/features/tts#custom-command-providers) · [STT](/user-guide/features/tts#voice-message-transcription-stt)。 +**完整指南:** [TTS 自定义命令提供商](/user-guide/features/tts#自定义命令提供商) · [STT](/user-guide/features/tts#语音消息转录stt)。 ## 通过 pip 分发 @@ -1077,7 +1080,7 @@ pip install hermes-plugin-calculator ```nix # User's configuration.nix services.hermes-agent.extraPythonPackages = [ - (pkgs.python312Packages.buildPythonPackage { + (config.services.hermes-agent.package.python.pkgs.buildPythonPackage { pname = "my-plugin"; version = "1.0.0"; src = pkgs.fetchFromGitHub { @@ -1087,7 +1090,7 @@ services.hermes-agent.extraPythonPackages = [ hash = "sha256-..."; # nix-prefetch-url --unpack }; format = "pyproject"; - build-system = [ pkgs.python312Packages.setuptools ]; + build-system = [ config.services.hermes-agent.package.python.pkgs.setuptools ]; }) ]; ``` @@ -1104,7 +1107,7 @@ services.hermes-agent.extraPlugins = [ ]; ``` -完整文档(包括 overlay 用法和冲突检查)见 [Nix 设置指南](/getting-started/nix-setup#plugins)。 +完整文档(包括 overlay 用法和冲突检查)见 [Nix 设置指南](/getting-started/nix-setup#插件)。 ## 常见错误 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/web-search-provider-plugin.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/web-search-provider-plugin.md index 6d69c569f8..98a62751be 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/web-search-provider-plugin.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/web-search-provider-plugin.md @@ -141,7 +141,7 @@ requires_env: |---|---| | `kind: backend` | 将插件路由至后端加载路径 | | `provides_web_providers` | 该插件注册的提供商 `name` 列表——在 `register()` 运行之前,加载器即可通过此字段在 `hermes tools` 中公示插件 | -| `requires_env` | 在 `hermes plugins install` 期间进行交互式凭据提示(富格式说明参见[构建 Hermes 插件](/developer-guide/plugins#gate-on-environment-variables)) | +| `requires_env` | 在 `hermes plugins install` 期间进行交互式凭据提示(富格式说明参见[构建 Hermes 插件](/developer-guide/plugins#根据环境变量决定是否启用)) | ## ABC 参考 @@ -233,7 +233,11 @@ web: ## 懒加载可选依赖 -如果你的提供商封装了第三方 SDK(如 DDGS 封装了 `ddgs` 包),请勿在模块顶层 `import`。在 `is_available()` 或 `search()` 内部使用 `tools.lazy_deps.ensure(...)` ——Hermes 将在首次使用时安装该包,并受 `security.allow_lazy_installs` 控制。安全模型详见[构建 Hermes 插件 → 懒加载](/developer-guide/plugins#lazy-install-optional-python-dependencies)。 +对于 Hermes 已声明的 SDK extra,在 `search()` 或 `extract()` 的实际操作中调用 +`pm.ensure_import("extra-name")`。`is_available()` 必须保持只读,可使用 `pm.available`, +不能通过它安装依赖。`pm.InstallError` 可以表示依赖不可用或新环境需要重启。 +第三方目录插件在自己的 `pyproject.toml` 或 `plugin.yaml` 中声明依赖,由 PM 统一准备。 +详见[插件依赖指南](/developer-guide/plugins#lazy-install-optional-python-dependencies)。 ## 参考实现 @@ -251,7 +255,7 @@ web: my-backend-web = "my_backend_web_package" ``` -`my_backend_web_package` 必须暴露顶层 `register` 函数。完整配置说明参见通用插件指南中的[通过 pip 分发](/developer-guide/plugins#distribute-via-pip)。 +`my_backend_web_package` 必须暴露顶层 `register` 函数。完整配置说明参见通用插件指南中的[通过 pip 分发](/developer-guide/plugins#通过-pip-分发)。 ## 相关页面 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md index 4d81dd1f2b..22fd3e9c52 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md @@ -28,53 +28,41 @@ curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` -安装程序处理**一切**:`uv`、Python 3.11、Node.js 22、`ripgrep`、`ffmpeg`,**以及一个便携式 Git Bash**(PortableGit——一个自包含的 Git-for-Windows 发行版,附带 `bash.exe` 和 Hermes 用于 shell 命令的完整 POSIX 工具链;在 32 位 Windows 上安装程序会回退到 MinGit,后者缺少 bash,终端工具和 agent 浏览器功能将被禁用)。它将仓库克隆到 `%LOCALAPPDATA%\hermes\hermes-agent`,创建虚拟环境,并将 `hermes` 添加到**用户 PATH**。安装完成后请重启终端(或打开新的 PowerShell 窗口)以使 PATH 生效。 +源码安装脚本克隆仓库,再由 PM 准备 Python 3.14、Node.js、npm、ripgrep、FFmpeg 和 Python 依赖。 +缺少 Git 时,脚本下载经过 SHA-256 验证的 Git for Windows 到工具存储目录。 +它不替换系统 Git,也不再使用 MinGit 或 `hermes\node` 的旧布局。 +安装完成后,打开新终端以读取用户 PATH。 -**Git 的处理方式:** +**桌面软件包:** MSIX/App Installer 是自包含的软件包,与源码脚本不同。 +它要求 Windows 11 22H2 或更新版本,首次启动无需克隆源码或编译基础运行时。 +打开 `.appinstaller` 文件安装并登记更新源。Microsoft Store 版本由商店更新。 +`Hermes-Setup.exe` 则是下载并配置源码的引导安装程序。 -1. 如果 `git` 已在你的 PATH 中,安装程序将使用现有安装。 -2. 否则,它会下载便携式 **PortableGit**(约 50MB,来自官方 `git-for-windows` GitHub 发布页)并解压到 `%LOCALAPPDATA%\hermes\git`。无需管理员权限,完全隔离——不会干扰任何系统 Git 安装,无论其状态如何。(在 32 位 Windows 上会回退到 MinGit,因为 PortableGit 仅提供 64 位和 ARM64 资产;依赖 bash 的 Hermes 功能在 32 位主机上无法使用。) - -**为什么不使用 winget?** 早期设计通过 `winget install Git.Git` 自动安装 Git,但当系统 Git 安装处于部分损坏状态时,winget 会严重失败(而这恰恰是用户最需要安装程序正常工作的时候)。便携式 Git 方案绕过了 winget、Windows 安装程序注册表以及任何现有系统 Git。如果 Hermes 的 Git 安装本身出现问题,执行 `Remove-Item %LOCALAPPDATA%\hermes\git` 并重新运行安装程序即可——对系统无影响,无需卸载操作。 - -安装程序还会将 `HERMES_GIT_BASH_PATH` 设置为找到的 `bash.exe` 路径,以便 Hermes 在新 shell 中确定性地解析它。 - -如果你偏好 WSL2,上方的 Linux 安装程序可在其中运行;原生安装和 WSL 安装可以共存而不冲突(原生数据位于 `%LOCALAPPDATA%\hermes`,WSL 数据位于 `~/.hermes`)。 - -**桌面安装程序(替代方案):** 也提供一个轻量 GUI 安装程序——下载 Hermes Desktop,运行 `.exe`,首次启动时它会在后台调用 `install.ps1` 来配置 Python(通过 `uv`)、Node、PortableGit 及其余依赖。桌面应用和 PowerShell 安装的 CLI 共享相同的安装目录和数据目录,可以单独或同时使用。详见 [Windows(原生)指南](../user-guide/windows-native#desktop-installer-alternative)。 +macOS 的 DMG 包含应用;将它复制到 Applications 后启动。 +其自动更新使用包含已签名应用的 ZIP。详情见 [桌面指南](/user-guide/desktop)。 ### Android / Termux -Hermes 不再支持 Android 或 Termux。请参阅[平台支持页面](./platform-support.md)了解支持的平台。 +aarch64 Android 设备可通过 Termux 安装预发布的 APT 软件包。软件包包含 Python、Node.js 和 TUI,无需在手机上编译核心依赖。请按照 [Termux 指南](./termux.md)配置签名仓库,然后运行 `pkg install hermes-agent`。桌面和服务器的安装脚本不支持 Termux。 -:::note Windows 功能对等性 +### 功能与安装目录 -除基于浏览器的 dashboard 聊天终端外,其余功能均可在 Windows 上原生运行: +Windows 的 CLI、TUI、gateway 和桌面应用可原生运行。 +Dashboard 终端使用 `pywinpty`/ConPTY,不再是尚未实现的 POSIX-only 功能。 +部分可选依赖仍受架构限制,请参阅 [Windows 指南](../user-guide/windows-native.md)。 -- **CLI(`hermes chat`、`hermes setup`、`hermes gateway` 等)** — 原生,使用默认终端 -- **Gateway(Telegram、Discord、Slack 等)** — 原生,作为后台 PowerShell 进程运行 -- **Cron 调度器** — 原生 -- **浏览器工具** — 原生(通过 Node.js 使用 Chromium) -- **MCP 服务器** — 原生(stdio 和 HTTP 传输均支持) -- **Dashboard `/chat` 终端面板** — **仅限 WSL2**(使用 POSIX PTY(伪终端),原生 Windows 无等效实现)。Dashboard 的其余部分(会话、任务、指标)可原生运行——仅嵌入式 PTY 终端标签页受限。 +| 安装方式 | 代码 | 命令入口 | 默认数据目录 | +|---|---|---|---| +| POSIX 源码脚本 | `~/.hermes/hermes-agent/` | `~/.local/bin/hermes` 包装器 | `~/.hermes/` | +| Windows 源码脚本 | `%LOCALAPPDATA%\hermes\hermes-agent\` | `%LOCALAPPDATA%\hermes\bin\` | `%LOCALAPPDATA%\hermes\` | +| 桌面软件包 | 应用包内部 | 包内启动器;Windows 执行别名 | 平台默认数据目录 | +| Docker | `/opt/hermes/` | 镜像入口和 `hermes` | 挂载的 `/opt/data/` | +| Termux APT | `$PREFIX/lib/hermes-agent/` | `$PREFIX/bin/` 符号链接 | `~/.hermes/` | -如果遇到编码相关的 bug 并希望回退到旧版 cp1252 stdio 路径(用于问题定位),请在环境中设置 `HERMES_DISABLE_WINDOWS_UTF8=1`。 -::: - -### 安装程序做了什么 - -安装程序自动处理一切——所有依赖(Python、Node.js、ripgrep、ffmpeg)、仓库克隆、虚拟环境、全局 `hermes` 命令配置以及 LLM 提供商配置。完成后即可开始聊天。 - -#### 安装目录结构 - -安装程序的存放位置取决于你是以普通用户还是 root 身份安装: - -| 安装方式 | 代码位置 | `hermes` 二进制 | 数据目录 | -| --------------------------------------- | ------------------------------ | ---------------------------------------- | ------------------------------------- | -| 用户级(git 安装程序) | `~/.hermes/hermes-agent/` | `~/.local/bin/hermes`(符号链接) | `~/.hermes/` | -| Root 模式(`sudo curl … \| sudo bash`) | `/usr/local/lib/hermes-agent/` | `/usr/local/bin/hermes` | `/root/.hermes/`(或 `$HERMES_HOME`) | - -Root 模式的 **FHS 布局**(`/usr/local/lib/…`、`/usr/local/bin/hermes`)与其他系统级开发工具在 Linux 上的安装位置一致。适用于共享机器部署场景,一次系统安装可服务所有用户。每个用户的个人配置(认证、技能、会话)仍位于各自的 `~/.hermes/` 或显式指定的 `HERMES_HOME` 下。 +`HERMES_HOME` 选择数据目录。POSIX 的 `--dir` 单独选择源码目录。 +以 root 身份运行不再自动选择 `/usr/local/lib` 的 FHS 布局。 +PM 的工具和 Python 环境代际位于独立目录,详见 [包管理](/reference/package-management)。 +不要为了修复应用而删除整个数据目录。 ### 安装后 @@ -109,17 +97,9 @@ hermes setup --portal ## 前置条件 -**Git 安装程序:** 唯一的前置条件是 **Git**。安装程序自动处理其余一切: - -- **uv**(快速 Python 包管理器) -- **Python 3.11**(通过 uv,无需 sudo) -- **Node.js v22**(用于浏览器自动化和 WhatsApp 桥接) -- **ripgrep**(快速文件搜索) -- **ffmpeg**(TTS 的音频格式转换) - -:::info -你**无需**手动安装 Python、Node.js、ripgrep 或 ffmpeg。安装程序会检测缺失的依赖并自动安装。只需确保 `git` 可用(`git --version`)。 -::: +POSIX 源码脚本需要 Git、curl、tar 和 SHA-256 工具。源码构建还可能需要编译器和系统库。 +Hermes 要求 Python 3.14(`>=3.14,<3.15`),工具版本由 `pm/lock.json` 决定。 +自包含桌面软件包不要求用户自行编译基础依赖。 :::tip Nix 用户 如果你使用 Nix(在 NixOS、macOS 或 Linux 上),有专门的配置路径,包含 Nix flake、声明式 NixOS 模块和可选容器模式。请参阅 **[Nix & NixOS 配置](./nix-setup.md)** 指南。 @@ -129,49 +109,19 @@ hermes setup --portal ## 手动 / 开发者安装 -如果你想克隆仓库并从源码安装——用于贡献代码、从特定分支运行或完全控制虚拟环境——请参阅贡献指南中的[开发环境配置](../developer-guide/contributing.md#development-setup)章节。 +如果你想克隆仓库并从源码安装——用于贡献代码、从特定分支运行或完全控制虚拟环境——请参阅贡献指南中的[开发环境配置](/developer-guide/contributing)章节。 --- ## 非 Sudo / 系统服务用户安装 -支持以专用非特权用户身份运行 Hermes(例如 `hermes` systemd 服务账户,或任何没有 `sudo` 权限的用户)。安装路径中真正需要 root 权限的只有 Playwright 的 `--with-deps` 步骤,该步骤通过 `apt` 安装 Chromium 所需的共享库(`libnss3`、`libxkbcommon` 等)。安装程序会检测 sudo 是否可用,并在不可用时优雅降级——它会将 Chromium 二进制安装到服务用户自己的 Playwright 缓存中,并打印管理员需要单独运行的确切命令。 +以目标服务用户运行源码安装脚本。由管理员预先安装构建所需工具和 Chromium 的系统库。 +当前脚本不运行 Playwright 的 `--with-deps`,也不提供按发行版选择的 sudo 回退。 -**推荐的分步方式(Debian/Ubuntu):** - -1. **一次性操作,以具有 sudo 权限的管理员用户身份**,安装 Chromium 所需的系统库: - - ```bash - sudo npx playwright install-deps chromium - ``` - - (可在任意位置运行——`npx` 会自动获取 Playwright。) - -2. **以非特权服务用户身份**,运行常规安装程序。它会检测到缺少 sudo,跳过 `--with-deps`,并将 Chromium 安装到用户本地的 Playwright 缓存中: - - ```bash - curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash - ``` - - 如果想完全跳过 Playwright 步骤——例如在无头环境中运行且不需要浏览器自动化——传入 `--skip-browser`: - - ```bash - curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --skip-browser - ``` - -3. **使 `hermes` 对服务用户的 shell 可用。** 安装程序将启动器写入 `~/.local/bin/hermes`。系统服务账户通常具有不包含 `~/.local/bin` 的最小 PATH。可以将其添加到用户环境,或将启动器符号链接到系统位置: - - ```bash - # 方案 A — 添加到服务用户的 profile - echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc - - # 方案 B — 系统级符号链接(以管理员身份运行) - sudo ln -s /home/hermes/.hermes/hermes-agent/venv/bin/hermes /usr/local/bin/hermes - ``` - -4. **验证:** `hermes doctor` 现在应能正常运行。如果出现 `ModuleNotFoundError: No module named 'dotenv'`,说明你在用系统 Python 调用仓库源码中的 `hermes` 文件(`~/.hermes/hermes-agent/hermes`),而非 venv 启动器(`~/.hermes/hermes-agent/venv/bin/hermes`)——请修正步骤 3。 - -同样的方式适用于 Arch(安装程序使用 pacman,具有相同的 sudo 检测逻辑)、Fedora/RHEL 和 openSUSE——这些发行版完全不支持 `--with-deps`,因此管理员始终需要单独安装系统库。安装程序会打印相应的 `dnf`/`zypper` 命令。 +安装后,将 `$HOME/.local/bin` 加入服务用户的 PATH,并运行 `hermes doctor`。 +请使用脚本生成的包装器,不要硬编码 `venv/bin/hermes`。 +Linux 用户服务需要在注销后继续运行时,由管理员为该用户启用 lingering。 +详见 [消息 Gateway](../user-guide/messaging/index.md)。 --- diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/nix-setup.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/nix-setup.md index dc8f822838..e527870623 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/nix-setup.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/nix-setup.md @@ -22,6 +22,17 @@ Hermes Agent 提供了一个 Nix flake,支持三个层级的集成: **对于 NixOS 模块用户**,整个生命周期有所不同:配置存放在 `configuration.nix` 中,密钥通过 sops-nix/agenix 管理,服务是一个 systemd 单元,CLI 配置命令被屏蔽。管理 hermes 的方式与管理其他 NixOS 服务相同。 ::: +## PM 固定版本 + +`nix/npm-pinned.nix` 和 `nix/pm-packages.nix` 读取 PM 锁文件。 +`nix build .#pm-ripgrep` 等输出解包对应的固定归档,不等于完整应用或所有平台的运行证明。 +应用仍由 uv2nix 环境和 Nix 包装器组成。 + +`nix/pythonLock.nix` 从 `pm/lock.json` 读取 Python 主/次版本。 +uv2nix 环境、覆盖包、插件依赖和开发 shell 使用同一个解释器系列。 +如果固定的 nixpkgs 不提供该系列,求值直接失败,不回退到其他 Python。 +原生 Nix 求值和构建仍由 CI 验证。通过 Nix 更新,不要向 Nix store 执行 pip 安装。 + ## 前提条件 - **已启用 flakes 的 Nix** — 推荐使用 [Determinate Nix](https://install.determinate.systems)(默认启用 flakes) @@ -107,7 +118,7 @@ nix build 就这些。`nixos-rebuild switch` 会创建 `hermes` 用户、生成 `config.yaml`、连接密钥并启动 gateway——这是一个长期运行的服务,将 Agent 连接到消息平台(Telegram、Discord 等)并监听传入消息。 :::warning 密钥是必需的 -上面的 `environmentFiles` 行假设你已配置 [sops-nix](https://github.com/Mic92/sops-nix) 或 [agenix](https://github.com/ryantm/agenix)。该文件至少应包含一个 LLM 提供商密钥(例如 `OPENROUTER_API_KEY=sk-or-...`)。完整设置请参阅[密钥管理](#secrets-management)。如果你还没有密钥管理器,可以先使用普通文件——只需确保它不是全局可读的: +上面的 `environmentFiles` 行假设你已配置 [sops-nix](https://github.com/Mic92/sops-nix) 或 [agenix](https://github.com/ryantm/agenix)。该文件至少应包含一个 LLM 提供商密钥(例如 `OPENROUTER_API_KEY=sk-or-...`)。完整设置请参阅[密钥管理](#密钥管理)。如果你还没有密钥管理器,可以先使用普通文件——只需确保它不是全局可读的: ```bash echo "OPENROUTER_API_KEY=sk-or-your-key" | sudo install -m 0600 -o hermes /dev/stdin /var/lib/hermes/env @@ -318,7 +329,7 @@ Nix 用户最常见自定义需求的快速参考: | 使用不同的提供商端点 | `settings.model.base_url` | `"https://openrouter.ai/api/v1"` | | 添加 API 密钥 | `environmentFiles` | `[ config.sops.secrets."hermes-env".path ]` | | 给 Agent 设置个性 | `${services.hermes-agent.stateDir}/.hermes/SOUL.md` | 直接管理该文件 | -| 添加 MCP 工具服务器 | `mcpServers.` | 参见 [MCP 服务器](#mcp-servers) | +| 添加 MCP 工具服务器 | `mcpServers.` | 参见 [MCP 服务器](#mcp-服务器) | | 将主机目录挂载到容器 | `container.extraVolumes` | `[ "/data:/data:rw" ]` | | 为容器传入 GPU 访问 | `container.extraOptions` | `[ "--gpus" "all" ]` | | 使用 Podman 替代 Docker | `container.backend` | `"podman"` | @@ -628,7 +639,7 @@ services.hermes-agent.extraPlugins = [ ```nix services.hermes-agent.extraPythonPackages = [ - (pkgs.python312Packages.buildPythonPackage { + (config.services.hermes-agent.package.python.pkgs.buildPythonPackage { pname = "rtk-hermes"; version = "1.0.0"; src = pkgs.fetchFromGitHub { @@ -638,7 +649,7 @@ services.hermes-agent.extraPythonPackages = [ hash = "sha256-..."; }; format = "pyproject"; - build-system = [ pkgs.python312Packages.setuptools ]; + build-system = [ config.services.hermes-agent.package.python.pkgs.setuptools ]; }) ]; ``` @@ -674,7 +685,7 @@ services.hermes-agent = { ```nix services.hermes-agent = { extraPlugins = [ my-plugin-src ]; # 插件源码 - extraPythonPackages = [ pkgs.python312Packages.redis ]; # 其 Python 依赖 + extraPythonPackages = [ config.services.hermes-agent.package.python.pkgs.redis ]; # 其 Python 依赖 extraPackages = [ pkgs.redis ]; # 其需要的系统二进制文件 }; ``` @@ -716,17 +727,14 @@ services.hermes-agent.settings.plugins.enabled = [ ### 开发 Shell -该 flake 提供了一个包含 Python 3.12、uv、Node.js 和所有运行时工具的开发 shell: +该 flake 提供包含 `dev` extra 的可编辑 Python 环境,解释器主/次版本来自 PM 锁文件。 +`HERMES_PYTHON` 指向该解释器,不会把 Python 依赖安装到仓库内的 `.venv`。 +shell 还提供 Node.js 和运行时工具。npm hook 根据输入变更刷新 JS workspaces。 ```bash cd hermes-agent nix develop - -# Shell 提供: -# - Python 3.12 + uv(首次进入时将依赖安装到 .venv) -# - Node.js 22、ripgrep、git、openssh、ffmpeg 在 PATH 上 -# - 戳记文件优化:依赖未变更时重新进入几乎即时 - +"$HERMES_PYTHON" -c "import sys; print(sys.executable); print(sys.version)" hermes setup hermes chat ``` @@ -738,7 +746,7 @@ hermes chat ```bash cd hermes-agent direnv allow # 仅需一次 -# 后续进入几乎即时(戳记文件跳过依赖安装) +# Nix 复用已构建的 Python 环境,npm hook 检查 JS 输入。 ``` ### Flake 检查 @@ -835,7 +843,7 @@ nix build .#checks.x86_64-linux.config-roundtrip # 合并脚本保留用户 | `extraArgs` | `listOf str` | `[]` | `hermes gateway` 的额外参数 | | `extraPackages` | `listOf package` | `[]` | Agent 可用的额外包。添加到 hermes 用户的每用户 profile,终端命令、skills 和 cron 任务均可见 | | `extraPlugins` | `listOf package` | `[]` | 以符号链接方式安装到 `$HERMES_HOME/plugins/` 的目录插件包。每个包必须包含 `plugin.yaml` | -| `extraPythonPackages` | `listOf package` | `[]` | 添加到 PYTHONPATH 用于入口点插件发现的 Python 包。使用 `python312Packages` 构建 | +| `extraPythonPackages` | `listOf package` | `[]` | 添加到 PYTHONPATH 用于入口点插件发现的 Python 包。使用所选包的 `python.pkgs` 构建 | | `extraDependencyGroups` | `listOf str` | `[]` | 包含到封闭 venv 中的 pyproject.toml 可选 extras(例如 `["hindsight"]`)。由 uv 解析——无冲突 | | `restart` | `str` | `"always"` | systemd `Restart=` 策略 | | `restartSec` | `int` | `5` | systemd `RestartSec=` 值 | @@ -970,6 +978,6 @@ nix-store --query --roots $(docker exec hermes-agent readlink /data/current-pack | `hermes --version` 显示旧版本 | 容器未重启 | `systemctl restart hermes-agent` | | `/var/lib/hermes` 权限拒绝 | 状态目录为 `0750 hermes:hermes` | 使用 `docker exec` 或 `sudo -u hermes` | | `nix-collect-garbage` 删除了 hermes | GC root 缺失 | 重启服务(preStart 会重新创建 GC root) | -| `no container with name or ID "hermes-agent"`(Podman) | Podman rootful 容器对普通用户不可见 | 为 podman 添加免密 sudo(参见[容器模式](#container-mode)章节) | +| `no container with name or ID "hermes-agent"`(Podman) | Podman rootful 容器对普通用户不可见 | 为 podman 添加免密 sudo(参见[容器模式](#容器架构)章节) | | `unable to find user hermes` | 容器仍在启动中(入口点尚未创建用户) | 等待几秒后重试——CLI 会自动重试 | | 通过 `extraPackages` 添加的工具在终端中找不到 | 需要 `nixos-rebuild switch` 更新每用户 profile | 重建并重启:`nixos-rebuild switch && systemctl restart hermes-agent` | \ No newline at end of file diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md index 5bf2fcd5db..6aa8a2ea84 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md @@ -61,11 +61,11 @@ description: "与 Hermes Agent 的第一次对话——从安装到开始聊天 curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` -安装脚本会在 `~/.hermes/hermes-agent` 创建一个受管理的隔离环境(独立的 uv 托管解释器和 venv),这是唯一受支持的安装方式 —— 包括开发用途。请勿使用 `pip install hermes-agent`。 +源码脚本通过 PM 准备运行时。桌面软件包、Docker、Nix 和 Termux APT 是独立的安装方式。 +请勿使用 `pip install hermes-agent` 替代受管理的安装。 -:::tip Windows 用户 -请先安装 [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install),然后在 WSL2 终端中运行上述命令。 -::: +Windows 原生安装可在 PowerShell 中运行 `iex (irm https://hermes-agent.nousresearch.com/install.ps1)`,无需 WSL。 +aarch64 Android 设备请使用 [Termux APT 指南](./termux.md),而非上述脚本。 安装完成后,重新加载 shell: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/termux.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/termux.md new file mode 100644 index 0000000000..125d15bb4d --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/termux.md @@ -0,0 +1,99 @@ +--- +sidebar_position: 3 +title: "Android / Termux" +description: "通过签名 APT 仓库安装适用于 aarch64 Android 的 Hermes 预发布软件包" +--- + +# 在 Android 上使用 Termux + +Hermes 的 Termux 软件包适用于 aarch64(arm64-v8a)设备,目前处于预发布测试阶段。 +它包含 Python、Node.js、npm、uv、ripgrep、FFmpeg 和核心 Python 依赖。 +安装时无需在手机上编译核心依赖或组装 Python 环境。 + +请使用标准 Termux 应用和前缀 `/data/data/com.termux/files/usr`。 +其他架构或更改应用包名的 Termux 版本不受支持。 +桌面和服务器的 `install.sh` 不是此平台的安装路径。 + +## 安装 + +1. 安装仓库配置工具: + + ```bash + pkg install curl gnupg + ``` + +2. 下载公钥: + + ```bash + mkdir -p "$PREFIX/etc/apt/keyrings" + curl -fsSL \ + https://hermes-assets.nousresearch.com/releases/termux/canary/key.asc \ + -o "$PREFIX/etc/apt/keyrings/hermes-agent.asc" + ``` + +3. 检查主密钥指纹: + + ```bash + gpg --show-keys --with-fingerprint "$PREFIX/etc/apt/keyrings/hermes-agent.asc" + ``` + + 预期指纹: + + ```text + C572 B5FD D1A2 9CCF A9A9 12B6 840B 0848 E139 156D + ``` + + 如果不一致,请停止。不要禁用签名验证。 + +4. 添加 canary 仓库: + + ```bash + printf '%s\n' \ + "deb [signed-by=$PREFIX/etc/apt/keyrings/hermes-agent.asc] https://hermes-assets.nousresearch.com/releases/termux/canary hermes-canary main" \ + > "$PREFIX/etc/apt/sources.list.d/hermes-agent.list" + ``` + +5. 安装并配置: + + ```bash + pkg update + pkg install hermes-agent + hermes setup + hermes --tui + ``` + +## 数据、更新和卸载 + +软件包位于 `$PREFIX/lib/hermes-agent/`。 +`$PREFIX/bin/` 中的 `hermes`、`hermes-agent` 和 `hermes-acp` 链接使用包内运行时。 +它们不依赖 Termux 的 Python 或 Node.js 软件包。 +数据位于 `~/.hermes/`,或所选 `HERMES_HOME`。 + +```bash +pkg update +pkg upgrade hermes-agent +``` + +`hermes update` 拒绝修改 APT 所有的代码,改由包管理器更新。 +Canary 版本带有 `~canary.TIMESTAMP`,排序低于对应的正式版本。 + +卸载应用软件包: + +```bash +pkg uninstall hermes-agent +``` + +APT 移除软件包和命令链接,但保留用户配置、会话、技能和记忆。 + +## Gateway 和限制 + +使用 `hermes gateway run` 在 Termux 会话中运行 gateway。 +当前 APT 路径不提供 systemd、launchd 或 Windows 计划任务。 +Android 可能挂起或终止后台进程;唤醒锁不保证持续运行。 + +该包不包含 `nemo-relay` 导出器,也不包含 Electron 桌面应用。 +本地 Chromium、桌面控制、Docker daemon 和音频设备集成不能因核心 CLI 可运行就视为可用。 +第三方插件仍可能需要 Android 不支持的依赖。 + +故障排查时运行 `hermes doctor`,并提供 `hermes --version` 和完整错误。 +缺少包内核心库或 TUI 输出属于软件包问题,不应要求用户重新编译。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md index 756d474c43..3ba9fd307a 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md @@ -63,7 +63,7 @@ hermes [global-options] [subcommand/options] | `hermes config` | 显示、编辑、迁移和查询配置文件。 | | `hermes pairing` | 审批或撤销消息配对码。 | | `hermes skills` | 浏览、安装、发布、审计和配置 skill。 | -| `hermes bundles` | 将多个 skill 归组到单个 `/` 斜杠命令下。参见 [Skill Bundles](../user-guide/features/skills.md#skill-bundles)。 | +| `hermes bundles` | 将多个 skill 归组到单个 `/` 斜杠命令下。参见 [Skill Bundles](../user-guide/features/skills.md#skill-捆绑包)。 | | `hermes curator` | 后台 skill 维护——状态、运行、暂停、固定。参见 [Curator](../user-guide/features/curator.md)。 | | `hermes memory` | 配置外部 memory provider。当对应 provider 激活时,特定于 plugin 的子命令(如 `hermes honcho`)会自动注册。 | | `hermes acp` | 将 Hermes 作为 ACP 服务器运行,用于编辑器集成。 | @@ -209,7 +209,7 @@ hermes gateway | 子命令 | 说明 | |------------|-------------| -| `run` | 在前台运行 gateway。推荐用于 WSL 和 Docker。 | +| `run` | 在前台运行 gateway。推荐用于 WSL、Docker 和 Termux。 | | `start` | 启动已安装的 systemd/launchd 后台服务。 | | `stop` | 停止服务(或前台进程)。 | | `restart` | 重启服务。 | @@ -227,7 +227,7 @@ hermes gateway | `--no-supervise` | 在 `run` 时:在 s6-overlay Docker 镜像内部,跳过 s6 自动监管,退回到 pre-s6 前台语义——gateway 作为容器主进程运行,无自动重启。在 s6 镜像之外为空操作。等同于设置 `HERMES_GATEWAY_NO_SUPERVISE=1`。 | :::tip WSL 用户 -使用 `hermes gateway run` 而非 `hermes gateway start`——WSL 的 systemd 支持不稳定。用 tmux 包裹以保持持久运行:`tmux new -s hermes 'hermes gateway run'`。详见 [WSL FAQ](/reference/faq#wsl-gateway-keeps-disconnecting-or-hermes-gateway-start-fails)。 +使用 `hermes gateway run` 而非 `hermes gateway start`——WSL 的 systemd 支持不稳定。用 tmux 包裹以保持持久运行:`tmux new -s hermes 'hermes gateway run'`。详见 [WSL FAQ](/reference/faq#wsl网关持续断开连接或-hermes-gateway-start-失败)。 ::: ## `hermes lsp` @@ -843,7 +843,7 @@ hermes skills reset google-workspace --restore --yes hermes bundles ``` -Skill bundle 将多个 skill 归组到一个 `/` 斜杠命令下。调用 bundle 会将每个引用的 skill 加载到单个合并的用户消息中。存储位置:`~/.hermes/skill-bundles/.yaml`。YAML schema 和行为请参阅 [Skill Bundles](../user-guide/features/skills.md#skill-bundles)。 +Skill bundle 将多个 skill 归组到一个 `/` 斜杠命令下。调用 bundle 会将每个引用的 skill 加载到单个合并的用户消息中。存储位置:`~/.hermes/skill-bundles/.yaml`。YAML schema 和行为请参阅 [Skill Bundles](../user-guide/features/skills.md#skill-捆绑包)。 子命令: @@ -998,7 +998,7 @@ hermes mcp | `configure `(别名:`config`) | 切换服务器的工具选择。 | | `login ` | 强制重新认证基于 OAuth 的 MCP 服务器。 | -参见 [MCP 配置参考](./mcp-config-reference.md)、[在 Hermes 中使用 MCP](../guides/use-mcp-with-hermes.md) 和 [MCP 服务器模式](../user-guide/features/mcp.md#running-hermes-as-an-mcp-server)。 +参见 [MCP 配置参考](./mcp-config-reference.md)、[在 Hermes 中使用 MCP](../guides/use-mcp-with-hermes.md) 和 [MCP 服务器模式](../user-guide/features/mcp.md#将-hermes-作为-mcp-服务器运行)。 ## `hermes plugins` @@ -1222,6 +1222,13 @@ hermes completion zsh >> ~/.zshrc hermes completion fish > ~/.config/fish/completions/hermes.fish ``` +## `hermes pm` + +PM 管理工具和 Python 依赖,不负责替换应用发布包。 +源码开发先运行一次 setup 脚本,然后用 Bash `source ./activate` 或 PowerShell `. .\activate.ps1` 激活已有环境。 +使用 `deactivate` 恢复激活前的环境。 +详见[PM 开发工作流](/reference/package-management#developer-workflow),包括依赖更新和测试环境。 + ## `hermes update` ```bash diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md index c39533eec5..002bf364a4 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md @@ -58,7 +58,7 @@ description: "Hermes Agent 使用的所有环境变量完整参考" | `AZURE_CLIENT_SECRET` | `EnvironmentCredential` 使用的服务主体密钥 | | `AZURE_CLIENT_CERTIFICATE_PATH` | 服务主体证书(`AZURE_CLIENT_SECRET` 的替代方案) | | `AZURE_FEDERATED_TOKEN_FILE` | AKS Workload Identity / OIDC 流程的联合 token 文件路径 | -| `AZURE_AUTHORITY_HOST` | 主权云 authority 覆盖(例如 Azure Government 使用 `https://login.microsoftonline.us`)。参见 [Azure Foundry 指南](/guides/azure-foundry#sovereign-clouds-government-china) | +| `AZURE_AUTHORITY_HOST` | 主权云 authority 覆盖(例如 Azure Government 使用 `https://login.microsoftonline.us`)。参见 [Azure Foundry 指南](/guides/azure-foundry#主权云政府云中国云) | | `IDENTITY_ENDPOINT` / `MSI_ENDPOINT` | App Service、Functions 和 Container Apps 的托管标识端点;VM 通常使用 IMDS 而不设置这些变量 | | `HF_TOKEN` | Hugging Face Inference Providers token([huggingface.co/settings/tokens](https://huggingface.co/settings/tokens)) | | `HF_BASE_URL` | 覆盖 Hugging Face base URL(默认:`https://router.huggingface.co/v1`) | @@ -95,8 +95,7 @@ description: "Hermes Agent 使用的所有环境变量完整参考" | `VOICE_TOOLS_OPENAI_KEY` | OpenAI 语音转文字和文字转语音提供商的首选 OpenAI 密钥 | | `HERMES_LOCAL_STT_COMMAND` | 可选的本地语音转文字命令模板。支持 `{input_path}`、`{output_dir}`、`{language}` 和 `{model}` 占位符 | | `HERMES_LOCAL_STT_LANGUAGE` | 传递给 `HERMES_LOCAL_STT_COMMAND` 或自动检测的本地 `whisper` CLI 回退的默认语言(默认:`en`) | -| `HERMES_HOME` | 覆盖 Hermes 配置目录(默认:`~/.hermes`)。同时限定 gateway PID 文件和 systemd 服务名称,允许多个安装并发运行 | -| `HERMES_GIT_BASH_PATH` | **仅 Windows。** 覆盖终端工具的 `bash.exe` 发现路径。可指向任意 bash——完整 Git-for-Windows 安装、通过符号链接的 WSL bash、MSYS2、Cygwin。安装程序会自动将其设置为所配置的 PortableGit。参见 [Windows(原生)指南](../user-guide/windows-native.md#how-hermes-runs-shell-commands-on-windows) | +| `HERMES_HOME` | 选择配置和用户数据目录。POSIX 默认为 `~/.hermes`,Windows 默认为 `%LOCALAPPDATA%\hermes`。源码位置和 PM 工具存储单独管理,详见[包管理](/reference/package-management)。 | | `HERMES_DISABLE_WINDOWS_UTF8` | **仅 Windows。** 设为 `1` 可禁用 UTF-8 stdio shim(`configure_windows_stdio()`),回退到控制台的本地代码页。用于排查编码问题;正常操作中极少需要 | | `HERMES_KANBAN_HOME` | 覆盖锚定 kanban 看板(数据库 + 工作区 + 工作日志)的共享 Hermes 根目录。回退到 `get_default_hermes_root()`(任意活动 profile 的父目录)。适用于测试和非常规部署 | | `HERMES_KANBAN_BOARD` | 为当前进程固定活动 kanban 看板。优先于 `~/.hermes/kanban/current`;调度器将其注入工作进程子进程环境,使工作进程无法看到其他看板上的任务。默认为 `default`。slug 验证:小写字母数字 + 连字符 + 下划线,1-64 字符 | @@ -272,7 +271,7 @@ description: "Hermes Agent 使用的所有环境变量完整参考" | `DISCORD_IGNORED_CHANNELS` | bot 永不响应的逗号分隔频道 ID | | `DISCORD_NO_THREAD_CHANNELS` | bot 不自动创建线程的逗号分隔频道 ID | | `DISCORD_REPLY_TO_MODE` | 回复引用行为:`off`、`first`(默认)或 `all` | -| `DISCORD_ALLOW_MENTION_EVERYONE` | 允许 bot ping `@everyone`/`@here`(默认:`false`)。参见 [Mention 控制](../user-guide/messaging/discord.md#mention-control)。 | +| `DISCORD_ALLOW_MENTION_EVERYONE` | 允许 bot ping `@everyone`/`@here`(默认:`false`)。参见 [Mention 控制](../user-guide/messaging/discord.md#提及控制)。 | | `DISCORD_ALLOW_MENTION_ROLES` | 允许 bot ping `@role` mention(默认:`false`)。 | | `DISCORD_ALLOW_MENTION_USERS` | 允许 bot ping 单个 `@user` mention(默认:`true`)。 | | `DISCORD_ALLOW_MENTION_REPLIED_USER` | 回复消息时 ping 原作者(默认:`true`)。 | @@ -336,7 +335,7 @@ description: "Hermes Agent 使用的所有环境变量完整参考" | `FEISHU_ENCRYPT_KEY` | webhook 模式的可选加密密钥 | | `FEISHU_VERIFICATION_TOKEN` | webhook 模式的可选验证 token | | `FEISHU_ALLOWED_USERS` | 允许向 bot 发送消息的逗号分隔飞书用户 ID | -| `FEISHU_ALLOW_BOTS` | `none`(默认)/`mentions`/`all`——接受来自其他 bot 的入站消息。参见 [bot 间消息传递](../user-guide/messaging/feishu.md#bot-to-bot-messaging) | +| `FEISHU_ALLOW_BOTS` | `none`(默认)/`mentions`/`all`——接受来自其他 bot 的入站消息。参见 [bot 间消息传递](../user-guide/messaging/feishu.md#机器人间消息传递) | | `FEISHU_REQUIRE_MENTION` | `true`(默认)/`false`——群组消息是否必须 @mention bot。可通过 `group_rules..require_mention` 按聊天覆盖。 | | `FEISHU_HOME_CHANNEL` | cron 投递和通知的飞书聊天 ID | | `WECOM_BOT_ID` | 来自管理控制台的企业微信 AI Bot ID | @@ -414,7 +413,7 @@ description: "Hermes Agent 使用的所有环境变量完整参考" | `API_SERVER_PORT` | API 服务器端口(默认:`8642`) | | `API_SERVER_HOST` | API 服务器主机/绑定地址(默认:`127.0.0.1`)。使用 `0.0.0.0` 开放网络访问——需要 `API_SERVER_KEY` 和严格的 `API_SERVER_CORS_ORIGINS` 白名单。 | | `API_SERVER_MODEL_NAME` | `/v1/models` 上公告的模型名称。默认为 profile 名称(默认 profile 为 `hermes-agent`)。适用于 Open WebUI 等前端需要每个连接使用不同模型名称的多用户场景。 | -| `GATEWAY_PROXY_URL` | 将消息转发到的远程 Hermes API 服务器 URL([代理模式](/user-guide/messaging/matrix#proxy-mode-e2ee-on-macos))。设置后,gateway 仅处理平台 I/O——所有 agent 工作委托给远程服务器。也可通过 `config.yaml` 中的 `gateway.proxy_url` 配置。 | +| `GATEWAY_PROXY_URL` | 将消息转发到的远程 Hermes API 服务器 URL([代理模式](/user-guide/messaging/matrix#代理模式macos-上的-e2ee))。设置后,gateway 仅处理平台 I/O——所有 agent 工作委托给远程服务器。也可通过 `config.yaml` 中的 `gateway.proxy_url` 配置。 | | `GATEWAY_PROXY_KEY` | 代理模式下与远程 API 服务器认证的 Bearer token。必须与远程主机上的 `API_SERVER_KEY` 一致。 | | `MESSAGING_CWD` | 消息模式下终端命令的工作目录(默认:`~`) | | `GATEWAY_ALLOWED_USERS` | 跨所有平台允许的逗号分隔用户 ID | @@ -446,7 +445,7 @@ Graph 事件(Teams 会议、日历、聊天等)的入站变更通知监听 ### Teams 会议摘要投递 -仅在启用 [`teams_pipeline` 插件](/user-guide/messaging/msgraph-webhook)时使用。设置也可在 `config.yaml` 的 `platforms.teams.extra` 下配置——两者都设置时环境变量优先。参见 [Microsoft Teams → 会议摘要投递](/user-guide/messaging/teams#meeting-summary-delivery-teams-meeting-pipeline)。 +仅在启用 [`teams_pipeline` 插件](/user-guide/messaging/msgraph-webhook)时使用。设置也可在 `config.yaml` 的 `platforms.teams.extra` 下配置——两者都设置时环境变量优先。参见 [Microsoft Teams → 会议摘要投递](/user-guide/messaging/teams#会议摘要投递teams-会议-pipeline)。 | 变量 | 描述 | |----------|-------------| @@ -567,7 +566,7 @@ Graph 事件(Teams 会议、日历、聊天等)的入站变更通知监听 | `HERMES_ALLOW_PRIVATE_URLS` | `true`/`false`——允许工具获取 localhost/私有网络 URL。gateway 模式下默认关闭。 | | `HERMES_REDACT_SECRETS` | `true`/`false`——控制工具输出、日志和聊天响应中的密钥脱敏(默认:`true`)。 | | `HERMES_WRITE_SAFE_ROOT` | 可选目录前缀,**硬阻止** `write_file`/`patch` 写入列出的根目录之外的路径(无审批提示)。支持多个目录,使用 `os.pathsep` 分隔(Unix 为 `:`,Windows 为 `;`)。详见下方 [HERMES_WRITE_SAFE_ROOT](#hermes_write_safe_root)。 | -| `HERMES_DISABLE_LAZY_INSTALLS` | 官方 Docker 镜像中自动设置的内部桥接变量,用于阻止运行时将依赖安装到不可变的 `/opt/hermes` 树。面向用户的等价配置是 `config.yaml` 中的 `security.allow_lazy_installs: false`;不要在 `.env` 中手动设置此变量。 | +| `HERMES_DISABLE_LAZY_INSTALLS` | 官方 Docker 镜像设置的内部按需安装禁用策略。仅更改 `security.allow_lazy_installs` 不能覆盖镜像策略;不要在 `.env` 中手动设置。 | | `HERMES_DISABLE_FILE_STATE_GUARD` | 设为 `1` 可关闭 `patch`/`write_file` 上的"文件自上次读取后已更改"保护。 | | `HERMES_CORE_TOOLS` | 规范核心工具列表的逗号分隔覆盖(高级;极少需要)。 | | `HERMES_BUNDLED_SKILLS` | 启动时加载的内置技能列表的逗号分隔覆盖。 | @@ -595,7 +594,7 @@ Graph 事件(Teams 会议、日历、聊天等)的入站变更通知监听 export HERMES_WRITE_SAFE_ROOT=/path/to/project:/home/you/.hermes ``` -取消设置或从 `.env` 中移除此变量可恢复常规写入(仍受凭证路径拒绝列表约束——见[文件写入安全](../user-guide/security.md#file-write-safety))。 +取消设置或从 `.env` 中移除此变量可恢复常规写入(仍受凭证路径拒绝列表约束——见[文件写入安全](https://hermes-agent.nousresearch.com/docs/user-guide/security#file-write-safety))。 ## 界面 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md index 6260153094..d52ac0cd13 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md @@ -52,11 +52,11 @@ curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash 参见: - [在 Hermes 中使用 MCP](../guides/use-mcp-with-hermes.md#wsl2-bridge-hermes-in-wsl-to-windows-chrome) -- [浏览器自动化](../user-guide/features/browser.md#wsl2--windows-chrome-prefer-mcp-over-browser-connect) +- [浏览器自动化](../user-guide/features/browser.md#wsl2--windows-chrome优先使用-mcp-而非-browser-connect) ### 支持 Android 吗? -不支持 — Hermes 已移除 Android 和 Termux 支持。请参阅[平台支持页面](../getting-started/platform-support.md)了解支持的平台。 +aarch64 设备可使用预发布的 Termux APT 软件包。请参阅 [Termux 指南](../getting-started/termux.md)了解签名仓库、安装步骤和限制。请使用 `pkg upgrade hermes-agent` 更新,不要使用 `hermes update` 或桌面和服务器的安装脚本。 ### 我的数据会被发送到哪里? @@ -93,7 +93,7 @@ Hermes 会将端点、提供商和 base URL 持久化到 `config.yaml`,重启 ::: :::tip 本地模型超时问题 -Hermes 会自动检测本地端点并放宽流式传输超时(读取超时从 120s 提升至 1800s,禁用停滞流检测)。如果在非常大的上下文下仍然超时,请在 `.env` 中设置 `HERMES_STREAM_READ_TIMEOUT=1800`。详情请参阅[本地 LLM 指南](../guides/local-llm-on-mac.md#timeouts)。 +Hermes 会自动检测本地端点并放宽流式传输超时(读取超时从 120s 提升至 1800s,禁用停滞流检测)。如果在非常大的上下文下仍然超时,请在 `.env` 中设置 `HERMES_STREAM_READ_TIMEOUT=1800`。详情请参阅[本地 LLM 指南](../guides/local-llm-on-mac.md#超时设置)。 ::: ### 费用是多少? @@ -153,20 +153,12 @@ ls ~/.local/bin/hermes 安装程序会将 `~/.local/bin` 添加到您的 PATH。如果您使用非标准 shell 配置,请手动添加 `export PATH="$HOME/.local/bin:$PATH"`。 ::: -#### Python 版本过旧 +#### Python 版本不受支持 -**原因:** Hermes 需要 Python 3.11 或更新版本。 - -**解决方案:** -```bash -python3 --version # 检查当前版本 - -# 安装更新的 Python -sudo apt install python3.12 # Ubuntu/Debian -brew install python@3.12 # macOS -``` - -安装程序会自动处理此问题 — 如果在手动安装时看到此错误,请先升级 Python。 +Hermes 要求 Python 3.14(`>=3.14,<3.15`),不是任意更新版本。 +源码安装脚本和软件包会提供相应的运行时。 +手动开发环境请按照 [开发指南](/developer-guide/contributing)准备。 +不要在签名应用或容器内部替换 Python 来修复版本错误。 #### 终端命令提示 `node: command not found`(或 `nvm`、`pyenv`、`asdf` 等) @@ -333,7 +325,7 @@ custom_providers: context_length: 32768 ``` -有关自动检测的工作原理及所有覆盖选项,请参阅[上下文长度检测](../integrations/providers.md#context-length-detection)。 +有关自动检测的工作原理及所有覆盖选项,请参阅[上下文长度检测](../integrations/providers.md#上下文长度检测)。 --- diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/docker.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/docker.md index 8b1609ef12..2a8f65229d 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/docker.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/docker.md @@ -9,10 +9,22 @@ description: "在 Docker 中运行 Hermes Agent 以及将 Docker 用作终端后 Docker 与 Hermes Agent 的交集有两种截然不同的方式: 1. **在 Docker 中运行 Hermes** — agent 本身在容器内运行(本页的主要内容) -2. **Docker 作为终端后端** — agent 在宿主机上运行,但将每条命令在单个持久化 Docker 沙箱容器中执行,该容器在工具调用、`/new` 和子 agent 之间保持存活,直至 Hermes 进程结束(参见 [配置 → Docker 后端](./configuration.md#docker-backend)) +2. **Docker 作为终端后端** — agent 在宿主机上运行,但将每条命令在单个持久化 Docker 沙箱容器中执行,该容器在工具调用、`/new` 和子 agent 之间保持存活,直至 Hermes 进程结束(参见 [配置 → Docker 后端](./configuration.md#docker-后端)) 本页介绍选项 1。容器将所有用户数据(配置、API 密钥、会话、技能、记忆)存储在从宿主机挂载于 `/opt/data` 的单个目录中。镜像本身是无状态的,可通过拉取新版本进行升级而不会丢失任何配置。 +## 镜像标签和更新 + +| 标签 | 含义 | +|---|---| +| `stable` / `latest` | 通过完整稳定发布验收后提升的镜像 | +| `main` | 主分支开发构建 | +| `X.Y.Z` | 发布流程生成的版本化稳定镜像;精确部署使用摘要 | + +镜像流程构建并测试 amd64 和 arm64。稳定发布复用已测试归档,不重新构建。 +主分支推送只更新 `main`,不会提升 `stable` 或 `latest`。 +应用代码位于 `/opt/hermes`,持久数据位于挂载的 `/opt/data`,两者分别管理。 + ## 快速开始 如果这是你第一次运行 Hermes Agent,请在宿主机上创建一个数据目录,并以交互方式启动容器以运行设置向导: @@ -184,7 +196,7 @@ docker exec hermes hermes -p coder gateway restart docker exec hermes hermes -p coder gateway status ``` -若第二个 profile 也要暴露 OpenAI 兼容 API server,请在**该 profile 自己的** `.env` 中设置不同的 `API_SERVER_PORT`,然后重启该 profile 的 gateway;不要把端口放进容器级 `environment:`,否则所有 profile 都会争抢同一个端口。更底层的监管细节见后文的 [Per-profile gateway 监管](#per-profile-gateway-监管)。 +若第二个 profile 也要暴露 OpenAI 兼容 API server,请在**该 profile 自己的** `.env` 中设置不同的 `API_SERVER_PORT`,然后重启该 profile 的 gateway;不要把端口放进容器级 `environment:`,否则所有 profile 都会争抢同一个端口。更底层的监管细节见后文的 [Per-profile gateway 监管](#per-profile-gateway-supervision)。 ## 环境变量转发 @@ -201,7 +213,7 @@ docker run -it --rm \ 直接传入的 `-e` 标志会覆盖 `.env` 中的值。这对于不希望将密钥写入磁盘的 CI/CD 或密钥管理器集成非常有用。 :::note 寻找 Docker 作为**终端后端**的说明? -本页介绍在 Docker 内运行 Hermes 本身。如果你希望 Hermes 在 Docker 沙箱容器内执行 agent 的 `terminal` / `execute_code` 调用(每个 Hermes 进程对应一个持久容器),那是另一个配置块——`terminal.backend: docker` 加上 `terminal.docker_image`、`terminal.docker_volumes`、`terminal.docker_forward_env`、`terminal.docker_run_as_host_user` 和 `terminal.docker_extra_args`。完整配置请参见 [配置 → Docker 后端](configuration.md#docker-backend)。 +本页介绍在 Docker 内运行 Hermes 本身。如果你希望 Hermes 在 Docker 沙箱容器内执行 agent 的 `terminal` / `execute_code` 调用(每个 Hermes 进程对应一个持久容器),那是另一个配置块——`terminal.backend: docker` 加上 `terminal.docker_image`、`terminal.docker_volumes`、`terminal.docker_forward_env`、`terminal.docker_run_as_host_user` 和 `terminal.docker_extra_args`。完整配置请参见 [配置 → Docker 后端](configuration.md#docker-后端)。 ::: ## Docker Compose 示例 @@ -233,7 +245,7 @@ services: cpus: "2.0" ``` -使用 `docker compose up -d` 启动,使用 `docker compose logs -f` 查看日志。Dashboard 的 stdout/stderr 会直接出现在这里;gateway 主日志则写入每个 profile 的 s6 日志文件,见下方的 [Per-profile gateway 监管](#per-profile-gateway-监管)。 +使用 `docker compose up -d` 启动,使用 `docker compose logs -f` 查看日志。Dashboard 的 stdout/stderr 会直接出现在这里;gateway 主日志则写入每个 profile 的 s6 日志文件,见下方的 [Per-profile gateway 监管](#per-profile-gateway-supervision)。 ## 资源限制 @@ -258,38 +270,36 @@ docker run -d \ nousresearch/hermes-agent gateway run ``` -## Dockerfile 说明 +## Dockerfile 说明 {#what-the-dockerfile-does} -官方镜像基于 `debian:13.4`,包含: +官方镜像基于 Debian 13.4,包含: -- Python 3 及所有 Hermes 依赖(`uv pip install -e ".[all]"`) -- Node.js + npm(用于浏览器自动化和 WhatsApp 桥接) -- Playwright 与 Chromium(`npx playwright install --with-deps chromium --only-shell`) -- ripgrep、ffmpeg、git 和 `xz-utils` 作为系统工具 -- **`docker-cli`** — 使容器内运行的 agent 可以驱动宿主机的 Docker 守护进程(绑定挂载 `/var/run/docker.sock` 以启用),用于 `docker build`、`docker run`、容器检查等操作 -- **`openssh-client`** — 从容器内启用 [SSH 终端后端](/user-guide/configuration#ssh-backend)。SSH 后端调用系统 `ssh` 二进制文件;若缺少此组件,在容器化安装中会静默失败 -- WhatsApp 桥接(`scripts/whatsapp-bridge/`) -- **[`s6-overlay`](https://github.com/just-containers/s6-overlay) v3** 作为 PID 1(替代旧版 `tini`)——监管 dashboard 和各 profile gateway,崩溃后自动重启,回收僵尸子进程,并转发信号 +- 按提交的 `uv.lock` 同步的 Python 3.14 环境,然后无依赖地安装 Hermes 源码。 +- 选定的 extras:`all`、`messaging`、`otlp`、`anthropic`、`bedrock`、`azure-identity`、`hindsight` 和 `matrix`,不是 `--all-extras`。 +- 从摘要固定的 Node 镜像提供的 Node.js 26 和 npm。 +- PM 固定版本的 uv、Chromium 和 headless shell,位于 `/opt/hermes/tools`。 +- 系统 Git、ripgrep、FFmpeg、OpenSSH、Docker CLI 和 Chromium 所需共享库。 +- 预构建的 TUI/dashboard 和 Photon sidecar 依赖,以及 s6-overlay。 -容器的 `ENTRYPOINT` 是 s6-overlay 的 `/init`。启动时: -1. 以 root 身份运行 `/etc/cont-init.d/01-hermes-setup`(即 `docker/stage2-hook.sh`):可选的 UID/GID 重映射、修复卷所有权、首次启动时初始化 `.env` / `config.yaml` / `SOUL.md`、同步内置技能。 -2. 运行 `/etc/cont-init.d/02-reconcile-profiles`(即 `hermes_cli.container_boot`):遍历 `$HERMES_HOME/profiles//`,在 `/run/service/gateway-/` 下重建各 profile 的 gateway s6 服务槽,并仅自动启动上次记录状态为 `running` 的 profile(参见 [Per-profile gateway 监管](#per-profile-gateway-supervision))。 -3. 启动静态的 `main-hermes` 和 `dashboard` s6-rc 服务。 -4. 将容器的 CMD 作为主程序 exec(`/opt/hermes/docker/main-wrapper.sh`),根据用户传给 `docker run` 的参数进行路由: - - 无参数 → `hermes`(默认) - - 第一个参数是 PATH 上的可执行文件(如 `sleep`、`bash`)→ 直接 exec - - 其他情况 → `hermes `(子命令透传) - 主程序退出时容器退出,并使用其退出码。 +Chromium 由 PM 准备,不使用 `npx playwright install`。 +实际可执行路径记录在 `/etc/hermes/agent-browser-executable-path`。 +`PLAYWRIGHT_BROWSERS_PATH` 指向 `/opt/hermes/tools`,不在数据卷内。 -:::warning 与 pre-s6 镜像的破坏性变更 -容器 ENTRYPOINT 现在是 `/init`(s6-overlay),而非 `/usr/bin/tini`。所有五种已记录的 `docker run` 调用模式(无参数、`chat -q "…"`、`sleep infinity`、`bash`、`--tui`)的行为与基于 tini 的镜像完全相同。如果你有依赖 tini 特定信号行为或硬编码 `/usr/bin/tini --` 调用的下游封装,请固定到之前的镜像标签。 -::: +镜像通过内部 `HERMES_DISABLE_LAZY_INSTALLS` 策略关闭按需安装。 +仅修改 `security.allow_lazy_installs` 不能覆盖它。新增所需依赖应烘焙进派生镜像, +或作为独立工具放在单独环境或服务中。旧 `lazy-packages` overlay 不再使用。 -:::warning 权限模型 -除非你在命令链中保留 `/init`(或等效的旧版 `docker/entrypoint.sh` shim,它会转发到 stage2 hook),否则不要覆盖镜像入口点。s6-overlay 的 `/init` 以 root 运行,以便在首次启动时对卷执行 chown,然后通过 `s6-setuidgid` 为每个受监管的服务**以及**主程序降权至 `hermes` 用户。在官方镜像内以 root 启动 `hermes gateway run` 默认会被拒绝,因为这可能在 `/opt/data` 中留下 root 所有的文件,导致后续 dashboard 或 gateway 启动失败。仅在你有意接受该风险时才设置 `HERMES_ALLOW_ROOT_GATEWAY=1`。 -::: +构建来源记录在 `/etc/hermes/image-provenance.json`,构建戳记位于 `/opt/hermes/install-stamp.json`。 +没有戳记的本地构建报告未知版本,不猜测提交。`hermes update` 不修改镜像所有的代码,应用更新需替换镜像。 -### Per-profile gateway 监管 +入口点是 `docker/entrypoint-dispatch.sh`。正常 Docker/Podman 中它占有 PID 1,转交给 s6 的 `/init`。 +若平台已有 PID-1 init,则直接运行 stage2 和主包装器;命令仍可运行,但没有 s6 监管的 dashboard 或各 profile gateway。 + +PID-1 路径先准备数据卷和配置,重建各 profile 的 gateway 服务槽,然后运行主命令。 +不要绕过这些入口步骤,否则会失去权限处理和服务监管。 +主程序及受监管服务以 `hermes` 用户运行,避免在 `/opt/data` 中留下 root 所有的文件。 + +### Per-profile gateway 监管 {#per-profile-gateway-supervision} 在容器内,每个通过 `hermes profile create ` 创建的 profile 都会自动在 `/run/service/gateway-/` 注册一个受 s6 监管的 gateway 服务。你在宿主机上运行的生命周期命令在此同样适用: @@ -333,7 +343,7 @@ docker compose up -d ## 技能与凭据文件 -当使用 Docker 作为执行环境时(不是上述方法,而是 agent 在 Docker 沙箱内运行命令——参见 [配置 → Docker 后端](./configuration.md#docker-backend)),Hermes 为所有工具调用复用单个长期运行的容器,并自动将技能目录(`~/.hermes/skills/`)和技能声明的所有凭据文件以只读卷的形式绑定挂载到该容器中。技能脚本、模板和引用在沙箱内无需手动配置即可使用,由于容器在 Hermes 进程的整个生命周期内持续存在,你安装的任何依赖或写入的文件都会在下次工具调用时保留。 +当使用 Docker 作为执行环境时(不是上述方法,而是 agent 在 Docker 沙箱内运行命令——参见 [配置 → Docker 后端](./configuration.md#docker-后端)),Hermes 为所有工具调用复用单个长期运行的容器,并自动将技能目录(`~/.hermes/skills/`)和技能声明的所有凭据文件以只读卷的形式绑定挂载到该容器中。技能脚本、模板和引用在沙箱内无需手动配置即可使用,由于容器在 Hermes 进程的整个生命周期内持续存在,你安装的任何依赖或写入的文件都会在下次工具调用时保留。 SSH 和 Modal 后端也会进行相同的同步——技能和凭据文件在每次命令执行前通过 rsync 或 Modal mount API 上传。 @@ -383,7 +393,7 @@ docker run -d \ ### 复杂工具或多服务栈——运行 sidecar 容器 -对于自带服务(数据库、Web 服务器、队列、无头浏览器集群)或过于庞大而不适合放在 Hermes 容器内的工具,将其作为独立容器运行在共享 Docker 网络上。Hermes 通过容器名称访问 sidecar,与访问本地推理服务器的方式相同(参见 [连接本地推理服务器](#connecting-to-local-inference-servers-vllm-ollama-etc))。 +对于自带服务(数据库、Web 服务器、队列、无头浏览器集群)或过于庞大而不适合放在 Hermes 容器内的工具,将其作为独立容器运行在共享 Docker 网络上。Hermes 通过容器名称访问 sidecar,与访问本地推理服务器的方式相同(参见 [连接本地推理服务器](#连接本地推理服务器vllmollama-等))。 ```yaml services: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/memory-providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/memory-providers.md index 2d7f762ff5..8612472c8a 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/memory-providers.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/memory-providers.md @@ -197,7 +197,7 @@ hermes honcho sync 通过 [Honcho 控制台](https://app.honcho.dev) 设置的服务端开关优先于本地默认值——在会话初始化时同步回来。 -参见 [Honcho 页面](./honcho.md#observation-directional-vs-unified) 获取完整的 observation 参考。 +参见 [Honcho 页面](./honcho.md#观察模式定向-vs-统一) 获取完整的 observation 参考。
完整 honcho.json 示例(多 profile) @@ -344,15 +344,18 @@ hermes config set memory.provider hindsight echo "HINDSIGHT_API_KEY=your-key" >> ~/.hermes/.env ``` -安装向导会自动安装依赖,并仅安装所选模式所需的内容(云端用 `hindsight-client`,本地用 `hindsight-all`)。需要 `hindsight-client >= 0.4.22`(会话启动时若版本过旧则自动升级)。 +安装向导通过 PM 的 `hindsight` extra 准备客户端。 +`local_embedded` 模式使用独立环境,选择记录位于 +`$HERMES_HOME/profiles/Hindsight/env/active.json`,不把 `hindsight-all` 安装到 Hermes 主环境。 +`local_external` 连接已有服务。各模式的平台限制仍适用。 -**本地模式 UI:** `hindsight-embed -p hermes ui start` +详见[插件 README](https://github.com/NousResearch/hermes-agent/blob/main/plugins/memory/hindsight/README.md)。 **配置:** `$HERMES_HOME/hindsight/config.json` | 键 | 默认值 | 描述 | |-----|---------|-------------| -| `mode` | `cloud` | `cloud` 或 `local` | +| `mode` | `cloud` | `cloud`、`local_embedded` 或 `local_external` | | `bank_id` | `hermes` | 记忆库标识符 | | `recall_budget` | `mid` | 召回彻底程度:`low` / `mid` / `high` | | `memory_mode` | `hybrid` | `hybrid`(上下文 + 工具)、`context`(仅自动注入)、`tools`(仅工具) | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/plugins.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/plugins.md index 1ae944e222..b71ac4476e 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/plugins.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/plugins.md @@ -101,7 +101,7 @@ def register(ctx): | 添加斜杠命令 | `ctx.register_command(name, handler, description)` — 在 CLI 和 gateway 会话中添加 `/name` | | 从命令中调度工具 | `ctx.dispatch_tool(name, args)` — 调用已注册的工具,自动注入父 agent 上下文 | | 添加 CLI 命令 | `ctx.register_cli_command(name, help, setup_fn, handler_fn)` — 添加 `hermes ` | -| 注入消息 | `ctx.inject_message(content, role="user")` — 参见 [注入消息](#injecting-messages) | +| 注入消息 | `ctx.inject_message(content, role="user")` — 参见 [注入消息](#注入消息) | | 附带数据文件 | `Path(__file__).parent / "data" / "file.yaml"` | | 打包 skill | `ctx.register_skill(name, path)` — 命名空间为 `plugin:skill`,通过 `skill_view("plugin:skill")` 加载 | | 按环境变量控制 | 在 plugin.yaml 中设置 `requires_env: [API_KEY]` — 在 `hermes plugins install` 时提示输入 | @@ -122,7 +122,7 @@ def register(ctx): | 用户 | `~/.hermes/plugins/` | 个人插件 | | 项目 | `.hermes/plugins/` | 项目专属插件(需要 `HERMES_ENABLE_PROJECT_PLUGINS=true`) | | pip | `hermes_agent.plugins` entry_points | 分发包 | -| Nix | `services.hermes-agent.extraPlugins` / `extraPythonPackages` | NixOS 声明式安装 — 参见 [Nix Setup](/getting-started/nix-setup#plugins) | +| Nix | `services.hermes-agent.extraPlugins` / `extraPythonPackages` | NixOS 声明式安装 — 参见 [Nix Setup](/getting-started/nix-setup#插件) | 名称冲突时,后面的来源会覆盖前面的,因此与内置插件同名的用户插件会替换它。 @@ -228,10 +228,10 @@ Memory provider 和 context engine 是 **provider 插件** — 每种类型同 | **上下文压缩策略** | Context-engine 插件 — `ctx.register_context_engine()` | [Context Engine Plugins](/developer-guide/context-engine-plugin) | | **图像生成后端**(DALL·E、SDXL 等) | 后端插件 — `ctx.register_image_gen_provider()` | [Image Generation Provider Plugins](/developer-guide/image-gen-provider-plugin) | | **视频生成后端**(Veo、Kling、Pixverse、Grok-Imagine、Runway 等) | 后端插件 — `ctx.register_video_gen_provider()` | [Video Generation Provider Plugins](/developer-guide/video-gen-provider-plugin) | -| **TTS 后端**(任意 CLI — Piper、VoxCPM、Kokoro、xtts、语音克隆脚本等) | 配置驱动(推荐)— 在 `config.yaml` 的 `tts.providers.` 下以 `type: command` 声明。或 Python 后端插件 — 对需要超出 shell 模板的 Python SDK / 流式引擎使用 `ctx.register_tts_provider()`。 | [TTS Setup](/user-guide/features/tts#custom-command-providers) · [Python plugin guide](/user-guide/features/tts#python-plugin-providers) | -| **STT 后端**(自定义 whisper 二进制、本地 ASR CLI) | 配置驱动 — 将 `HERMES_LOCAL_STT_COMMAND` 环境变量设置为 shell 模板 | [Voice Message Transcription (STT)](/user-guide/features/tts#voice-message-transcription-stt) | +| **TTS 后端**(任意 CLI — Piper、VoxCPM、Kokoro、xtts、语音克隆脚本等) | 配置驱动(推荐)— 在 `config.yaml` 的 `tts.providers.` 下以 `type: command` 声明。或 Python 后端插件 — 对需要超出 shell 模板的 Python SDK / 流式引擎使用 `ctx.register_tts_provider()`。 | [TTS Setup](/user-guide/features/tts#自定义命令提供商) · [Python plugin guide](/user-guide/features/tts#python-插件提供商) | +| **STT 后端**(自定义 whisper 二进制、本地 ASR CLI) | 配置驱动 — 将 `HERMES_LOCAL_STT_COMMAND` 环境变量设置为 shell 模板 | [Voice Message Transcription (STT)](/user-guide/features/tts#语音消息转录stt) | | **通过 MCP 使用外部工具**(文件系统、GitHub、Linear、Notion、任意 MCP 服务器) | 配置驱动 — 在 `config.yaml` 中以 `command:` / `url:` 声明 `mcp_servers.`。Hermes 自动发现服务器的工具并与内置工具一同注册。 | [MCP](/user-guide/features/mcp) | -| **额外 skill 来源**(自定义 GitHub 仓库、私有 skill 索引) | CLI — `hermes skills tap add ` | [Skills Hub](/user-guide/features/skills#skills-hub) · [发布自定义 tap](/user-guide/features/skills#publishing-a-custom-skill-tap) | +| **额外 skill 来源**(自定义 GitHub 仓库、私有 skill 索引) | CLI — `hermes skills tap add ` | [Skills Hub](/user-guide/features/skills#skills-hub) · [发布自定义 tap](/user-guide/features/skills#发布自定义-skill-tap) | | **Gateway 事件 hook**(在 `gateway:startup`、`session:start`、`agent:end`、`command:*` 时触发) | 将 `HOOK.yaml` + `handler.py` 放入 `~/.hermes/hooks//` | [Event Hooks](/user-guide/features/hooks#gateway-event-hooks) | | **Shell hook**(在事件时运行 shell 命令 — 通知、审计日志、桌面提醒) | 配置驱动 — 在 `config.yaml` 的 `hooks:` 下声明 | [Shell Hooks](/user-guide/features/hooks#shell-hooks) | @@ -241,14 +241,14 @@ Memory provider 和 context engine 是 **provider 插件** — 每种类型同 ## NixOS 声明式插件 -在 NixOS 上,插件可通过模块选项声明式安装 — 无需 `hermes plugins install`。完整详情请参见 **[Nix Setup 指南](/getting-started/nix-setup#plugins)**。 +在 NixOS 上,插件可通过模块选项声明式安装 — 无需 `hermes plugins install`。完整详情请参见 **[Nix Setup 指南](/getting-started/nix-setup#插件)**。 ```nix services.hermes-agent = { # 目录插件(包含 plugin.yaml 的源码树) extraPlugins = [ (pkgs.fetchFromGitHub { ... }) ]; # 入口点插件(pip 包) - extraPythonPackages = [ (pkgs.python312Packages.buildPythonPackage { ... }) ]; + extraPythonPackages = [ (config.services.hermes-agent.package.python.pkgs.buildPythonPackage { ... }) ]; # 在 config 中启用 settings.plugins.enabled = [ "my-plugin" ]; }; @@ -262,7 +262,7 @@ services.hermes-agent = { hermes plugins # 统一交互式 UI hermes plugins list # 表格:已启用 / 已禁用 / 未启用 hermes plugins install user/repo # 从 Git 安装,然后提示 Enable? [y/N] -hermes plugins install user/repo --enable # 安装并启用(无提示) +hermes plugins install user/repo --enable # 请求启用;依赖安装仍需单独同意 hermes plugins install user/repo --no-enable # 安装但保持禁用(无提示) hermes plugins update my-plugin # 拉取最新版本 hermes plugins remove my-plugin # 卸载 @@ -273,6 +273,31 @@ hermes plugins disable my-plugin # 从允许列表移除并 对于子分类目录下的插件(例如 `plugins/observability/langfuse/`、`plugins/image_gen/openai/`),使用完整的 `/` key — 这正是 `hermes plugins list` 在 **Name** 列中显示的内容。 +### 更新检查、来源与依赖 + +```bash +hermes plugins check-updates +hermes plugins adopt my-plugin +hermes plugins trust-update-url my-plugin +``` + +Git 安装的来源和版本记录在 `.install-metadata.json`。 +未固定版本的已跟踪插件比较保存的远程来源;固定版本保持固定。 +自行克隆的目录需先 `adopt` 才纳入管理,手动复制或来源漂移的目录仅得到诊断提示。 +pip 入口点插件可报告发行包版本,但不会因此转为 Git 管理。 + +Gateway 按 `plugins.auto_update_check_hours` 检查更新,默认 24 小时,`0` 关闭。 +`check-updates` 不改写插件文件。检查记录可通过 `hermes pm status` 和桌面同步状态查看。 +默认通过 `hermes plugins update NAME` 手动应用;`plugins.auto_apply: true` 才允许跟踪的 Git 插件自动更新。 +固定版本、手动目录、来源漂移和 pip 发行包不参与自动应用。 +新引入或变更的 `update_url` 需通过 `trust-update-url` 审阅确认。 + +Python 依赖安装有独立同意和准入步骤,`--enable` 不绕过它。 +拒绝或非交互式依赖安装可使插件保留为已安装但未启用状态。 +PM 统一准备核心和插件依赖,失败保留旧选择。新环境尚未进入当前进程时需重启。 +普通应用更新保留插件目录,显式插件更新或删除可以改动这些文件。 +详见[包管理](/reference/package-management)。 + ### 交互式 UI 不带参数运行 `hermes plugins` 会打开一个复合交互界面: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/voice-mode.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/voice-mode.md index 6593030880..869826b9c0 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/voice-mode.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/voice-mode.md @@ -38,30 +38,20 @@ Hermes Agent 支持在 CLI 和消息平台上进行完整的语音交互。通 ### Python 包 -```bash -# CLI 语音模式(麦克风 + 音频播放) -cd ~/.hermes/hermes-agent && uv pip install -e ".[voice]" +通过 `hermes tools` 配置语音提供商。缺失的内置功能依赖由 PM 按策略和目标平台支持准备。 +如果选择的环境改变,请按提示重启 Hermes。 +桌面包预装其支持的引擎;Docker 使用较小集合并关闭按需安装。 +不要修改签名载荷或系统 Python。手动开发环境参见[开发配置](/developer-guide/contributing)。 -# Discord + Telegram 消息(包含 discord.py[voice] 以支持语音频道) -cd ~/.hermes/hermes-agent && uv pip install -e ".[messaging]" +| Extra | 包 | 用途 | +|---|---|---| +| `voice` | `sounddevice`、`numpy`,以及支持平台上的 Faster-Whisper | CLI 音频和本地 STT | +| `audio-io` | `sounddevice`、`numpy` | 不包含本地 STT 的麦克风和播放支持 | +| `messaging` | `discord.py[voice]`、`python-telegram-bot`、`aiohttp` | Discord 和 Telegram | +| `tts-premium` | `elevenlabs` | ElevenLabs TTS | -# 高级 TTS(ElevenLabs) -cd ~/.hermes/hermes-agent && uv pip install -e ".[tts-premium]" - -# 本地 TTS(NeuTTS,可选) -python -m pip install -U neutts[all] - -# 一次性安装所有内容 -cd ~/.hermes/hermes-agent && uv pip install -e ".[all]" -``` - -| 扩展包 | 包含的包 | 用途 | -|-------|----------|-------------| -| `voice` | `sounddevice`、`numpy` | CLI 语音模式 | -| `messaging` | `discord.py[voice]`、`python-telegram-bot`、`aiohttp` | Discord 和 Telegram 机器人 | -| `tts-premium` | `elevenlabs` | ElevenLabs TTS 提供商 | - -可选本地 TTS 提供商:使用 `python -m pip install -U neutts[all]` 单独安装 `neutts`。首次使用时会自动下载模型。 +原生 Windows ARM64 和 Intel macOS 不包含 Faster-Whisper,请使用云端或命令式 STT。 +`all` 不代表所有语音或唤醒引擎。NeuTTS 是单独的可选运行时,首次使用会下载模型。 :::info `discord.py[voice]` 会自动安装 **PyNaCl**(用于语音加密)和 **opus 绑定**。这是 Discord 语音频道支持的必要条件。 @@ -92,7 +82,7 @@ sudo apt install espeak-ng # for NeuTTS ```bash # 语音转文字(STT)— 本地提供商完全不需要密钥 -# pip install faster-whisper # 免费,本地运行,推荐 +# 本地 Faster-Whisper 由 PM 在支持的目标上准备,无需 STT API key。 GROQ_API_KEY=your-key # Groq Whisper — 速度快,有免费额度(云端) VOICE_TOOLS_OPENAI_KEY=your-key # OpenAI Whisper — 付费(云端) @@ -315,7 +305,7 @@ Bot 会从以下路径自动加载编解码器: DISCORD_BOT_TOKEN=your-bot-token DISCORD_ALLOWED_USERS=your-user-id -# STT — 本地提供商无需密钥(pip install faster-whisper) +# 本地 Faster-Whisper 由 PM 在支持的目标上准备,无需 STT API key。 # GROQ_API_KEY=your-key # 替代方案:云端,速度快,有免费额度 # TTS — 可选。Edge TTS 和 NeuTTS 无需密钥。 @@ -428,7 +418,7 @@ tts: ```bash # 语音转文字提供商(本地无需密钥) -# pip install faster-whisper # 免费本地 STT — 无需 API 密钥 +# 本地 Faster-Whisper 由 PM 在支持的目标上准备,无需 STT API key。 GROQ_API_KEY=... # Groq Whisper(速度快,有免费额度) VOICE_TOOLS_OPENAI_KEY=... # OpenAI Whisper(付费) diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/matrix.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/matrix.md index fd9c552bbc..b040fa0a6f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/matrix.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/matrix.md @@ -370,7 +370,7 @@ MATRIX_ALLOWED_ROOMS="!abc123def456:matrix.example.org,!opsroom789:matrix.exampl - 非空 → 房间 ID 必须在列表中。该检查在所有其他门控(提及要求、发送者白名单等)**之前**运行。 - 使用房间的**内部 ID**(`!abc...:server`),而非别名(`#room:server`)。你可以在 Element 中通过 房间 → 设置 → 高级 找到房间的内部 ID。 -另请参阅:[管理员/用户斜杠命令分离](../../reference/slash-commands.md#permissions-and-adminuser-split)。 +另请参阅:[管理员/用户斜杠命令分离](../../reference/slash-commands.md#权限与管理员用户分级)。 :::tip 查找房间 ID:在 Element 中,进入房间 → **设置** → **高级** → **内部房间 ID**(以 `!` 开头)。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/security.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/security.md index b5d7b36a79..5c717cbac4 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/security.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/security.md @@ -73,7 +73,7 @@ YOLO 模式在 CLI 和 gateway 会话中均可使用。在内部,它会设置 YOLO 模式会禁用会话中**所有**危险命令安全检查——**但硬性黑名单除外**(见下文)。仅在完全信任所生成命令的情况下使用(例如,在一次性环境中经过充分测试的自动化脚本)。 ::: -对于破坏性会话斜杠命令(`/clear`、`/new` / `/reset`、`/undo`、`/exit --delete`),CLI 在执行前也会提示确认。参见[斜杠命令——破坏性命令的确认提示](../reference/slash-commands.md#confirmation-prompts-for-destructive-commands)。 +对于破坏性会话斜杠命令(`/clear`、`/new` / `/reset`、`/undo`、`/exit --delete`),CLI 在执行前也会提示确认。参见[斜杠命令——破坏性命令的确认提示](../reference/slash-commands.md#破坏性命令的确认提示)。 ### 硬性黑名单(始终生效的底线) @@ -495,7 +495,7 @@ security: 当请求被阻止的 URL 时,工具会返回一条错误,说明该域名已被策略阻止。黑名单在 `web_search`、`web_extract`、`browser_navigate` 及所有支持 URL 的工具中均强制执行。 -完整详情请参见配置指南中的[网站黑名单](/user-guide/configuration#website-blocklist)。 +完整详情请参见配置指南中的[网站黑名单](/user-guide/configuration#网站黑名单)。 ### SSRF 防护 @@ -630,35 +630,33 @@ hermes doctor --ack ### 可选依赖的懒加载安装 -许多功能(Mistral TTS、ElevenLabs、Honcho 记忆、Bedrock、Slack、Matrix 等)依赖并非每个用户都需要的 Python 包。Hermes 在首次使用时**懒加载**安装这些包,而非在 `hermes-agent[all]` 下急切安装。实现位于 `tools/lazy_deps.py`。 +PM 通过 `pyproject.toml` 中的 extras 管理可选 Python 功能。 +源码安装选择 `all` extra,原生桌面包预装目标平台支持的所有 extras。 +这两种集合并不相同。 -此方案解决的权衡问题: +当功能请求缺失的 extra 时,`pm.ensure_import("extra-name")` 使用与插件准入相同的依赖事务: -- **脆弱性。** 当某个额外依赖的传递依赖在 PyPI 上不可用时(因恶意软件被隔离、被撤回、上传损坏),整个 `[all]` 解析会失败,新安装会静默回退到精简版本——同时丢失 10 个以上不相关的额外功能。懒加载安装将每个后端隔离,使一个受损依赖不会破坏不相关的功能。 -- **臃肿。** 只使用一个提供商的用户不再需要拉取数百个永远不会导入的包。 +1. 检查平台支持和 `security.allow_lazy_installs`。 +2. 在候选环境中统一准备核心依赖、现有 extras 和已启用插件的依赖。 +3. 没有插件成员时保持提交的锁文件不变;有成员时从先前选择开始解析,然后执行冻结同步。 +4. 验证候选环境后才发布新选择。失败会保留原环境,不会自动禁用或删除其他插件。 +5. 若当前进程仍使用旧环境,则提示重启,不在进程中直接替换已导入的库。 -工作原理: +已发布的源码、锁文件和签名载荷保持不变。新增依赖使用包外的可写存储。 +插件依赖共享完整 Python 环境,不是相互隔离的沙箱。 +兼容的传递依赖可以更新,但声明的约束和精确固定版本仍有效。 +失败通过 `pm.InstallError` 和同步记录报告。 -1. 后端模块在其首次导入路径的顶部调用 `ensure("feature.name")`。 -2. 若依赖缺失,`ensure` 检查 `config.yaml` 中的 `security.allow_lazy_installs`(默认 `true`),并为允许列表中的规格运行 venv 作用域的 `pip install`。 -3. 若安装失败或用户已禁用懒加载安装,调用会抛出 `FeatureUnavailable`,附带实际的 pip stderr 和指向 `hermes tools` 的提示。 +关闭按需安装: -`tools/lazy_deps.py` 强制执行的安全保证: - -| 保证 | 含义 | -|---|---| -| 仅限 venv 作用域 | 安装目标为活跃 venv 中的 `sys.executable`——绝不安装到系统 Python | -| 仅按名称从 PyPI 安装 | 规格接受 `"package>=1.0,<2"` 语法。不允许 `--index-url`、`git+https://` 或 `file:` 路径——恶意的 `config.yaml` 无法重定向安装 | -| 允许列表 | 只有出现在内置 `LAZY_DEPS` 映射中的规格才能通过此路径安装。功能名称中的拼写错误**不会**获得任意安装语义 | -| 可选退出 | 设置 `security.allow_lazy_installs: false` 可完全禁用运行时安装。适用于受限网络或严格安全态势 | -| 无静默重试 | 失败以 `FeatureUnavailable` 形式呈现——不缓存错误状态,不发生重试风暴 | - -禁用运行时安装: - -```yaml -# ~/.hermes/config.yaml -security: - allow_lazy_installs: false +```bash +hermes config set security.allow_lazy_installs false ``` -禁用后,需要可选依赖的后端会提示用户手动运行安装(`pip install …`)或通过 `hermes tools` 选择其他后端。 \ No newline at end of file +已安装的依赖仍可使用。显式安装命令与按需安装不同。 +关闭懒加载且存在包内冻结功能列表时,请求的 Python extra 名称仍受该列表限制。 +该设置不是禁止显式插件准入或手动包管理命令的沙箱。 +官方 Docker 镜像还通过内部策略关闭按需安装,仅更改配置不能覆盖它。 + +用 `hermes tools` 和 `hermes doctor` 检查缺失需求。 +不要向签名载荷或系统 Python 执行 pip 安装。详见[包管理](/reference/package-management)。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-native.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-native.md index 205d347e74..bce899a61f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-native.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-native.md @@ -1,4 +1,4 @@ -P--- +--- title: "Windows(原生)指南" description: "在 Windows 10 / 11 上原生运行 Hermes Agent — 安装、功能矩阵、UTF-8 控制台、Git Bash、将 gateway 作为计划任务、编辑器处理、PATH、卸载及常见问题" sidebar_label: "Windows(原生)" @@ -25,58 +25,56 @@ iex (irm https://hermes-agent.nousresearch.com/install.ps1) 无需管理员权限。安装程序会写入 `%LOCALAPPDATA%\hermes\`,并将 `hermes` 添加到你的**用户 PATH**——安装完成后打开新终端即可使用。 -**安装程序选项**(需要使用 scriptblock 形式传递参数): +**安装程序选项:** ```powershell -& ([scriptblock]::Create((irm https://hermes-agent.nousresearch.com/install.ps1))) -NoVenv -SkipSetup -Branch main +& ([scriptblock]::Create((irm https://hermes-agent.nousresearch.com/install.ps1))) -NonInteractive -Branch main ``` -| 参数 | 默认值 | 用途 | -| ------------- | ------------------------------------ | ----------------------------------------------- | -| `-Branch` | `main` | 克隆指定分支(用于测试 PR) | -| `-Commit` | 未设置 | 将安装固定到指定 commit SHA(覆盖 `-Branch`) | -| `-Tag` | 未设置 | 将安装固定到指定 git tag(如 `v0.14.0`) | -| `-NoVenv` | 关闭 | 跳过 venv 创建(高级用法——由你自行管理 Python) | -| `-SkipSetup` | 关闭 | 跳过安装后的 `hermes setup` 向导 | -| `-HermesHome` | `%LOCALAPPDATA%\hermes` | 覆盖数据目录 | -| `-InstallDir` | `%LOCALAPPDATA%\hermes\hermes-agent` | 覆盖代码存放位置 | +| 参数 | 用途 | +|---|---| +| `-Branch NAME` | 选择源码分支,默认 `main`。 | +| `-Commit SHA` | 在分支检出后固定到指定 commit。 | +| `-HermesHome PATH` | 选择数据目录。 | +| `-InstallDir PATH` | 选择源码目录。 | +| `-NonInteractive` | 跳过需要输入的 setup/gateway 阶段。 | +| `-IncludeDesktop` | 构建桌面应用并创建快捷方式。 | +| `-ShowResolvedPaths` | 只输出解析后的路径 JSON,不安装。 | +| `-Manifest` / `-ProtocolVersion` | 查看引导 GUI 使用的阶段协议。 | +| `-Stage NAME -Json` | 执行单个阶段并输出结果帧。 | -安装程序会自动重试不稳定的 git 拉取,并剥离下载的 `install.ps1` 内容中的 BOM,因此 HTTP 传输中携带的 UTF-8 BOM 不再会破坏 `[scriptblock]::Create((irm ...))` 形式。 +当前脚本不接受 `-NoVenv`、`-SkipSetup` 或 `-Tag`。 -### 桌面安装程序(备选方案) +### MSIX / App Installer 和 Microsoft Store -也提供了一个轻量 GUI 安装程序——如果你更倾向于双击 `.exe` 而非打开 PowerShell,可以使用它。下载 Hermes Desktop,运行安装程序,首次启动时 GUI 会在后台调用 `install.ps1` 来配置 Python(通过 `uv`)、Node、PortableGit 以及下文描述的其余依赖引导流程。首次运行后,桌面应用与 PowerShell 安装的 `hermes` CLI 共享同一个 `%LOCALAPPDATA%\hermes\hermes-agent` 安装目录和 `%USERPROFILE%\.hermes` 数据目录——可以在 GUI 和 CLI 之间自由切换。 +自包含 MSIX 要求 Windows 11 22H2 或更新版本。 +Windows 10 源码安装支持不代表 MSIX 支持 Windows 10。 +打开 `.appinstaller` 文件,Windows 会安装签名包并记录更新源。 +软件包包含 Python、Node 和基础依赖,首次启动无需克隆或编译源码。 -如果你想要熟悉的 Windows 安装体验,或者要将 Hermes 交给非开发者使用,请使用桌面安装程序;如果你已经在终端中,请使用 PowerShell 一行命令。 +执行别名提供 `hermes`、`hermes-agent` 和 `hermes-acp`。 +用 `Get-Command hermes -All` 检查是否被其他安装覆盖。 +在 Windows 的应用执行别名设置中管理这些入口。 -### 依赖引导(`dep_ensure`) +侧载版通过桌面 Update 控件交给 App Installer 更新。 +Hermes 先下载本地描述文件,再停止自己的后端、退出并等待包替换。 +它不依赖 `ms-appinstaller:` URL 协议。商店版本由 Microsoft Store 更新。 -在首次启动时(以及检测到缺少工具时按需触发),Hermes 会运行一个小型 Python 引导程序——`hermes_cli/dep_ensure.py`——检查并懒加载安装所需的非 Python 依赖。在 Windows 上,相关依赖如下: +`Hermes-Setup.exe` 是另一种引导安装程序,会下载并配置源码安装。 +不要把它与自包含 MSIX 混为一谈。 -| 依赖 | Hermes 需要它的原因 | -| --------------- | ----------------------------------------------------------------------------------------------- | -| **PortableGit** | 为终端工具提供 `bash.exe`,为会话内克隆提供 `git`。在安装时配置,而非由 `dep_ensure` 负责。 | -| **Node.js 22** | 浏览器工具(`agent-browser`)、TUI 的 web 桥接以及 WhatsApp 桥接所必需。 | -| **ffmpeg** | TTS / 语音消息的音频格式转换。 | -| **ripgrep** | 快速文件搜索——不可用时回退到 `grep`。 | -| **npm 包** | `agent-browser`、Playwright Chromium 以及各工具集的 Node 依赖,在首次使用浏览器工具时安装一次。 | +## 源码安装程序实际做了什么 -每个依赖都有类似 `shutil.which(...)` 的检查;如果二进制文件缺失且当前为交互式运行,`dep_ensure` 会提示安装(实际安装逻辑委托给 `scripts\install.ps1 -ensure `)。非交互式运行(gateway、cron、无头桌面启动)会跳过提示,并直接给出清晰的 `this feature needs ` 错误。 +1. 使用现有 Git;缺少时下载经过验证的 Git for Windows 工具包。 +2. 克隆源码分支,并应用可选的 commit pin。 +3. 引导 uv,再委托 PM 准备 Python 3.14、必要工具和名为 `all` 的 Python extra。 +4. 在数据目录的 `bin` 下生成启动器,并加入用户 PATH。 +5. 准备配置;非交互模式跳过输入阶段。 +6. 按需构建桌面应用,并写入完成标记。 -## 安装程序实际做了什么 - -从头到尾,按顺序: - -1. **引导 `uv`** — Astral 的快速 Python 管理器。安装到 `%USERPROFILE%\.local\bin`。 -2. **通过 `uv` 安装 Python 3.11**。无需预先安装 Python。 -3. **安装 Node.js 22**(优先使用 winget,否则将便携式 Node 压缩包解压到 `%LOCALAPPDATA%\hermes\node`)。用于浏览器工具和 WhatsApp 桥接。 -4. **安装便携式 Git** — 如果 `git` 已在 PATH 中,安装程序直接使用;否则从官方 `git-for-windows` 发布版下载精简的自包含 **PortableGit**(约 45 MB)到 `%LOCALAPPDATA%\hermes\git`。无需管理员权限,不写入 Windows 安装程序注册表,不干扰系统上的其他任何内容。 -5. **将仓库克隆**到 `%LOCALAPPDATA%\hermes\hermes-agent` 并在其中创建 virtualenv。 -6. **分层 `uv pip install`** — 先尝试 `.[all]`,如果 `git+https` 依赖在 GitHub 限速时失败,则逐步回退到更小的集合(`[messaging,dashboard,ext]` → `[messaging]` → `.`)。防止"单次失败导致裸安装"的故障模式。 -7. **根据 `.env` 自动安装消息 SDK** — 如果存在 `TELEGRAM_BOT_TOKEN` / `DISCORD_BOT_TOKEN` / `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` / `WHATSAPP_ENABLED`,则运行 `python -m ensurepip --upgrade` 并针对性地调用 `pip install`,确保各平台 SDK 可正常导入。 -8. **设置 `HERMES_GIT_BASH_PATH`** 为解析后的 `bash.exe` 路径,使 Hermes 在新 shell 中能确定性地找到它。 -9. **将 `%LOCALAPPDATA%\hermes\bin` 添加到用户 PATH** — 打开新终端后即可使用 `hermes` 命令。 -10. **运行 `hermes setup`** — 正常的首次运行向导(模型、提供商、工具集)。使用 `-SkipSetup` 跳过。 +PM 通过 `pm/lock.json` 管理工具版本,不使用旧的 winget/分层 pip 回退。 +启动器运行工具存储中的 Python,并在导入依赖前选择完整环境。 +`hermes_cli/dep_ensure.py` 将功能需求交给 PM,不再调用 `install.ps1 -Ensure`。 :::tip 在 Windows 上跳过繁琐的提供商配置 在 Windows 上,逐个配置工具 API key(Firecrawl、FAL、Browser Use、OpenAI TTS)是获得可用 agent 摩擦最大的部分。[Nous Portal](/user-guide/features/tool-gateway) 订阅通过一次 OAuth 登录即可覆盖模型**以及**所有这些工具。安装程序完成后,运行 `hermes setup --portal` 完成配置。 @@ -84,7 +82,7 @@ iex (irm https://hermes-agent.nousresearch.com/install.ps1) ## 功能矩阵 -除 dashboard 内嵌终端面板外,所有功能均可在 Windows 上原生运行。 +Windows 支持取决于功能和架构。部分可选 SDK 不支持所有 Windows 目标。 | 功能 | 原生 Windows | WSL2 | | ------------------------------------------------------------ | ------------------- | ------------------ | @@ -96,26 +94,20 @@ iex (irm https://hermes-agent.nousresearch.com/install.ps1) | MCP 服务器(stdio 和 HTTP) | ✓ | ✓ | | 本地 Ollama / LM Studio / llama-server | ✓ | ✓(通过 WSL 网络) | | Web dashboard(会话、任务、指标、配置) | ✓ | ✓ | -| Dashboard `/chat` 内嵌终端面板 | ✗(需要 POSIX PTY) | ✓ | +| Dashboard `/chat` 内嵌终端面板 | `pywinpty`/ConPTY | POSIX PTY | | 登录时自动启动 | ✓(schtasks) | ✓(systemd) | -Dashboard 的 `/chat` 标签页通过 POSIX PTY(`ptyprocess`)内嵌了真实终端。原生 Windows 没有等效的原语;Python 的 `pywinpty` / Windows ConPTY 可以实现,但需要单独的实现——视为未来工作。**dashboard 的其余部分均可原生运行**——只有该标签页会显示"请使用 WSL2"的提示横幅。 +Dashboard 已有 Windows ConPTY 实现,依赖 `pywinpty`。SDK 缺失或损坏时终端仍可能不可用。原生 Windows ARM64 不包含 Mem0/Google Chat SDK、Faster-Whisper、openWakeWord 或 sherpa;可选择远程服务或其他引擎。 ## Hermes 在 Windows 上如何运行 shell 命令 Hermes 的终端工具通过 **Git Bash** 运行命令,与 Claude Code 采用相同策略。这在不重写每个工具的情况下绕过了 POSIX 与 Windows 的差异。 -`bash.exe` 的解析顺序: +`pm.shell()` 先读取 PM facts 中的 Git/Bash,再检查 PATH。 +当前脚本不再设置 `HERMES_GIT_BASH_PATH`。MinGit 不能替代带 Bash 的 Git for Windows。 -1. 如果设置了 `HERMES_GIT_BASH_PATH` 环境变量,优先使用。 -2. `%LOCALAPPDATA%\hermes\git\usr\bin\bash.exe`(安装程序管理的 PortableGit)。 -3. `%LOCALAPPDATA%\hermes\git\bin\bash.exe`(旧版 Git-for-Windows 布局)。 -4. 系统 Git-for-Windows 安装(`%ProgramFiles%\Git\bin\bash.exe` 等)。 -5. MSYS2、Cygwin 或 PATH 上任意 `bash.exe` 作为最后手段。 - -安装程序会显式设置 `HERMES_GIT_BASH_PATH`,使新 PowerShell 会话无需重新发现。如果你想让 Hermes 使用特定的 bash——例如系统 Git Bash 或通过符号链接的 WSL bash——可以覆盖此变量。 - -**注意事项:** MinGit 的目录布局与完整 Git-for-Windows 安装程序不同——bash 位于 `usr\bin\bash.exe`,而非 `bin\bash.exe`。Hermes 会同时检查两个路径。如果你手动解压 MinGit zip,请确保选择**非 busybox** 变体(`MinGit-*-64-bit.zip`,而非 `MinGit-*-busybox*.zip`)——busybox 构建附带的是 `ash` 而非 `bash`,且大多数 coreutils 工具缺失。 +WindowsApps 软件包中的可执行文件可能无法由包外 Python 启动,并返回 `WinError 5`。 +请使用包自己的入口,或为源码环境使用常规工具安装,不要关闭系统安全控制。 ## Windows 上的 UTF-8 控制台 @@ -200,25 +192,23 @@ hermes gateway uninstall # 移除 schtasks 条目、Startup 快捷方式、pid ## 数据布局 -| 路径 | 内容 | -| ------------------------------------- | --------------------------------------------------------------- | -| `%LOCALAPPDATA%\hermes\hermes-agent\` | Git 检出 + venv。可安全执行 `Remove-Item -Recurse` 后重新安装。 | -| `%LOCALAPPDATA%\hermes\git\` | PortableGit(仅在安装程序配置时存在)。 | -| `%LOCALAPPDATA%\hermes\node\` | 便携式 Node.js(仅在安装程序配置时存在)。 | -| `%LOCALAPPDATA%\hermes\bin\` | `hermes.cmd` 垫片,已添加到用户 PATH。 | -| `%USERPROFILE%\.hermes\` | 你的配置、认证、技能、会话、日志。**重装后保留。** | +| 路径 | 内容 | +|---|---| +| `%LOCALAPPDATA%\hermes\hermes-agent\` | 源码安装的 checkout;纯 MSIX 安装没有此目录。 | +| `%LOCALAPPDATA%\hermes\tools\` | 可写工具存储;MSIX 基础工具保留在包内。 | +| `%LOCALAPPDATA%\hermes\installs\` | 每个安装的环境选择、事务日志和 Python 代际。 | +| `%LOCALAPPDATA%\hermes\bin\` | 源码安装启动器;MSIX 使用执行别名。 | +| `%LOCALAPPDATA%\hermes\` | 用户配置、密钥、会话、插件、技能和日志。 | -这种分离是有意为之:`%LOCALAPPDATA%\hermes` 是可丢弃的基础设施(可以删除后用一行命令恢复)。`%USERPROFILE%\.hermes` 是你的数据——配置、记忆、技能、会话历史——其结构与 Linux 安装完全相同。在机器间同步它,你的 Hermes 就随之迁移。 - -**覆盖 `HERMES_HOME`:** 设置该环境变量以指向不同的数据目录。与 Linux 上的用法相同。 +这些是默认路径,`HERMES_HOME` 可以更改数据位置。 +不要删除整个 `%LOCALAPPDATA%\hermes` 来修复应用,否则会丢失共享数据。 ## 浏览器工具 -浏览器工具使用 `agent-browser`(一个 Node 辅助程序)驱动 Chromium。在 Windows 上: - -- 安装程序通过 npm 将 `agent-browser` 添加到 PATH。 -- `shutil.which("agent-browser", path=...)` 会自动找到 `.cmd` 垫片——`CreateProcessW` 无法执行无扩展名的 shebang 脚本,因此 Hermes 始终解析到 `.CMD` 包装器。不要手动调用 shebang 脚本;始终通过 `.cmd` 调用。 -- Playwright Chromium 在首次运行时自动安装(`npx playwright install chromium`)。如果安装失败,`hermes doctor` 会给出修复提示。 +内置浏览器后端使用 PM 管理的 `agent-browser` 和 Chromium。 +Browser Use 则通过 `hermes tools` 配置自己的 CLI。 +ARM64 Windows 上的 Chromium/agent-browser 可以使用 x64 模拟,这与原生 Python 不同。 +详见 [浏览器自动化](./features/browser.md)。 ## 在 Windows 上运行 Hermes — 实用说明 @@ -229,13 +219,13 @@ hermes gateway uninstall # 移除 schtasks 条目、Startup 快捷方式、pid 验证: ```powershell -Get-Command hermes # 应输出 C:\Users\\AppData\Local\hermes\bin\hermes.cmd +Get-Command hermes # 应输出 C:\Users\\AppData\Local\hermes\bin\hermes.exe hermes --version ``` ### 环境变量 -Hermes 同时支持 `$env:X`(进程作用域)和用户环境变量(永久,在系统属性 → 环境变量中设置)。将 API key 放在 `%USERPROFILE%\.hermes\.env` 中是标准做法——与 Linux 相同: +Hermes 同时支持 `$env:X`(进程作用域)和用户环境变量(永久,在系统属性 → 环境变量中设置)。将 API key 放在所选 `HERMES_HOME` 的 `.env` 中(默认 `%LOCALAPPDATA%\hermes\.env`)——与 Linux 相同: ``` OPENROUTER_API_KEY=sk-or-... @@ -250,7 +240,6 @@ TELEGRAM_BOT_TOKEN=... | 变量 | 效果 | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `HERMES_GIT_BASH_PATH` | 覆盖 bash.exe 的发现逻辑。可指向任意 bash——完整 Git-for-Windows、通过符号链接的 WSL bash、MSYS2、Cygwin。安装程序会自动设置此变量。 | | `HERMES_DISABLE_WINDOWS_UTF8` | 设为 `1` 可禁用 UTF-8 stdio 垫片,回退到区域设置代码页。用于排查编码 bug。 | | `EDITOR` / `VISUAL` | 用于 `/edit` 和 `Ctrl-X Ctrl-E` 的编辑器。如果两者均未设置,Hermes 默认使用 `notepad`。 | @@ -262,15 +251,14 @@ TELEGRAM_BOT_TOKEN=... hermes uninstall ``` -这是干净的卸载路径——移除 schtasks 条目、Startup 文件夹快捷方式、`hermes.cmd` 垫片,删除 `%LOCALAPPDATA%\hermes\hermes-agent\`,并从用户 PATH 中移除相关条目。它会保留 `%USERPROFILE%\.hermes\`(你的配置、认证、技能、会话、日志),以防你需要重新安装。 +源码安装可先用 `hermes uninstall --dry-run` 查看范围。`--full` 同时删除数据,`--data` 只删除数据。MSIX/Store 应通过 Windows 设置的“已安装的应用”移除,CLI 不删除包所有的代码。 -彻底清除所有内容: - -```powershell -hermes uninstall -Remove-Item -Recurse -Force "$env:USERPROFILE\.hermes" -Remove-Item -Recurse -Force "$env:LOCALAPPDATA\hermes" -``` +:::caution 删除用户数据 +删除前先停止使用所选 `HERMES_HOME` 的全部进程,并备份数据。 +通过 `hermes uninstall --dry-run` 检查范围,再选择数据删除模式。 +不要为了修复一个应用或 profile 而递归删除默认数据根目录。 +自定义 `HERMES_HOME` 可以位于其他位置,移除应用包也不会删除这些数据。 +::: `hermes uninstall` CLI 子命令还能处理 schtasks 条目以不同任务名注册的情况(旧版安装)——它通过安装路径而非硬编码任务名来搜索。 @@ -287,7 +275,7 @@ Remove-Item -Recurse -Force "$env:LOCALAPPDATA\hermes" ## 常见问题 **安装后立即出现 `hermes: command not found`。** -打开新的 PowerShell 窗口。安装程序已将 `%LOCALAPPDATA%\hermes\bin` 添加到用户 PATH,但现有 shell 需要重启才能获取更新。在此期间可以运行 `& "$env:LOCALAPPDATA\hermes\bin\hermes.cmd"`。 +打开新的 PowerShell 窗口。安装程序已将 `%LOCALAPPDATA%\hermes\bin` 添加到用户 PATH,但现有 shell 需要重启才能获取更新。在此期间可以运行 `& "$env:LOCALAPPDATA\hermes\bin\hermes.exe"`。 **运行工具时出现 `WinError 193: %1 is not a valid Win32 application`。** 你触发了绕过 `.cmd` 垫片的 shebang 脚本调用。Hermes 通过 `shutil.which(cmd, path=local_bin)` 解析命令,使 PATHEXT 能识别 `.CMD`——如果你通过硬编码路径调用工具,请切换到 `.cmd` 变体(例如使用 `npx.cmd` 而非 `npx`)。 @@ -302,10 +290,10 @@ Remove-Item -Recurse -Force "$env:LOCALAPPDATA\hermes" 你只在当前进程中设置了它;请关闭并重新打开 shell,或在系统属性 → 环境变量中以用户作用域设置。在新 PowerShell 窗口中用 `echo $env:EDITOR` 验证。 **浏览器工具启动了,但工具调用超时。** -Chromium 在首次运行时自动安装。如果安装失败(GitHub 限速、Playwright CDN 故障),运行 `hermes doctor`——它会检测缺失的 Chromium 并打印修复所需的确切 `npx playwright install chromium` 命令。 +运行 `hermes doctor` 和 `hermes pm doctor`,并通过 `hermes tools` 检查所选浏览器后端。不要向签名包写入另一个 Playwright 版本。 **`agent-browser` 报奇怪的 Node 版本错误。** -安装程序在 `%LOCALAPPDATA%\hermes\node` 配置了 Node 22,但你的 PATH 中可能有更靠前的旧版系统 Node 18。要么将 Hermes 的 node 目录移到 PATH 前面,要么如果你不在其他地方使用 Node,删除系统安装。 +运行 `hermes pm doctor` 并检查当前 Hermes 入口。PM 提供固定的 Node 版本,不要为了修复 Hermes 而删除其他程序使用的系统 Node。 **CLI 中中文/日文/阿拉伯文字符显示为 `?`。** UTF-8 stdio 垫片未激活。检查 `HERMES_DISABLE_WINDOWS_UTF8` 是否**未**设置(`Get-ChildItem env:HERMES_DISABLE_WINDOWS_UTF8`)。如果该变量为空但仍然看到 `?`,控制台宿主(非常旧的 `cmd.exe`)可能完全不支持 UTF-8——请切换到 Windows Terminal。 diff --git a/website/package-lock.json b/website/package-lock.json index c173631cb8..4a21e720ac 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -13,6 +13,7 @@ "@docusaurus/preset-classic": "3.10.2", "@docusaurus/theme-mermaid": "3.10.2", "@mdx-js/react": "3.1.1", + "@mermaid-js/layout-elk": "0.1.9", "clsx": "2.1.1", "prism-react-renderer": "2.3.0", "react": "19.2.7", @@ -4801,6 +4802,19 @@ "react": ">=16" } }, + "node_modules/@mermaid-js/layout-elk": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@mermaid-js/layout-elk/-/layout-elk-0.1.9.tgz", + "integrity": "sha512-HuvaqFZBr6yT9PpWYockvKAZPJVd89yn/UjOYPxhzbZxlybL2v+2BjVCg7MVH6vRs1irUohb/s42HEdec1CCZw==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "elkjs": "^0.9.3" + }, + "peerDependencies": { + "mermaid": "^11.0.2" + } + }, "node_modules/@mermaid-js/parser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", @@ -9003,6 +9017,12 @@ "integrity": "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==", "license": "ISC" }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", diff --git a/website/package.json b/website/package.json index 220b182b81..f201c77749 100644 --- a/website/package.json +++ b/website/package.json @@ -24,6 +24,7 @@ "@docusaurus/preset-classic": "3.10.2", "@docusaurus/theme-mermaid": "3.10.2", "@mdx-js/react": "3.1.1", + "@mermaid-js/layout-elk": "0.1.9", "clsx": "2.1.1", "prism-react-renderer": "2.3.0", "react": "19.2.7", diff --git a/website/sidebars.ts b/website/sidebars.ts index 1d62c9f309..8c75378934 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -823,6 +823,7 @@ const sidebars: SidebarsConfig = { label: 'Command Reference', items: [ 'reference/cli-commands', + 'reference/package-management', 'reference/slash-commands', 'reference/profile-commands', ], diff --git a/website/src/components/AutomationBlueprintsCatalog/index.tsx b/website/src/components/AutomationBlueprintsCatalog/index.tsx index 7edeca2c70..f77eaf1bc5 100644 --- a/website/src/components/AutomationBlueprintsCatalog/index.tsx +++ b/website/src/components/AutomationBlueprintsCatalog/index.tsx @@ -25,7 +25,7 @@ interface Blueprint { const INDEX_URL = "/docs/api/automation-blueprints-index.json"; -function CopyButton({ text }: { text: string }): JSX.Element { +function CopyButton({ text }: { text: string }): React.JSX.Element { const [copied, setCopied] = useState(false); return (