Compare commits

...

7 Commits

Author SHA1 Message Date
ethernet
ef1a933e29 change: add setup stamp 2026-06-04 22:25:40 -04:00
ethernet
1e5ae386ee fix: bad const 2026-06-04 22:09:54 -04:00
ethernet
77fef76924 fix(venv): .venv -> venv 2026-06-04 21:47:15 -04:00
ethernet
55879ebf1f fix(update): detached head update support 2026-06-04 21:33:34 -04:00
ethernet
692146939e fix(windows): Split hermes update into two-phase pull + post-pull with re-exec
_cmd_update_impl (monolith ~1350 lines) is gone. The update flow is now:

Phase 1 (_cmd_update_pull_new_version):
  - concurrent guard, backup, git pull / ZIP download+extract
  - stash handling, syntax guard, rollback, bytecode clear

Phase 2 (_cmd_update_post_pull):
  - pip install (managed-uv), node deps, web UI, desktop rebuild
  - skills sync, config migration, gateway restart, cleanup

Between phases, _reexec_for_post_pull replaces the process:
  - POSIX: os.execvp (true exec, same PID, fresh sys.modules)
  - Windows: subprocess.run relay (parent stays alive so bootstrap
    installer's child.wait() sees the real exit code)

Hidden --post-pull flag routes directly to phase 2.
--pre-update-snapshot carries snapshot ID across exec boundary.

Pip self-update is removed entirely. pip/uv/pipx installs now error
with recommended_update_command guidance, same as managed installs.

Also allow uv to come from termux.

Test updates:
  - _inline_post_pull autouse fixture in test_cmd_update,
    test_update_autostash, test_update_concurrent_quarantine
    (patches _reexec_for_post_pull to call _cmd_update_post_pull
    in-process, preventing os.execvp from nuking pytest)
  - test_uv_tool_update: removed _cmd_update_pip tests (function
    deleted), kept is_uv_tool_install + recommendation helpers
  - test_update_zip_symlink_reject: renamed imports to match
    _update_files_via_zip (now a thin delegation wrapper)
2026-06-04 21:16:16 -04:00
ethernet
27135e0e6a fix(update): don't try any git commands on windows if .git is missing 2026-06-04 20:23:52 -04:00
ethernet
f774e9c6f5 feat(windows installer): recover from partial clone with no origin 2026-06-04 20:23:52 -04:00
31 changed files with 2101 additions and 1894 deletions

View File

@@ -7,7 +7,7 @@ name: Docker / shell lint
#
# Rules and ignores are documented in .hadolint.yaml at the repo root.
# shellcheck severity is pinned to `error` so SC1091-style "can't follow
# sourced script" info-level warnings don't fail the job — the .venv
# sourced script" info-level warnings don't fail the job — the venv
# activate script doesn't exist at lint time.
on:

View File

@@ -112,8 +112,8 @@ jobs:
- name: Install Python dependencies (for docker tests)
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv venv venv --python 3.11
source venv/bin/activate
# ``dev`` extra pulls in pytest, pytest-asyncio, pytest-timeout —
# everything tests/docker/ needs. We deliberately avoid ``all``
# here because the docker tests only drive the container via
@@ -130,7 +130,7 @@ jobs:
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
run: |
source .venv/bin/activate
source venv/bin/activate
python -m pytest tests/docker/ -v --tb=short
- name: Log in to Docker Hub

View File

@@ -61,8 +61,8 @@ jobs:
- name: Install dependencies
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv venv venv --python 3.11
source venv/bin/activate
uv pip install -e ".[all,dev]"
- name: Run tests (slice ${{ matrix.slice }}/6)
@@ -90,7 +90,7 @@ jobs:
# estimate and get split roughly evenly by count — still correct,
# just not perfectly balanced.
run: |
source .venv/bin/activate
source venv/bin/activate
python scripts/run_tests_parallel.py --slice ${{ matrix.slice }}/6
env:
# Ensure tests don't accidentally call real APIs
@@ -167,18 +167,18 @@ jobs:
- name: Install dependencies
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv venv venv --python 3.11
source venv/bin/activate
uv pip install -e ".[all,dev]"
- name: Packaged-wheel i18n smoke test
run: |
source .venv/bin/activate
source venv/bin/activate
python -m pytest -m integration tests/test_wheel_locales_e2e.py -v
- name: Run e2e tests
run: |
source .venv/bin/activate
source venv/bin/activate
python -m pytest tests/e2e/ -v --tb=short
env:
OPENROUTER_API_KEY: ""

6
.gitignore vendored
View File

@@ -117,3 +117,9 @@ scripts/out/
# stores the published notes. They are not a build artifact and must never be
# committed to the repo root. See the hermes-release skill.
RELEASE_v*.md
# Post-pull stamp written by `hermes update`'s post-pull phase into the install
# dir (the repo root for source checkouts). Records the commit the post-pull
# steps last ran for; the per-launch gate compares it against live HEAD. Local
# runtime state, never a repo artifact.
.post_pull_stamp

137
AGENTS.md
View File

@@ -7,11 +7,11 @@ Instructions for AI coding assistants and developers working on the hermes-agent
## Development Environment
```bash
# Prefer .venv; fall back to venv if that's what your checkout has.
source .venv/bin/activate # or: source venv/bin/activate
# Prefer venv; fall back to .venv if that's what your checkout has.
source venv/bin/activate # or: source .venv/bin/activate
```
`scripts/run_tests.sh` probes `.venv` first, then `venv`, then
`scripts/run_tests.sh` probes `venv` first, then `.venv`, then
`$HOME/.hermes/hermes-agent/venv` (for worktrees that share a venv with the
main checkout).
@@ -189,23 +189,30 @@ All slash commands are defined in a central `COMMAND_REGISTRY` list of `CommandD
### Adding a Slash Command
1. Add a `CommandDef` entry to `COMMAND_REGISTRY` in `hermes_cli/commands.py`:
```python
CommandDef("mycommand", "Description of what it does", "Session",
aliases=("mc",), args_hint="[arg]"),
```
2. Add handler in `HermesCLI.process_command()` in `cli.py`:
```python
elif canonical == "mycommand":
self._handle_mycommand(cmd_original)
```
3. If the command is available in the gateway, add a handler in `gateway/run.py`:
```python
if canonical == "mycommand":
return await self._handle_mycommand(event)
```
4. For persistent settings, use `save_config_value()` in `cli.py`
**CommandDef fields:**
- `name` — canonical name without slash (e.g. `"background"`)
- `description` — human-readable description
- `category` — one of `"Session"`, `"Configuration"`, `"Tools & Skills"`, `"Info"`, `"Exit"`
@@ -240,16 +247,16 @@ Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. Se
### Key Surfaces
| Surface | Ink component | Gateway method |
|---------|---------------|----------------|
| Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit``message.delta/complete` |
| Tool activity | `thinking.tsx` | `tool.start/progress/complete` |
| Approvals | `prompts.tsx` | `approval.respond``approval.request` |
| Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` |
| Session picker | `sessionPicker.tsx` | `session.list/resume` |
| Slash commands | Local handler + fallthrough | `slash.exec``_SlashWorker`, `command.dispatch` |
| Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` with skin data |
| Surface | Ink component | Gateway method |
| ------------------- | --------------------------------- | ------------------------------------------------- |
| Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit``message.delta/complete` |
| Tool activity | `thinking.tsx` | `tool.start/progress/complete` |
| Approvals | `prompts.tsx` | `approval.respond``approval.request` |
| Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` |
| Session picker | `sessionPicker.tsx` | `session.list/resume` |
| Slash commands | Local handler + fallthrough | `slash.exec``_SlashWorker`, `command.dispatch` |
| Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` with skin data |
### Slash Command Flow
@@ -272,7 +279,7 @@ npm test # vitest
### TUI in the Dashboard (`hermes dashboard` → `/chat`)
The dashboard embeds the real `hermes --tui`**not** a rewrite. See `hermes_cli/pty_bridge.py` + the `@app.websocket("/api/pty")` endpoint in `hermes_cli/web_server.py`.
The dashboard embeds the real `hermes --tui`**not** a rewrite. See `hermes_cli/pty_bridge.py` + the `@app.websocket("/api/pty")` endpoint in `hermes_cli/web_server.py`.
- Browser loads `web/src/pages/ChatPage.tsx`, which mounts xterm.js's `Terminal` with the WebGL renderer, `@xterm/addon-fit` for container-driven resize, and `@xterm/addon-unicode11` for modern wide-character widths.
- `/api/pty?token=…` upgrades to a WebSocket; auth uses the same ephemeral `_SESSION_TOKEN` as REST, via query param (browsers can't set `Authorization` on WS upgrade).
@@ -314,6 +321,7 @@ core Hermes tool that should ship in the base system.
Built-in/core tools require changes in **2 files**:
**1. Create `tools/your_tool.py`:**
```python
import json, os
from tools.registry import registry
@@ -334,7 +342,7 @@ registry.register(
)
```
**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. **This step is required:** auto-discovery imports the tool and registers its schema, but the tool is only *exposed to an agent* if its name appears in a toolset. `_HERMES_CORE_TOOLS` is not dead code — it's the default bundle every platform's base toolset inherits from.
**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. **This step is required:** auto-discovery imports the tool and registers its schema, but the tool is only _exposed to an agent_ if its name appears in a toolset. `_HERMES_CORE_TOOLS` is not dead code — it's the default bundle every platform's base toolset inherits from.
Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain. Wiring into a toolset is still a deliberate, manual step.
@@ -354,14 +362,15 @@ All dependencies must have upper bounds to limit supply-chain attack surface.
This policy was established after the litellm compromise (PR #2796, #2810) and
reinforced after the Mini Shai-Hulud worm campaign (May 2026).
| Source type | Treatment | Example |
|---|---|---|
| PyPI package | `>=floor,<next_major` | `"httpx>=0.28.1,<1"` |
| Git URL | Commit SHA | `git+https://...@<40-char-sha>` |
| GitHub Actions | Commit SHA + comment | `uses: actions/checkout@<sha> # v4` |
| CI-only pip | `==exact` | `pyyaml==6.0.2` |
| Source type | Treatment | Example |
| -------------- | --------------------- | ------------------------------------ |
| PyPI package | `>=floor,<next_major` | `"httpx>=0.28.1,<1"` |
| Git URL | Commit SHA | `git+https://...@<40-char-sha>` |
| GitHub Actions | Commit SHA + comment | `uses: actions/checkout@<sha> # v4` |
| CI-only pip | `==exact` | `pyyaml==6.0.2` |
**When adding a new dependency to `pyproject.toml`:**
1. Pin to `>=current_version,<next_major` for post-1.0 (e.g. `>=1.5.0,<2`).
2. For pre-1.0 packages, use `<0.(current_minor + 2)` (e.g. `>=0.29,<0.32`).
3. Never commit a bare `>=X.Y.Z` without a ceiling — CI and reviewers will reject it.
@@ -374,6 +383,7 @@ Reference: #2810 (bounds pass), #9801 (SHA pinning + audit CI).
## Adding Configuration
### config.yaml options:
1. Add to `DEFAULT_CONFIG` in `hermes_cli/config.py`
2. Bump `_config_version` (check the current value at the top of `DEFAULT_CONFIG`)
ONLY if you need to actively migrate/transform existing user config
@@ -398,7 +408,9 @@ its own provider/model/base_url/max_tokens/reasoning_effort. See
`archive_after_days`, `backup` (nested).
### .env variables (SECRETS ONLY — API keys, tokens, passwords):
1. Add to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` with metadata:
```python
"NEW_API_KEY": {
"description": "What it's for",
@@ -416,16 +428,17 @@ the env var in code (see `gateway_timeout`, `terminal.cwd` → `TERMINAL_CWD`).
### Config loaders (three paths — know which one you're in):
| Loader | Used by | Location |
|--------|---------|----------|
| `load_cli_config()` | CLI mode | `cli.py` — merges CLI-specific defaults + user YAML |
| `load_config()` | `hermes tools`, `hermes setup`, most CLI subcommands | `hermes_cli/config.py` — merges `DEFAULT_CONFIG` + user YAML |
| Direct YAML load | Gateway runtime | `gateway/run.py` + `gateway/config.py` — reads user YAML raw |
| Loader | Used by | Location |
| ------------------- | ---------------------------------------------------- | ------------------------------------------------------------ |
| `load_cli_config()` | CLI mode | `cli.py` — merges CLI-specific defaults + user YAML |
| `load_config()` | `hermes tools`, `hermes setup`, most CLI subcommands | `hermes_cli/config.py` — merges `DEFAULT_CONFIG` + user YAML |
| Direct YAML load | Gateway runtime | `gateway/run.py` + `gateway/config.py` — reads user YAML raw |
If you add a new key and the CLI sees it but the gateway doesn't (or vice
versa), you're on the wrong loader. Check `DEFAULT_CONFIG` coverage.
### Working directory:
- **CLI** — uses the process's current directory (`os.getcwd()`).
- **Messaging** — uses `terminal.cwd` from `config.yaml`. The gateway bridges this
to the `TERMINAL_CWD` env var for child tools. **`MESSAGING_CWD` has been
@@ -454,24 +467,24 @@ hermes_cli/skin_engine.py # SkinConfig dataclass, built-in skins, YAML loader
### What skins customize
| Element | Skin Key | Used By |
|---------|----------|---------|
| Banner panel border | `colors.banner_border` | `banner.py` |
| Banner panel title | `colors.banner_title` | `banner.py` |
| Banner section headers | `colors.banner_accent` | `banner.py` |
| Banner dim text | `colors.banner_dim` | `banner.py` |
| Banner body text | `colors.banner_text` | `banner.py` |
| Response box border | `colors.response_border` | `cli.py` |
| Spinner faces (waiting) | `spinner.waiting_faces` | `display.py` |
| Spinner faces (thinking) | `spinner.thinking_faces` | `display.py` |
| Spinner verbs | `spinner.thinking_verbs` | `display.py` |
| Spinner wings (optional) | `spinner.wings` | `display.py` |
| Tool output prefix | `tool_prefix` | `display.py` |
| Per-tool emojis | `tool_emojis` | `display.py``get_tool_emoji()` |
| Agent name | `branding.agent_name` | `banner.py`, `cli.py` |
| Welcome message | `branding.welcome` | `cli.py` |
| Response box label | `branding.response_label` | `cli.py` |
| Prompt symbol | `branding.prompt_symbol` | `cli.py` |
| Element | Skin Key | Used By |
| ------------------------ | ------------------------- | --------------------------------- |
| Banner panel border | `colors.banner_border` | `banner.py` |
| Banner panel title | `colors.banner_title` | `banner.py` |
| Banner section headers | `colors.banner_accent` | `banner.py` |
| Banner dim text | `colors.banner_dim` | `banner.py` |
| Banner body text | `colors.banner_text` | `banner.py` |
| Response box border | `colors.response_border` | `cli.py` |
| Spinner faces (waiting) | `spinner.waiting_faces` | `display.py` |
| Spinner faces (thinking) | `spinner.thinking_faces` | `display.py` |
| Spinner verbs | `spinner.thinking_verbs` | `display.py` |
| Spinner wings (optional) | `spinner.wings` | `display.py` |
| Tool output prefix | `tool_prefix` | `display.py` |
| Per-tool emojis | `tool_emojis` | `display.py``get_tool_emoji()` |
| Agent name | `branding.agent_name` | `banner.py`, `cli.py` |
| Welcome message | `branding.welcome` | `cli.py` |
| Response box label | `branding.response_label` | `cli.py` |
| Prompt symbol | `branding.prompt_symbol` | `cli.py` |
### Built-in skins
@@ -596,6 +609,7 @@ discovery system** — scanned on first `get_provider_profile()` or
`list_providers()` call, NOT by the general PluginManager.
Scan order:
1. Bundled: `<repo>/plugins/model-providers/<name>/`
2. User: `$HERMES_HOME/plugins/model-providers/<name>/`
3. Legacy: `<repo>/providers/<name>.py` (back-compat)
@@ -665,6 +679,7 @@ violate them.
the implementation. No marketing words ("powerful",
"comprehensive", "seamless", "advanced"). Don't repeat the skill
name. Verify with:
```python
import re, pathlib
m = re.search(r'^description: (.*)$',
@@ -804,6 +819,7 @@ go to `~/.hermes/skills/.archive/` and are restorable.
archived), `pinned`.
Invariants:
- Curator only touches skills with `created_by: "agent"` provenance —
bundled + hub-installed skills are off-limits.
- Never deletes; max destructive action is archive.
@@ -829,6 +845,7 @@ schedule jobs via the `cronjob` tool; users via `hermes cron <verb>`
`/cron` slash command.
Supported schedule formats:
- Duration: `"30m"`, `"2h"`, `"1d"`
- "every" phrase: `"every 2h"`, `"every monday 9am"`
- 5-field cron expression: `"0 9 * * *"`
@@ -842,6 +859,7 @@ job B's prompt), `workdir` (run in a specific directory with its
`AGENTS.md`/`CLAUDE.md` loaded), and multi-platform delivery.
Hardening invariants:
- **3-minute hard interrupt** on cron sessions — runaway agent loops
cannot monopolize the scheduler.
- Catchup window: half the job's period, clamped to 120s2h.
@@ -884,10 +902,11 @@ kanban task.
standalone dispatcher deployment).
Isolation model:
- **Board** is the hard boundary — workers are spawned with
`HERMES_KANBAN_BOARD` pinned in their env so they can't see other
boards.
- **Tenant** is a soft namespace *within* a board — one specialist
- **Tenant** is a soft namespace _within_ a board — one specialist
fleet can serve multiple businesses with workspace-path + memory-key
isolation.
- After `kanban.failure_limit` consecutive non-success attempts on the
@@ -903,6 +922,7 @@ Full user-facing docs: `website/docs/user-guide/features/kanban.md`.
### Prompt Caching Must Not Break
Hermes-Agent ensures caching remains valid throughout a conversation. **Do NOT implement changes that would:**
- Alter past context mid-conversation
- Change toolsets mid-conversation
- Reload memories or rebuild system prompts mid-conversation
@@ -941,6 +961,7 @@ automatically scope to the active profile.
1. **Use `get_hermes_home()` for all HERMES_HOME paths.** Import from `hermes_constants`.
NEVER hardcode `~/.hermes` or `Path.home() / ".hermes"` in code that reads/writes state.
```python
# GOOD
from hermes_constants import get_hermes_home
@@ -952,6 +973,7 @@ automatically scope to the active profile.
2. **Use `display_hermes_home()` for user-facing messages.** Import from `hermes_constants`.
This returns `~/.hermes` for default or `~/.hermes/profiles/<name>` for profiles.
```python
# GOOD
from hermes_constants import display_hermes_home
@@ -967,6 +989,7 @@ automatically scope to the active profile.
4. **Tests that mock `Path.home()` must also set `HERMES_HOME`** — since code now uses
`get_hermes_home()` (reads env var), not `Path.home() / ".hermes"`:
```python
with patch.object(Path, "home", return_value=tmp_path), \
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
@@ -987,11 +1010,13 @@ automatically scope to the active profile.
## Known Pitfalls
### DO NOT hardcode `~/.hermes` paths
Use `get_hermes_home()` from `hermes_constants` for code paths. Use `display_hermes_home()`
for user-facing print/log messages. Hardcoding `~/.hermes` breaks profiles — each profile
has its own `HERMES_HOME` directory. This was the source of 5 bugs fixed in PR #3575.
### DO NOT introduce new `simple_term_menu` usage
Existing call sites in `hermes_cli/main.py` remain for legacy fallback only;
the preferred UI is curses (stdlib) because `simple_term_menu` has
ghost-duplication rendering bugs in tmux/iTerm2 with arrow keys. New
@@ -999,15 +1024,19 @@ interactive menus must use `hermes_cli/curses_ui.py` — see
`hermes_cli/tools_config.py` for the canonical pattern.
### DO NOT use `\033[K` (ANSI erase-to-EOL) in spinner/display code
Leaks as literal `?[K` text under `prompt_toolkit`'s `patch_stdout`. Use space-padding: `f"\r{line}{' ' * pad}"`.
### `_last_resolved_tool_names` is a process-global in `model_tools.py`
`_run_single_child()` in `delegate_tool.py` saves and restores this global around subagent execution. If you add new code that reads this global, be aware it may be temporarily stale during child agent runs.
### DO NOT hardcode cross-tool references in schema descriptions
Tool schema descriptions must not mention tools from other toolsets by name (e.g., `browser_navigate` saying "prefer web_search"). Those tools may be unavailable (missing API keys, disabled toolset), causing the model to hallucinate calls to non-existent tools. If a cross-reference is needed, add it dynamically in `get_tool_definitions()` in `model_tools.py` — see the `browser_navigate` / `execute_code` post-processing blocks for the pattern.
### The gateway has TWO message guards — both must bypass approval/control commands
When an agent is running, messages pass through two sequential guards:
(1) **base adapter** (`gateway/platforms/base.py`) queues messages in
`_pending_messages` when `session_key in self._active_sessions`, and
@@ -1019,6 +1048,7 @@ guards and be dispatched inline, not via `_process_message_background()`
(which races session lifecycle).
### Squash merges from stale branches silently revert recent fixes
Before squash-merging a PR, ensure the branch is up to date with `main`
(`git fetch origin main && git reset --hard origin/main` in the worktree,
then re-apply the PR's commits). A stale branch's version of an unrelated
@@ -1027,16 +1057,19 @@ with `git diff HEAD~1..HEAD` after merging — unexpected deletions are a
red flag.
### Don't wire in dead code without E2E validation
Unused code that was never shipped was dead for a reason. Before wiring an
unused module into a live code path, E2E test the real resolution chain
with actual imports (not mocks) against a temp `HERMES_HOME`.
### Tests must not write to `~/.hermes/`
The `_isolate_hermes_home` autouse fixture in `tests/conftest.py` redirects `HERMES_HOME` to a temp dir. Never hardcode `~/.hermes/` paths in tests.
**Profile tests**: When testing profile features, also mock `Path.home()` so that
`_get_profiles_root()` and `_get_default_hermes_home()` resolve within the temp dir.
Use the pattern from `tests/hermes_cli/test_profiles.py`:
```python
@pytest.fixture
def profile_env(tmp_path, monkeypatch):
@@ -1090,13 +1123,13 @@ Implementation notes:
Five real sources of local-vs-CI drift the script closes:
| | Without wrapper | With wrapper |
|---|---|---|
| Provider API keys | Whatever is in your env (auto-detects pool) | All `*_API_KEY`/`*_TOKEN`/etc. unset |
| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
| Timezone | Local TZ (PDT etc.) | UTC |
| Locale | Whatever is set | C.UTF-8 |
| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) |
| | Without wrapper | With wrapper |
| ------------------- | ------------------------------------------- | -------------------------------------------------------------------- |
| Provider API keys | Whatever is in your env (auto-detects pool) | All `*_API_KEY`/`*_TOKEN`/etc. unset |
| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
| Timezone | Local TZ (PDT etc.) | UTC |
| Locale | Whatever is set | C.UTF-8 |
| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) |
`tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest
invocation (including IDE integrations) gets hermetic behavior — but the wrapper

View File

@@ -158,7 +158,7 @@ RUN npm install --prefer-offline --no-audit && \
# lazy-install access to PyPI (often blocked in containerized envs).
#
# The hindsight memory provider's client (hindsight-client) is baked in
# for the same reason: it lazy-installs into /opt/hermes/.venv at first
# for the same reason: it lazy-installs into /opt/hermes/venv at first
# use, which lives inside the (immutable) image layer rather than the
# mounted /opt/data volume, so it is lost on every container recreate /
# image update and recall/retain then fails with
@@ -188,13 +188,13 @@ RUN cd web && npm run build && \
# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and
# gateway state artifacts beneath the package after services drop privileges,
# especially when the hermes UID is remapped at boot (#27221).
# The .venv MUST remain hermes-writable so lazy_deps.py can install
# The venv MUST remain hermes-writable so lazy_deps.py can install
# remaining optional platform packages and future pin bumps at first use.
# Without this, `uv pip install` fails with EACCES and adapters silently
# fail to load. See tools/lazy_deps.py.
USER root
RUN chmod -R a+rX /opt/hermes && \
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules
chown -R hermes:hermes /opt/hermes/venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
# the data volume. Each supervised service then drops to the hermes user via
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
@@ -285,7 +285,7 @@ ENV HERMES_HOME=/opt/data
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
# Pre-s6 entrypoint.sh did `source venv/bin/activate` which exported
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
# same for the container's main process, but `docker exec` and our
# cont-init.d scripts don't pass through the wrapper. Expose the venv
@@ -296,7 +296,7 @@ COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
# shim wins PATH resolution. The shim's last act is to exec the venv
# binary by absolute path, so this PATH ordering is transparent to
# every other consumer.
ENV PATH="/opt/hermes/bin:/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}"
ENV PATH="/opt/hermes/bin:/opt/hermes/venv/bin:/opt/data/.local/bin:${PATH}"
RUN mkdir -p /opt/data
VOLUME [ "/opt/data" ]

315
PLAN.md Normal file
View File

@@ -0,0 +1,315 @@
# Plan: Split `_cmd_update_impl` into pull + post-pull phases
## Problem
After `git pull` (or ZIP extraction) overwrites source files on disk, the
running Python process still has the **old** code in `sys.modules` and
`__pycache__`. The current monolith runs post-pull steps (pip install, node
deps, skills sync, config migration, gateway restart) under stale bytecode,
which causes `ImportError` on gateway restart and subtle drift bugs.
## Solution: Two-phase update with re-exec
Split `_cmd_update_impl` into:
1. **`_cmd_update_pull_new_version`** — download new code onto disk
2. **Re-exec** into a fresh Python process
3. **`_cmd_update_post_pull`** — run all post-pull steps under the new code
The re-exec guarantees a clean `sys.modules` / `__pycache__` for phase 2.
---
## Phase 1: `_cmd_update_pull_new_version(args, gateway_mode)`
Everything up to and including "code is on disk, stash is restored":
1. Windows concurrent-hermes guard (exit 2 if locked)
2. Pre-update backup (`_run_pre_update_backup`)
3. Route detection:
- No `.git` + Windows → `_update_files_via_zip` (download + extract only)
- No `.git` + non-Windows + pip → **error** (pip self-update ripped out)
- No `.git` + non-Windows → reinstall hint + exit 1
- `.git` exists → git flow (below)
4. Git config setup (autocrlf, appendAtomically)
5. Discard lockfile churn
6. Fork detection + origin URL check
7. Fetch + branch logic + stash
8. Check for new commits (if 0, restore + "already up to date" + return)
9. Pre-update snapshot (`create_quick_snapshot`)
10. `git pull --ff-only` (or `reset --hard` on divergence)
11. Post-pull syntax guard (`_validate_critical_files_syntax`) + rollback on failure
12. Restore stashed changes
13. `_invalidate_update_cache()`
14. `_clear_bytecode_cache()`
At this point new code is on disk and the working tree is clean.
**Return `True`** to signal "re-exec needed".
### ZIP path change
`_update_files_via_zip` currently does the full pipeline (download, extract,
clear bytecode, pip install, node deps, skills sync, curator notices, kill
dashboard). We split it:
- **`_update_files_via_zip`** → rename to `_download_and_extract_zip(args)`:
downloads + extracts + clears bytecode. Returns `True` when new code landed.
- The pip install / node deps / skills sync / etc moves to
`_cmd_update_post_pull` (shared by both git and zip).
### Already-up-to-date fast path
If git reports 0 new commits (or zip extraction shows no changes), we return
`False` from pull — no re-exec needed. The caller prints "already up to date"
and exits cleanly.
---
## Re-exec mechanism
After `_cmd_update_pull_new_version` returns `True`:
```python
def _reexec_for_post_pull(args, gateway_mode: bool):
"""Replace this process with a fresh Python running --post-pull."""
argv = list(sys.argv)
# Insert --post-pull before any positional args (there shouldn't be any)
argv.append("--post-pull")
if gateway_mode:
argv.append("--gateway")
# Pass pre-update snapshot ID if we have one
snapshot_id = getattr(args, "_pre_update_snapshot_id", None)
if snapshot_id:
argv.extend(["--pre-update-snapshot", snapshot_id])
if sys.platform == "win32":
# Windows: os.execvp internally does spawn+exit (PID changes).
# The bootstrap installer watches the original PID via run_streamed().
# If we execvp, the installer sees exit-0 before post-pull finishes.
# Instead: relay through a subprocess so the parent PID stays alive
# until the child completes. The installer's child.wait() sees the
# real exit code.
result = subprocess.run([sys.executable, "-m", "hermes_cli.main"] + argv)
sys.exit(result.returncode)
else:
# POSIX: true exec — same PID, no stale modules.
os.execvp(sys.executable, [sys.executable, "-m", "hermes_cli.main"] + argv)
```
### Why subprocess.run on Windows (not Popen+exit)
`Popen` + `sys.exit` has a race: if the parent exits before the child's
stdout pipe is drained, the bootstrap installer's `BufReader` gets a broken
pipe and the child may get SIGPIPE. `subprocess.run` waits for the child to
finish and properly reaps it, then we forward the exit code. This is
functionally identical to what the bootstrap installer already does when it
spawns `hermes update` directly.
### Gateway output file continuity
In gateway mode, `hermes update --gateway` writes to an output file that the
gateway watches. On POSIX, `os.execvp` inherits FDs so the output file
continues being written. On Windows, the relay subprocess inherits the
same FDs (subprocess.run passes them through). The `--gateway` flag is
passed to the child so `_install_hangup_protection` / `_finalize_update_output`
work correctly.
**However**, the gateway's spawn path (gateway/run.py) launches the update
detached with its own output redirection. The output file is opened by the
*child*, not inherited from the parent. So on the re-exec path the child
re-opens the same output path via `_install_hangup_protection` (which
already sets up the update.log mirror). No special handling needed.
### Bootstrap installer interaction
The Windows bootstrap installer (`update.rs`) calls `hermes update --yes
--gateway` via `run_streamed()`, which:
1. Spawns the hermes binary as a child process
2. Reads stdout/stderr line-by-line
3. Waits for exit via `child.wait().await`
4. Checks exit code (0 = success, 2 = concurrent lock)
Our Windows relay pattern is safe because:
- The **parent** (original `hermes update` PID) stays alive until the
**child** (post-pull phase) exits
- `child.wait()` in the installer sees the parent exit only after the
child finishes → correct exit code propagation
- The parent's stdout/stderr are piped to the installer's `BufReader`;
the child inherits those FDs, so output streams continue seamlessly
---
## Phase 2: `_cmd_update_post_pull(args, gateway_mode)`
All post-pull steps, running under freshly-exec'd Python with clean
`sys.modules`. Steps are identical regardless of whether code arrived via
git or zip:
1. `_refresh_active_lazy_features()`
2. Python dependency install (uv/pip — the entire `ensure_uv` +
`_install_python_dependencies_with_optional_fallback` block)
3. `_update_node_dependencies()`
4. `_build_web_ui`
5. Desktop app rebuild check + build
6. Skills sync (`sync_skills` + profile seed)
7. Honcho profile sync
8. Config migration (missing env, missing config, version bump, interactive prompts)
9. Cron jobs safety-net restore
10. Curator notices (first-run + recent-run)
11. FHS PATH guard
12. cua-driver refresh (macOS)
13. Gateway restart (systemd, launchd, manual processes, survivor sweep)
14. Legacy unit warning
15. Kill stale dashboard processes
16. Print "Update complete!" + tip
---
## Argparse changes
Add to `update_parser`:
```python
update_parser.add_argument(
"--post-pull",
action="store_true",
default=False,
help=argparse.SUPPRESS, # internal flag — not for users
)
update_parser.add_argument(
"--pre-update-snapshot",
default=None,
metavar="ID",
help=argparse.SUPPRESS, # carries snapshot ID across re-exec
)
```
## `cmd_update` dispatch changes
```python
def cmd_update(args):
# ... existing managed/docker/check guards ...
_update_io_state = _install_hangup_protection(gateway_mode=gateway_mode)
try:
if getattr(args, "post_pull", False):
_cmd_update_post_pull(args, gateway_mode=gateway_mode)
else:
needs_reexec = _cmd_update_pull_new_version(args, gateway_mode=gateway_mode)
if needs_reexec:
_reexec_for_post_pull(args, gateway_mode=gateway_mode)
# if not needs_reexec, we're already done (already up to date)
finally:
_finalize_update_output(_update_io_state)
```
**Important**: `_finalize_update_output` runs in the parent on the
non-reexec path. On the re-exec path, it runs in the parent *before*
the exec replaces the process. On Windows relay, it runs before
`subprocess.run`. The post-pull child has its own
`_install_hangup_protection` / `_finalize_update_output` cycle because
`cmd_update` is re-entered via the fresh process.
Wait — actually on the re-exec path the parent process is *replaced* (POSIX)
or *exits* (Windows relay). So `_finalize_update_output` would run in the
`finally` block *after* `_reexec_for_post_pull` returns. But on POSIX,
`os.execvp` never returns (or raises on failure). On Windows,
`sys.exit(result.returncode)` never returns. So the `finally` block only
runs if `_reexec_for_post_pull` raises (e.g. execvp fails). That's the
right behavior — cleanup only on failure.
On the post-pull path, `cmd_update` is called fresh in the new process,
so `_install_hangup_protection` + `_finalize_update_output` wrap the
post-pull phase correctly.
---
## Pip install path: RIP
`_cmd_update_pip` is removed. `cmd_update` already checks for
`is_managed()` and `detect_install_method() == "docker"` before entering
the impl. We add pip to the early-exit guards:
```python
def cmd_update(args):
# ... existing is_managed() check ...
if detect_install_method(PROJECT_ROOT) == "docker":
print(format_docker_update_message())
sys.exit(1)
if detect_install_method(PROJECT_ROOT) == "pip":
from hermes_cli.config import recommended_update_command
print("✗ Self-update is not supported for pip/uv-tool installs.")
print(f" Run '{recommended_update_command()}' instead.")
sys.exit(1)
# ... --check and --post-pull dispatch ...
```
Also update `_cmd_update_check` to keep its existing pip check path
(it just checks PyPI — no code mutation, no re-exec needed).
---
## Cross-phase state
| State | Mechanism |
|-------|-----------|
| `gateway_mode` | Passed via `--gateway` flag (already exists) |
| `assume_yes` | Passed via `--yes` flag (already exists) |
| `pre_update_snapshot_id` | New `--pre-update-snapshot` arg |
| `auto_stash_ref` | NOT needed — stash is restored in phase 1 |
| `branch` | Passed via `--branch` (already exists) |
| `force` | Passed via `--force` (already exists) |
| `backup` / `no_backup` | Passed via `--backup` / `--no-backup` (already exists) |
The only new cross-phase arg is `--pre-update-snapshot`, because the
snapshot is created in phase 1 but consumed in phase 2 (cron safety net).
---
## `_update_files_via_zip` refactor
Rename current function to `_download_and_extract_zip(args) -> bool`:
- Downloads ZIP, extracts, clears bytecode
- Returns `True` if new code was written
- Does NOT do pip install, node deps, skills sync, etc.
The old post-extraction code moves to `_cmd_update_post_pull`.
---
## Test impact
| Test file | Change needed |
|-----------|--------------|
| `test_cmd_update.py` | Update mocks for two-phase flow; test `--post-pull` dispatch |
| `test_cmd_update_docker.py` | Minimal — docker bailout is unchanged |
| `test_update_autostash.py` | Stash logic stays in phase 1 — should work as-is |
| `test_update_concurrent_quarantine.py` | Concurrent check stays in phase 1 — should work as-is |
| `test_managed_installs.py` | Add pip install error test (was previously allowed) |
| `test_uv_tool_update.py` | Remove — pip/uv-tool self-update is now an error |
---
## Execution order summary
```
hermes update
└─ cmd_update()
├─ is_managed? → error
├─ docker? → error + hint
├─ pip? → error + hint (NEW — was _cmd_update_pip)
├─ --check? → _cmd_update_check() (unchanged)
├─ --post-pull? → _cmd_update_post_pull() (phase 2)
└─ else → _cmd_update_pull_new_version() (phase 1)
├─ download code (git pull or zip)
├─ validate + stash restore + clear bytecode
└─ if new code: _reexec_for_post_pull()
├─ POSIX: os.execvp → same PID, fresh Python
└─ Windows: subprocess.run + sys.exit → relay
```

View File

@@ -35,7 +35,7 @@ use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
/// `hermes update` exit code meaning "another hermes process is holding the
/// venv shim open / dirty precondition" — see _cmd_update_impl in
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
/// hermes_cli/update.py (sys.exit(2)). We surface a targeted message for this.
const UPDATE_EXIT_CONCURRENT: i32 = 2;
/// How long to wait for the old desktop process to release the venv shim

View File

@@ -43,5 +43,5 @@ if [ -d /run/service/.s6-svscan ]; then
fi
# Skip the drop when already non-root.
[ "$(id -u)" = 0 ] || exec /opt/hermes/.venv/bin/python -m hermes_cli.container_boot
exec s6-setuidgid hermes /opt/hermes/.venv/bin/python -m hermes_cli.container_boot
[ "$(id -u)" = 0 ] || exec /opt/hermes/venv/bin/python -m hermes_cli.container_boot
exec s6-setuidgid hermes /opt/hermes/venv/bin/python -m hermes_cli.container_boot

View File

@@ -30,7 +30,7 @@
# other path.
#
# Recursion safety: the shim exec's the venv binary by *absolute path*
# (/opt/hermes/.venv/bin/hermes), so the second hop cannot re-enter this
# (/opt/hermes/venv/bin/hermes), so the second hop cannot re-enter this
# shim regardless of PATH state. No sentinel env var needed.
#
# Opt-out: set HERMES_DOCKER_EXEC_AS_ROOT=1 (1/true/yes, case-insensitive)
@@ -40,7 +40,7 @@
set -e
REAL=/opt/hermes/.venv/bin/hermes
REAL=/opt/hermes/venv/bin/hermes
# Defensive: if the venv binary is missing (corrupted image, partial
# install), fail loudly rather than silently masking it.

View File

@@ -62,7 +62,7 @@ _hermes_orig_cwd="${HERMES_ORIG_CWD:-$PWD}"
cd /opt/data
# shellcheck disable=SC1091
. /opt/hermes/.venv/bin/activate
. /opt/hermes/venv/bin/activate
# Restore the original working directory before handing off to
# the user's command so `hermes chat` starts in the Docker -w

View File

@@ -25,7 +25,7 @@ export HOME=/opt/data
cd /opt/data
# shellcheck disable=SC1091
. /opt/hermes/.venv/bin/activate
. /opt/hermes/venv/bin/activate
dash_host="${HERMES_DASHBOARD_HOST:-0.0.0.0}"
dash_port="${HERMES_DASHBOARD_PORT:-9119}"

View File

@@ -31,7 +31,7 @@ as_hermes() { [ "$(id -u)" = 0 ] || { "$@"; return; }; s6-setuidgid hermes "$@";
# Under s6-overlay this no longer works: the bootstrap (UID remap, volume +
# build-tree chown, config seeding) all require root, and they're skipped when
# the container starts non-root. The baked image trees (/opt/data, /opt/hermes/
# .venv, ui-tui, node_modules) stay owned by the hermes build UID (10000), so an
# venv, ui-tui, node_modules) stay owned by the hermes build UID (10000), so an
# arbitrary `--user` UID can't write them — the runtime then fails with EACCES
# on a bind mount, or hard-crashes on a named volume (Docker initialises the
# volume from the image as UID 10000, and the non-root start can't even `cd`
@@ -210,7 +210,7 @@ fi
# --- Fix ownership of build trees under $INSTALL_DIR ---
# Hermes-owned trees under $INSTALL_DIR must be re-chowned whenever the
# runtime hermes UID no longer owns them — otherwise:
# - .venv: lazy_deps.py cannot install platform packages (discord.py,
# - venv: lazy_deps.py cannot install platform packages (discord.py,
# telegram, slack, etc.) with EACCES (#15012, #21100)
# - ui-tui: esbuild rebuilds dist/entry.js on every TUI launch (when
# the source mtime is newer than dist/ or when HERMES_TUI_FORCE_BUILD
@@ -239,11 +239,11 @@ fi
# when the venv is not already owned by the runtime hermes UID. Idempotent
# and skips the expensive recursive chown on every restart once ownership
# is settled.
venv_owner=$(stat -c %u "$INSTALL_DIR/.venv" 2>/dev/null || echo "")
venv_owner=$(stat -c %u "$INSTALL_DIR/venv" 2>/dev/null || echo "")
if [ -n "$venv_owner" ] && [ "$venv_owner" != "$actual_hermes_uid" ]; then
echo "[stage2] Fixing ownership of build trees under $INSTALL_DIR to hermes ($actual_hermes_uid)"
chown -R hermes:hermes \
"$INSTALL_DIR/.venv" \
"$INSTALL_DIR/venv" \
"$INSTALL_DIR/ui-tui" \
"$INSTALL_DIR/gateway" \
"$INSTALL_DIR/node_modules" \
@@ -353,7 +353,7 @@ fi
# after first-boot seeding and before supervised gateway services start.
# Set HERMES_SKIP_CONFIG_MIGRATION=1 for controlled/manual migrations.
if [ -f "$HERMES_HOME/config.yaml" ]; then
s6-setuidgid hermes "$INSTALL_DIR/.venv/bin/python" "$INSTALL_DIR/scripts/docker_config_migrate.py" \
s6-setuidgid hermes "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/scripts/docker_config_migrate.py" \
|| echo "[stage2] Warning: docker_config_migrate.py failed; continuing"
fi
@@ -403,9 +403,9 @@ fi
# wrapper to source the activate script. This is safe because
# skills_sync.py doesn't depend on any environment exports beyond what
# the python binary's own bin-stub already sets up (sys.path is rooted
# at the venv's site-packages by virtue of running .venv/bin/python).
# at the venv's site-packages by virtue of running venv/bin/python).
if [ -d "$INSTALL_DIR/skills" ]; then
as_hermes "$INSTALL_DIR/.venv/bin/python" "$INSTALL_DIR/tools/skills_sync.py" \
as_hermes "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/tools/skills_sync.py" \
|| echo "[stage2] Warning: skills_sync.py failed; continuing"
fi

File diff suppressed because it is too large Load Diff

View File

@@ -6,11 +6,6 @@ If the binary is missing, ``ensure_uv()`` bootstraps it via the official
standalone installer with ``UV_UNMANAGED_INSTALL`` / ``UV_INSTALL_DIR`` pointed
at ``$HERMES_HOME/bin`` so the installer writes directly there — no PATH
probing, no conda guards, no multi-location resolution chains.
When ``ensure_uv()`` bootstraps uv for the first time (i.e. there was no
managed uv before), it returns ``(path, True)`` instead of just ``path``.
Callers in the update path use that signal to nuke and recreate the venv
with the now-current managed uv, guaranteeing a Python with FTS5.
"""
from __future__ import annotations
@@ -18,11 +13,12 @@ from __future__ import annotations
import logging
import os
import platform
import shutil
import subprocess
import sys
import tempfile
import shutil
from pathlib import Path
from typing import Optional, Tuple
from typing import Optional
from hermes_constants import get_hermes_home
@@ -53,23 +49,20 @@ def resolve_uv() -> Optional[str]:
p = managed_uv_path()
if p.is_file() and os.access(p, os.X_OK):
return str(p)
if _is_termux_env():
return shutil.which("uv")
return None
def ensure_uv() -> Tuple[Optional[str], bool]:
def ensure_uv() -> Optional[str]:
"""Return the managed uv path, installing it first if necessary.
Returns ``(path, freshly_bootstrapped)`` where *freshly_bootstrapped* is
``True`` when we just installed managed uv for the first time (there was
no managed uv before this call). Callers can use that signal to rebuild
the venv so Python is guaranteed to have FTS5.
On failure returns ``(None, False)`` (never raises) so callers can fall
back to pip gracefully.
On failure returns ``None`` (never raises) so callers can fall back to
pip gracefully.
"""
existing = resolve_uv()
if existing:
return (existing, False)
return existing
target = managed_uv_path()
target.parent.mkdir(parents=True, exist_ok=True)
@@ -81,7 +74,7 @@ def ensure_uv() -> Tuple[Optional[str], bool]:
except Exception as exc:
logger.warning("Managed uv install failed: %s", exc)
print(f" ✗ Failed to install managed uv: {exc}")
return (None, False)
return None
# Verify
result = resolve_uv()
@@ -95,95 +88,7 @@ def ensure_uv() -> Tuple[Optional[str], bool]:
print(f" ✓ Managed uv installed ({version})")
else:
print(" ✗ Managed uv install appeared to succeed but binary not found")
return (result, result is not None)
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
"""Nuke and recreate the venv with managed uv.
Called when managed uv is first bootstrapped on an existing install — the
old venv may point to a Python without FTS5, so we rebuild it with a
fresh interpreter from the current managed uv. Returns ``True`` on
success.
The old venv is moved aside *atomically* (``os.replace`` to ``<venv>.old``)
before recreating — never deleted in place. On Windows a still-running
``hermes.exe`` (gateway/desktop) holds ``venv\\Scripts\\python.exe`` open;
``shutil.rmtree(ignore_errors=True)`` would delete everything it *can*
(site-packages, certifi's cert bundle) and silently leave a half-gutted
venv that the following ``uv venv`` then refuses to overwrite ("directory
already exists") — bricking the install with no recovery (every later HTTPS
call dies with ``FileNotFoundError`` for the missing cert bundle).
``--clear`` alone does not fix this: when the locked interpreter is *inside*
the venv being rebuilt, neither ``rmtree`` nor ``uv venv --clear`` can
delete the held ``python.exe``. ``os.replace`` of the parent directory *is*
allowed (Windows tracks a running ``.exe`` by handle, not path), so the
rebuild completes while the running process keeps using the moved-aside copy
until it restarts. If the venv genuinely cannot be moved, we abort cleanly
and leave it fully intact; and if the rebuild itself fails we move the old
venv back so Hermes is never left with no venv at all.
"""
backup: Optional[Path] = None
if venv_dir.exists():
print(f" → Rebuilding venv (old Python may lack FTS5)...")
backup = venv_dir.with_name(venv_dir.name + ".old")
shutil.rmtree(backup, ignore_errors=True) # clear any stale backup
try:
# Atomic move — fails (without partial deletion) if a process still
# holds files inside the venv, which is exactly the Windows
# file-lock case that previously bricked the install.
os.replace(venv_dir, backup)
except OSError as exc:
logger.warning("venv rebuild aborted — venv in use: %s", exc)
print(
" ✗ venv rebuild aborted — the venv is in use; stop the "
f"gateway/desktop and retry ({exc})"
)
return False
result = subprocess.run(
[uv_bin, "venv", str(venv_dir), "--python", python_version, "--clear"],
capture_output=True,
text=True,
check=False,
)
def _restore_backup() -> None:
if backup is not None and backup.exists():
shutil.rmtree(venv_dir, ignore_errors=True)
try:
os.replace(backup, venv_dir)
print(" ↩ Restored previous venv after failed rebuild.")
except OSError:
pass
if result.returncode == 0:
venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python"
# uv can exit 0 yet leave no usable interpreter (e.g. a half-written
# venv). Don't report success on a venv that has no python — restore the
# moved-aside copy so the caller can abort without losing a working env.
if not venv_python.exists():
logger.warning("venv rebuild reported success but %s is missing", venv_python)
print(f" ✗ venv rebuild failed: Python interpreter missing at {venv_python}")
_restore_backup()
return False
if backup is not None:
shutil.rmtree(backup, ignore_errors=True)
py_ver = subprocess.run(
[str(venv_python), "--version"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
print(f" ✓ venv rebuilt ({py_ver})")
return True
else:
# Rebuild failed — restore the old venv so we never leave Hermes with no
# venv (the bricked-install failure mode this function exists to avoid).
_restore_backup()
logger.warning("venv rebuild failed: %s", result.stderr)
print(f" ✗ venv rebuild failed: {result.stderr.strip()}")
return False
return result
def update_managed_uv() -> Optional[str]:
@@ -228,6 +133,8 @@ def _install_uv(target: Path) -> None:
Uses ``UV_UNMANAGED_INSTALL`` (POSIX) or ``UV_INSTALL_DIR`` (Windows)
so the astral installer writes the binary directly into
``$HERMES_HOME/bin/`` instead of ``~/.local/bin/``.
On termux, installs it using pip.
"""
system = platform.system()
env = {
@@ -238,12 +145,16 @@ def _install_uv(target: Path) -> None:
"UV_UNMANAGED_INSTALL": str(target.parent),
"UV_INSTALL_DIR": str(target.parent),
}
if system == "Windows":
_install_uv_windows(env)
elif _is_termux_env():
_install_uv_termux(env)
else:
_install_uv_posix(env)
def _is_termux_env() -> bool:
return bool(os.environ.get("TERMUX_VERSION")) or "com.termux/files/usr" in os.environ.get("PREFIX", "")
def _install_uv_posix(env: dict[str, str]) -> None:
"""Download + sh the POSIX installer (two-stage to avoid curl|sh pitfalls)."""
@@ -280,3 +191,7 @@ def _install_uv_windows(env: dict[str, str]) -> None:
check=True,
capture_output=True,
)
def _install_uv_termux(env: dict[str, str]) -> None:
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
subprocess.run([sys.executable, "-m", "pip", "install", "uv"], cwd=PROJECT_ROOT, check=False)

View File

@@ -602,7 +602,7 @@ class S6ServiceManager:
"set -e",
"export HOME=/opt/data",
"cd /opt/data",
". /opt/hermes/.venv/bin/activate",
". /opt/hermes/venv/bin/activate",
]
for k, v in sorted(extra_env.items()):
lines.append(f"export {k}={shlex.quote(v)}")

View File

@@ -227,14 +227,14 @@ stdenv.mkDerivation (finalAttrs: {
STAMP_VALUE="${pyprojectHash}:${uvLockHash}"
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
echo "hermes-agent: installing Python dependencies..."
uv venv .venv --python ${python312}/bin/python3 2>/dev/null || true
source .venv/bin/activate
uv venv venv --python ${python312}/bin/python3 2>/dev/null || true
source venv/bin/activate
uv pip install -e ".[all]"
[ -d mini-swe-agent ] && uv pip install -e ./mini-swe-agent 2>/dev/null || true
mkdir -p .nix-stamps
echo "$STAMP_VALUE" > "$STAMP"
else
source .venv/bin/activate
source venv/bin/activate
export HERMES_PYTHON=${hermesVenv}/bin/python3
fi
'';

View File

@@ -13,7 +13,7 @@
# Tool resolution: the hermes wrapper uses --suffix PATH for nix store tools,
# so apt/uv-installed versions take priority. The container entrypoint provisions
# extensible tools on first boot: nodejs/npm via apt, uv via curl, and a Python
# 3.11 venv (bootstrapped entirely by uv) at ~/.venv with pip seeded. Agents get
# 3.11 venv (bootstrapped entirely by uv) at ~/venv with pip seeded. Agents get
# writable tool prefixes for npm i -g, pip install, uv tool install, etc.
#
# Usage:
@@ -159,17 +159,17 @@
# Python 3.12 venv gives the agent a writable Python with pip.
# --seed includes pip/setuptools so bare `pip install` works.
_UV_BIN="$TARGET_HOME/.local/bin/uv"
if [ ! -d "$TARGET_HOME/.venv" ] && [ -x "$_UV_BIN" ]; then
if [ ! -d "$TARGET_HOME/venv" ] && [ -x "$_UV_BIN" ]; then
su -s /bin/sh "$TARGET_USER" -c "
export PATH=\"\$HOME/.local/bin:\$PATH\"
uv python install 3.12
uv venv --python 3.12 --seed \"\$HOME/.venv\"
uv venv --python 3.12 --seed \"\$HOME/venv\"
" || true
fi
# Put the agent venv first on PATH so python/pip resolve to writable copies
if [ -d "$TARGET_HOME/.venv/bin" ]; then
export PATH="$TARGET_HOME/.venv/bin:$PATH"
if [ -d "$TARGET_HOME/venv/bin" ]; then
export PATH="$TARGET_HOME/venv/bin:$PATH"
fi
if command -v setpriv >/dev/null 2>&1; then

View File

@@ -17,7 +17,7 @@ transport:
# with the path the catalog cloned the repo into. The catalog never
# auto-updates: the user re-runs `hermes mcp install official/n8n` to
# refresh.
command: "${INSTALL_DIR}/.venv/bin/python"
command: "${INSTALL_DIR}/venv/bin/python"
args:
- "${INSTALL_DIR}/server.py"
@@ -31,8 +31,8 @@ install:
ref: main
# Bootstrap commands run inside the cloned directory after clone.
bootstrap:
- "python3 -m venv .venv"
- ".venv/bin/pip install -r requirements.txt"
- "python3 -m venv venv"
- "venv/bin/pip install -r requirements.txt"
# Authentication. Three shapes:
# type: api_key — prompt for env vars, write to ~/.hermes/.env

View File

@@ -146,7 +146,7 @@ The harness lives in `tests/docker/` and skips when Docker isn't available. The
### "command not found" via `docker exec`
`/command/` (where s6-overlay puts its binaries) is on PATH only for processes spawned by the supervision tree — services, cont-init.d, main-wrapper.sh. `docker exec <c> s6-svstat …` will fail with "command not found"; always use the absolute path `/command/s6-svstat`. The `hermes` binary works because the Dockerfile adds `/opt/hermes/.venv/bin` to the runtime `ENV PATH`.
`/command/` (where s6-overlay puts its binaries) is on PATH only for processes spawned by the supervision tree — services, cont-init.d, main-wrapper.sh. `docker exec <c> s6-svstat …` will fail with "command not found"; always use the absolute path `/command/s6-svstat`. The `hermes` binary works because the Dockerfile adds `/opt/hermes/venv/bin` to the runtime `ENV PATH`.
### Profile directory ownership

View File

@@ -1047,6 +1047,25 @@ function Install-Repository {
$null = & git -c windows.appendAtomically=false status --short 2>&1
$statusOk = ($LASTEXITCODE -eq 0)
# Verify the configured remote is reachable and is a valid git
# repo. `git ls-remote --exit-code` exits non-zero if the URL
# is unreachable, returns no refs (empty/non-git repo), or the
# transport fails. We cap the wait with a timeout so a dead
# network doesn't stall the installer indefinitely.
$remoteOk = $false
if ($revParseOk -and $statusOk) {
$global:LASTEXITCODE = 0
$remoteUrl = & git remote get-url origin 2>&1
if ($LASTEXITCODE -eq 0 -and $remoteUrl) {
$global:LASTEXITCODE = 0
$null = & git -c http.connectTimeout=10 `
-c http.lowSpeedLimit=0 `
-c http.lowSpeedTime=15 `
ls-remote --exit-code --heads origin 2>&1
$remoteOk = ($LASTEXITCODE -eq 0)
}
}
if ($revParseOk -and $statusOk) {
$repoValid = $true
}

View File

@@ -60,58 +60,57 @@ def _patch_managed_uv(request):
def _fake_ensure_uv():
path = shutil.which("uv")
return (path, False) # never freshly bootstrapped in tests
return path # Optional[str] — no more tuple
def _fake_update_managed_uv():
return None # never actually self-update in tests
def _fake_rebuild_venv(*args, **kwargs):
return True # no-op in tests
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv):
yield
@pytest.fixture(autouse=True)
def _inline_post_pull():
"""Run post-pull in-process instead of re-execing a fresh interpreter.
cmd_update() is now a two-phase flow: phase 1 downloads code, then
_reexec_for_post_pull() replaces the process (os.execvp on POSIX) to run
phase 2 under the freshly-downloaded code. That hard process replacement
would nuke the pytest process mid-test. Patch the re-exec to call
_cmd_update_post_pull() directly so the whole flow stays in-process —
preserving the old "one cmd_update() call exercises everything" behavior
these tests rely on (npm deps, config migration, skill sync, etc.).
"""
from hermes_cli import main as hm
def _inline(args, gateway_mode):
hm._cmd_update_post_pull(args, gateway_mode=gateway_mode)
with patch.object(hm, "_reexec_for_post_pull", side_effect=_inline):
yield
class TestCmdUpdatePip:
"""Regression tests for pip-install update flows."""
"""Pip-installed Hermes can't self-update — it errors with guidance.
@patch("shutil.which", return_value="/usr/bin/uv")
@patch("subprocess.run")
def test_update_pip_exports_virtualenv_from_sys_prefix(
self, mock_run, _mock_which, mock_args, monkeypatch
):
from hermes_cli import main as hm
The old git-checkout self-update logic (_cmd_update_pip: uv tool upgrade /
uv pip install / pipx upgrade) was removed. A pip/uv/pipx install is
managed by its package manager, so ``hermes update`` now refuses with the
recommended command instead of trying to mutate a managed install.
"""
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/tmp/hermes-launcher-venv")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
@patch("hermes_cli.config.detect_install_method", return_value="pip")
def test_pip_install_errors_with_guidance(self, _mock_method, mock_args, capsys):
with pytest.raises(SystemExit) as exc_info:
cmd_update(mock_args)
assert exc_info.value.code == 1
hm._cmd_update_pip(mock_args)
out = capsys.readouterr().out
assert "Cannot self-update" in out
assert "pip" in out.lower()
assert mock_run.call_count == 1
assert mock_run.call_args.args[0] == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"]
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/tmp/hermes-launcher-venv"
@patch("shutil.which", return_value="/usr/bin/uv")
@patch("subprocess.run")
def test_update_pip_does_not_export_virtualenv_for_system_python(
self, mock_run, _mock_which, mock_args, monkeypatch
):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/usr")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
hm._cmd_update_pip(mock_args)
assert mock_run.call_count == 1
assert "env" not in mock_run.call_args.kwargs
class TestCmdUpdateBranchFallback:
@@ -308,6 +307,8 @@ class TestCmdUpdateBranchFallback:
def test_update_non_interactive_runs_safe_config_migrations(self, mock_args, capsys):
"""Dashboard/web updates apply non-interactive migrations before restart."""
import sys as _sys
with patch("shutil.which", return_value=None), patch(
"subprocess.run"
) as mock_run, patch("builtins.input") as mock_input, patch(
@@ -318,9 +319,8 @@ class TestCmdUpdateBranchFallback:
), patch("hermes_cli.config.check_config_version", return_value=(1, 2)), patch(
"hermes_cli.config.migrate_config",
return_value={"env_added": [], "config_added": ["new.option"]},
), patch("hermes_cli.main.sys") as mock_sys:
mock_sys.stdin.isatty.return_value = False
mock_sys.stdout.isatty.return_value = False
), patch.object(_sys.stdin, "isatty", return_value=False), \
patch.object(_sys.stdout, "isatty", return_value=False):
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
@@ -380,6 +380,8 @@ class TestCmdUpdateMigrationPrompt:
self, mock_args, capsys
):
"""New env/config keys are printed by name so the user can decide."""
import sys as _sys
env_items = [
{"name": "FOO_API_KEY", "description": "Foo service API key"},
]
@@ -397,9 +399,8 @@ class TestCmdUpdateMigrationPrompt:
), patch(
"hermes_cli.config.migrate_config",
return_value={"env_added": [], "config_added": [], "warnings": []},
), patch("hermes_cli.main.sys") as mock_sys:
mock_sys.stdin.isatty.return_value = True
mock_sys.stdout.isatty.return_value = True
), patch.object(_sys.stdin, "isatty", return_value=True), \
patch.object(_sys.stdout, "isatty", return_value=True):
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
@@ -495,7 +496,7 @@ class TestCmdUpdateBranchFlag:
target without monkey-patching the implementation.
"""
def _branch_side_effect(self, current_branch, target_branch, *, checkout_fails=False, track_fails=False, commit_count="0"):
def _branch_side_effect(self, current_branch, target_branch, *, checkout_fails=False, track_fails=False, commit_count="0", pre_checkout_sha="abc123"):
"""Mock side-effect that knows about checkout/track behavior.
- ``current_branch`` what ``git rev-parse --abbrev-ref HEAD`` returns
@@ -505,6 +506,7 @@ class TestCmdUpdateBranchFlag:
- ``track_fails`` if True, ``git checkout -B <target> origin/<target>`` ALSO fails
(simulates branch absent on origin too)
- ``commit_count`` rev-list count returned (0 = up-to-date, >0 = behind)
- ``pre_checkout_sha`` SHA returned by ``git rev-parse HEAD`` before checkout
"""
def side_effect(cmd, **kwargs):
@@ -513,6 +515,9 @@ class TestCmdUpdateBranchFlag:
if "rev-parse" in joined and "--abbrev-ref" in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{current_branch}\n", stderr="")
if "rev-parse" in joined and "HEAD" in joined and "--abbrev-ref" not in joined:
return subprocess.CompletedProcess(cmd, 0, stdout=f"{pre_checkout_sha}\n", stderr="")
if "checkout" in joined and "-B" in joined:
rc = 128 if track_fails else 0
err = f"fatal: '{target_branch}' did not match any file(s) known to git\n" if track_fails else ""
@@ -630,6 +635,48 @@ class TestCmdUpdateBranchFlag:
assert "does not exist locally or on origin" in out
assert "nonexistent" in out
@patch("shutil.which", return_value=None)
@patch("subprocess.run")
def test_checkout_b_uses_pre_checkout_sha_for_rev_list(self, mock_run, _mock_which, capsys):
"""After ``checkout -B`` fallback, rev-list must compare pre-checkout SHA
vs origin/<branch>, not HEAD vs origin/<branch>.
Regression test: on a detached HEAD with a missing local branch, the
``checkout -B <branch> origin/<branch>`` fallback puts HEAD at the
same commit as origin/<branch>. The old code compared
``HEAD..origin/<branch>`` which was always 0, falsely printing
"already up to date" and skipping post-pull processing (pip install,
node deps, skills sync, config migration).
"""
mock_run.side_effect = self._branch_side_effect(
current_branch="HEAD", # detached HEAD
target_branch="bb/gui",
checkout_fails=True, # plain checkout fails (no local branch)
track_fails=False, # -B from origin/bb/gui succeeds
commit_count="3", # new commits exist on remote
pre_checkout_sha="deadbeef",
)
args = SimpleNamespace(branch="bb/gui")
cmd_update(args)
commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
rev_list_cmds = [c for c in commands if "rev-list" in c]
# After -B, rev-list MUST use the pre-checkout SHA, not HEAD
assert len(rev_list_cmds) >= 1
assert "deadbeef" in rev_list_cmds[0], (
f"Expected pre-checkout SHA in rev-list, got: {rev_list_cmds[0]}"
)
assert "HEAD" not in rev_list_cmds[0].split(), (
f"rev-list must not use HEAD after -B checkout, got: {rev_list_cmds[0]}"
)
out = capsys.readouterr().out
# Must NOT falsely report "already up to date"
assert "already up to date" not in out.lower()
# Must report the new commits
assert "3 new commit" in out
class TestCmdUpdateCheckBranchFlag:
"""``hermes update --check --branch <name>`` honors the branch override.
@@ -783,11 +830,11 @@ class TestCmdUpdateZipBranchRefusal:
"""
def test_zip_fallback_refuses_non_main_branch(self, capsys):
from hermes_cli.main import _update_via_zip
from hermes_cli.main import _update_files_via_zip
args = SimpleNamespace(branch="bb/gui")
with pytest.raises(SystemExit) as exc_info:
_update_via_zip(args)
_update_files_via_zip(args)
assert exc_info.value.code == 1
out = capsys.readouterr().out

View File

@@ -76,11 +76,10 @@ class TestEnsureUv:
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import ensure_uv
path, fresh = ensure_uv()
path = ensure_uv()
assert path == str(tmp_path / "bin" / "uv")
assert fresh is False
def test_installs_if_missing_sets_bootstrap_flag(self, tmp_path):
def test_installs_if_missing(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv._install_uv") as mock_install:
# Simulate the installer creating the binary
@@ -89,127 +88,16 @@ class TestEnsureUv:
mock_install.side_effect = fake_install
from hermes_cli.managed_uv import ensure_uv
path, fresh = ensure_uv()
path = ensure_uv()
assert path == str(tmp_path / "bin" / "uv")
assert fresh is True
mock_install.assert_called_once()
def test_install_failure_returns_none_false(self, tmp_path):
def test_install_failure_returns_none(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
from hermes_cli.managed_uv import ensure_uv
path, fresh = ensure_uv()
path = ensure_uv()
assert path is None
assert fresh is False
# ---------------------------------------------------------------------------
# rebuild_venv
# ---------------------------------------------------------------------------
class TestRebuildVenv:
def test_moves_old_venv_aside_and_creates_new(self, tmp_path):
"""The old venv is moved aside to <venv>.old (never rmtree'd in place),
uv is invoked with --clear, the moved-aside backup is removed on
success, and the rebuilt interpreter is reported."""
venv_dir = tmp_path / "venv"
venv_dir.mkdir()
(venv_dir / "old_file").write_text("stale")
uv_bin = str(tmp_path / "bin" / "uv")
call_log: list[list[str]] = []
def fake_run(cmd, **kwargs):
call_log.append(list(cmd))
m = MagicMock(returncode=0, stderr="", stdout="")
if len(cmd) >= 2 and cmd[1] == "venv":
# Simulate uv creating the venv dir with a python interpreter
bin_dir = venv_dir / ("Scripts" if os.name == "nt" else "bin")
bin_dir.mkdir(parents=True, exist_ok=True)
python_name = "python.exe" if os.name == "nt" else "python"
(bin_dir / python_name).write_text("#!/bin/sh\necho Python 3.11.0")
elif "--version" in cmd:
m.stdout = "Python 3.11.0"
return m
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run):
from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir)
assert result is True
# uv venv was invoked exactly once, always with --clear.
venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"]
assert len(venv_calls) == 1, f"expected 1 venv call, got {venv_calls}"
assert "--clear" in venv_calls[0]
# The moved-aside backup is cleaned up after a successful rebuild.
assert not (tmp_path / "venv.old").exists()
def test_aborts_without_deleting_when_venv_in_use(self, tmp_path):
"""If os.replace fails (Windows file lock — venv in use), we must abort
cleanly WITHOUT deleting the venv and WITHOUT invoking uv."""
venv_dir = tmp_path / "venv"
venv_dir.mkdir()
(venv_dir / "locked") .write_text("held open")
uv_bin = str(tmp_path / "bin" / "uv")
call_log: list[list[str]] = []
def fake_run(cmd, **kwargs):
call_log.append(list(cmd))
return MagicMock(returncode=0, stderr="", stdout="")
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
patch("hermes_cli.managed_uv.os.replace", side_effect=OSError("in use")):
from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir)
assert result is False
# venv left fully intact, uv never invoked.
assert venv_dir.exists() and (venv_dir / "locked").exists()
assert [c for c in call_log if len(c) >= 2 and c[1] == "venv"] == []
def test_restores_backup_when_rebuild_fails(self, tmp_path):
"""If uv venv exits non-zero, the moved-aside venv is restored so we
never leave Hermes with no venv at all."""
venv_dir = tmp_path / "venv"
venv_dir.mkdir()
(venv_dir / "marker").write_text("original")
uv_bin = str(tmp_path / "bin" / "uv")
def fake_run(cmd, **kwargs):
return MagicMock(returncode=1, stderr="boom", stdout="")
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run):
from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir)
assert result is False
# Original venv restored from the .old backup.
assert venv_dir.exists() and (venv_dir / "marker").read_text() == "original"
assert not (tmp_path / "venv.old").exists()
def test_rebuild_failure_returns_false(self, tmp_path):
venv_dir = tmp_path / "venv"
uv_bin = str(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stderr="nope")
from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir)
assert result is False
def test_rebuild_success_without_python_returns_false(self, tmp_path):
"""uv can exit 0 yet leave no interpreter; that must not count as success
(guard adapted from #38511)."""
venv_dir = tmp_path / "venv"
uv_bin = str(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir)
assert result is False
# Returned before the `python --version` probe ran (only the uv venv call).
assert mock_run.call_count == 1
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,157 @@
"""Tests for the post-pull stamp gate.
After ``hermes update``'s post-pull phase, the install dir's current commit
is written to a stamp file. Every launch (CLI/TUI/gateway) routed through
``main()`` calls ``_ensure_post_pull_current`` to confirm the stamp matches
the live commit; if not, it runs post-pull and exits asking for a re-run.
"""
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from hermes_cli import main as hm
@pytest.fixture
def stamp_dir(tmp_path):
"""Point PROJECT_ROOT (and thus the stamp path) at a temp dir."""
with patch.object(hm, "PROJECT_ROOT", tmp_path):
yield tmp_path
# ---------------------------------------------------------------------------
# stamp read/write round-trip
# ---------------------------------------------------------------------------
def test_write_then_read_round_trip(stamp_dir):
assert hm._write_post_pull_stamp("abc123") is True
assert hm._read_post_pull_stamp() == "abc123"
assert (stamp_dir / ".post_pull_stamp").read_text().strip() == "abc123"
def test_read_missing_stamp_returns_none(stamp_dir):
assert hm._read_post_pull_stamp() is None
def test_write_default_uses_current_commit(stamp_dir):
with patch.object(hm, "_current_install_commit", return_value="deadbeef"):
assert hm._write_post_pull_stamp() is True
assert hm._read_post_pull_stamp() == "deadbeef"
def test_write_no_commit_resolvable_is_noop(stamp_dir):
with patch.object(hm, "_current_install_commit", return_value=None):
assert hm._write_post_pull_stamp() is False
assert hm._read_post_pull_stamp() is None
# ---------------------------------------------------------------------------
# is-current logic
# ---------------------------------------------------------------------------
def test_is_current_true_when_stamp_matches(stamp_dir):
with patch.object(hm, "_current_install_commit", return_value="sha1"):
hm._write_post_pull_stamp("sha1")
assert hm._post_pull_stamp_is_current() is True
def test_is_current_false_when_stamp_stale(stamp_dir):
hm._write_post_pull_stamp("old_sha")
with patch.object(hm, "_current_install_commit", return_value="new_sha"):
assert hm._post_pull_stamp_is_current() is False
def test_is_current_false_when_stamp_missing(stamp_dir):
with patch.object(hm, "_current_install_commit", return_value="any_sha"):
assert hm._post_pull_stamp_is_current() is False
def test_is_current_true_when_commit_unresolvable(stamp_dir):
# Can't determine the live commit (e.g. not a git checkout) -> never block.
with patch.object(hm, "_current_install_commit", return_value=None):
assert hm._post_pull_stamp_is_current() is True
# ---------------------------------------------------------------------------
# the gate
# ---------------------------------------------------------------------------
def test_gate_skips_update_command(stamp_dir):
"""The updater owns the stamp; gating it would recurse."""
args = SimpleNamespace(command="update")
with patch.object(hm, "_cmd_update_post_pull") as post_pull:
hm._ensure_post_pull_current(args) # must not raise/exit
post_pull.assert_not_called()
def test_gate_skips_non_git_install(stamp_dir):
args = SimpleNamespace(command="chat")
with patch.object(hm, "_is_git_source_install", return_value=False), \
patch.object(hm, "_cmd_update_post_pull") as post_pull:
hm._ensure_post_pull_current(args)
post_pull.assert_not_called()
def test_gate_skips_via_env_escape_hatch(stamp_dir, monkeypatch):
monkeypatch.setenv("HERMES_SKIP_POST_PULL_GATE", "1")
args = SimpleNamespace(command="chat")
with patch.object(hm, "_is_git_source_install", return_value=True), \
patch.object(hm, "_cmd_update_post_pull") as post_pull:
hm._ensure_post_pull_current(args)
post_pull.assert_not_called()
def test_gate_noop_when_stamp_current(stamp_dir):
args = SimpleNamespace(command="chat")
with patch.object(hm, "_is_git_source_install", return_value=True), \
patch.object(hm, "_post_pull_stamp_is_current", return_value=True), \
patch.object(hm, "_cmd_update_post_pull") as post_pull:
hm._ensure_post_pull_current(args)
post_pull.assert_not_called()
def test_gate_runs_post_pull_and_exits_when_stale(stamp_dir):
args = SimpleNamespace(command="chat")
with patch.object(hm, "_is_git_source_install", return_value=True), \
patch.object(hm, "_post_pull_stamp_is_current", return_value=False), \
patch.object(hm, "_cmd_update_post_pull") as post_pull, \
patch.object(hm, "_write_post_pull_stamp") as write_stamp:
with pytest.raises(SystemExit) as exc:
hm._ensure_post_pull_current(args)
assert exc.value.code == 0
post_pull.assert_called_once()
# post_pull is called with assume_yes=True and gateway_mode=False
called_args = post_pull.call_args
assert called_args.kwargs.get("gateway_mode") is False
assert getattr(called_args.args[0], "yes") is True
write_stamp.assert_called_once()
def test_gate_post_pull_systemexit_propagates(stamp_dir):
"""A fatal precondition in post-pull (e.g. bad Python) must not be swallowed."""
args = SimpleNamespace(command="chat")
with patch.object(hm, "_is_git_source_install", return_value=True), \
patch.object(hm, "_post_pull_stamp_is_current", return_value=False), \
patch.object(hm, "_cmd_update_post_pull", side_effect=SystemExit(1)):
with pytest.raises(SystemExit) as exc:
hm._ensure_post_pull_current(args)
assert exc.value.code == 1
def test_gate_post_pull_exception_exits_nonzero(stamp_dir):
args = SimpleNamespace(command="chat")
with patch.object(hm, "_is_git_source_install", return_value=True), \
patch.object(hm, "_post_pull_stamp_is_current", return_value=False), \
patch.object(hm, "_cmd_update_post_pull", side_effect=RuntimeError("boom")):
with pytest.raises(SystemExit) as exc:
hm._ensure_post_pull_current(args)
assert exc.value.code == 1
def test_gate_missing_command_attr_treated_as_non_update(stamp_dir):
"""args without a .command attr (some launch paths) must still gate."""
args = SimpleNamespace() # no .command
with patch.object(hm, "_is_git_source_install", return_value=True), \
patch.object(hm, "_post_pull_stamp_is_current", return_value=True), \
patch.object(hm, "_cmd_update_post_pull") as post_pull:
hm._ensure_post_pull_current(args) # must not raise
post_pull.assert_not_called()

View File

@@ -30,18 +30,31 @@ def _patch_managed_uv(request):
def _fake_ensure_uv():
path = shutil.which("uv")
return (path, False) # never freshly bootstrapped in tests
return path # Optional[str] — no more tuple
def _fake_update_managed_uv():
return None # never actually self-update in tests
def _fake_rebuild_venv(*args, **kwargs):
return True # no-op in tests
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv):
yield
@pytest.fixture(autouse=True)
def _inline_post_pull():
"""Run post-pull in-process instead of re-execing a fresh interpreter.
cmd_update() is now a two-phase flow: phase 1 downloads code, then
_reexec_for_post_pull() replaces the process (os.execvp on POSIX) to run
phase 2 under the freshly-downloaded code. That hard process replacement
would nuke the pytest process mid-test. Patch the re-exec to call
_cmd_update_post_pull() directly so the whole flow stays in-process.
"""
def _inline(args, gateway_mode):
hermes_main._cmd_update_post_pull(args, gateway_mode=gateway_mode)
with patch.object(hermes_main, "_reexec_for_post_pull", side_effect=_inline):
yield
def test_stash_local_changes_if_needed_returns_none_when_tree_clean(monkeypatch, tmp_path):
@@ -423,41 +436,6 @@ def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path):
assert ".[all]" in install_cmds[0]
def test_cmd_update_aborts_when_fresh_managed_uv_rebuild_fails(monkeypatch, tmp_path):
"""A failed fresh managed-uv venv rebuild must not continue into pip install
(guard adapted from #38511)."""
_setup_update_mocks(monkeypatch, tmp_path)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None)
monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False)
recorded = []
def fake_run(cmd, **kwargs):
recorded.append(cmd)
# Tolerant matching: the update flow's exact git invocations vary by
# checkout, so key off the verb. Branch detection must return a real name
# and rev-list a parseable count, or the flow aborts early before it ever
# reaches the venv rebuild this test exercises.
if isinstance(cmd, (list, tuple)) and cmd and cmd[0] == "git":
if "rev-parse" in cmd:
return SimpleNamespace(stdout="main\n", stderr="", returncode=0)
if "rev-list" in cmd:
return SimpleNamespace(stdout="1\n", stderr="", returncode=0)
if "pull" in cmd:
return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(hermes_main.subprocess, "run", fake_run)
with patch("hermes_cli.managed_uv.ensure_uv", return_value=("/usr/bin/uv", True)), \
patch("hermes_cli.managed_uv.rebuild_venv", return_value=False), \
pytest.raises(RuntimeError, match="venv rebuild failed"):
hermes_main.cmd_update(SimpleNamespace())
install_cmds = [c for c in recorded if "pip" in c and "install" in c]
assert install_cmds == []
def test_install_with_optional_fallback_honors_custom_group(monkeypatch):
"""Termux update path should target .[termux-all] when requested."""
calls = []

View File

@@ -19,6 +19,23 @@ import pytest
from hermes_cli import main as cli_main
@pytest.fixture(autouse=True)
def _inline_post_pull():
"""Run post-pull in-process instead of re-execing a fresh interpreter.
cmd_update() is now a two-phase flow: phase 1 downloads code, then
_reexec_for_post_pull() replaces the process (os.execvp on POSIX) to run
phase 2 under the freshly-downloaded code. That hard process replacement
would nuke the pytest process mid-test. Patch the re-exec to call
_cmd_update_post_pull() directly so the whole flow stays in-process.
"""
def _inline(args, gateway_mode):
cli_main._cmd_update_post_pull(args, gateway_mode=gateway_mode)
with patch.object(cli_main, "_reexec_for_post_pull", side_effect=_inline):
yield
# Tests in this module either exercise the REAL _detect_concurrent_hermes_instances
# helper (and need the autouse stub in tests/hermes_cli/conftest.py disabled),
# or supply their own explicit return value via patch.object. Mark the whole
@@ -511,8 +528,8 @@ def test_cmd_update_force_bypasses_concurrent_check(_winp, tmp_path):
detect = MagicMock(return_value=[(9, "hermes.exe")])
# Short-circuit out of _cmd_update_impl via a sentinel raise immediately
# AFTER the gate. _run_pre_update_backup is the first call after the gate.
# Short-circuit out of _cmd_update_pull_new_version via a sentinel raise
# immediately AFTER the gate. _run_pre_update_backup is the first call after the gate.
sentinel = RuntimeError("reached post-gate body")
with patch.object(
cli_main, "_venv_scripts_dir", return_value=scripts_dir

View File

@@ -1,83 +0,0 @@
"""Tests for ``_wait_for_interpreter_venv_ready`` in ``hermes_cli/main.py``.
During ``hermes update`` the managed-uv path can rebuild the project venv
(rmtree + ``uv venv``) before the desktop-rebuild and profile-skills-sync
steps spawn ``sys.executable``. If those children fire while the venv is
mid-rewrite, the interpreter launcher aborts with ``No pyvenv.cfg file`` and
the step spuriously "fails" on an otherwise-successful update. The helper
waits for the marker to settle first.
"""
from __future__ import annotations
import os
import threading
import time
from pathlib import Path
from hermes_cli.main import _wait_for_interpreter_venv_ready
def _make_fake_venv(tmp_path: Path, *, with_cfg: bool) -> Path:
"""Create a venv-shaped dir and return the interpreter path inside it."""
bin_name = "Scripts" if os.name == "nt" else "bin"
bin_dir = tmp_path / bin_name
bin_dir.mkdir(parents=True)
py = bin_dir / ("python.exe" if os.name == "nt" else "python")
py.write_text("#!/bin/sh\n")
if with_cfg:
(tmp_path / "pyvenv.cfg").write_text("home = /usr\n")
return py
class TestWaitForInterpreterVenvReady:
def test_intact_venv_returns_immediately(self, tmp_path, monkeypatch):
py = _make_fake_venv(tmp_path, with_cfg=True)
monkeypatch.setattr("sys.executable", str(py))
t0 = time.monotonic()
assert _wait_for_interpreter_venv_ready(timeout=5) is True
assert time.monotonic() - t0 < 0.5
def test_non_venv_interpreter_returns_immediately(self, tmp_path, monkeypatch):
# A bare interpreter whose parent.parent has no bin/Scripts marker
# dir is not venv-hosted; pyvenv.cfg is irrelevant.
sys_py = tmp_path / "usr" / "bin" / "python"
sys_py.parent.mkdir(parents=True)
sys_py.write_text("#!/bin/sh\n")
# Ensure parent.parent (tmp_path/usr) has no bin sibling shaped like a venv
monkeypatch.setattr("sys.executable", str(sys_py))
# parent.parent == tmp_path/usr; its "bin" child IS tmp_path/usr/bin
# which exists — so this would look venv-ish. Use a deeper layout
# where parent.parent has no bin marker:
deep = tmp_path / "opt" / "py3" / "real" / "python"
deep.parent.mkdir(parents=True)
deep.write_text("#!/bin/sh\n")
monkeypatch.setattr("sys.executable", str(deep))
t0 = time.monotonic()
assert _wait_for_interpreter_venv_ready(timeout=5) is True
assert time.monotonic() - t0 < 0.5
def test_waits_for_cfg_to_appear(self, tmp_path, monkeypatch):
py = _make_fake_venv(tmp_path, with_cfg=False)
monkeypatch.setattr("sys.executable", str(py))
def _write_cfg_later():
time.sleep(0.6)
(tmp_path / "pyvenv.cfg").write_text("home = /usr\n")
th = threading.Thread(target=_write_cfg_later)
th.start()
try:
t0 = time.monotonic()
assert _wait_for_interpreter_venv_ready(timeout=5) is True
elapsed = time.monotonic() - t0
finally:
th.join()
assert 0.5 < elapsed < 2.0
def test_returns_false_when_cfg_never_appears(self, tmp_path, monkeypatch):
py = _make_fake_venv(tmp_path, with_cfg=False)
monkeypatch.setattr("sys.executable", str(py))
t0 = time.monotonic()
assert _wait_for_interpreter_venv_ready(timeout=1) is False
assert 0.9 < time.monotonic() - t0 < 1.6

View File

@@ -12,9 +12,60 @@ import subprocess
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from hermes_cli.main import cmd_update
# ---------------------------------------------------------------------------
# Managed-uv compatibility for tests that patch shutil.which
# ---------------------------------------------------------------------------
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
# instead of ``shutil.which("uv")``. These autouse fixtures make the
# managed_uv functions delegate to the patched ``shutil.which`` so the
# existing test setup keeps working without per-test changes.
@pytest.fixture(autouse=True)
def _patch_managed_uv(request):
"""Make managed_uv helpers follow shutil.which mocking in tests."""
import shutil
def _fake_resolve_uv():
return shutil.which("uv")
def _fake_ensure_uv():
path = shutil.which("uv")
return path # Optional[str] — no more tuple
def _fake_update_managed_uv():
return None # never actually self-update in tests
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv):
yield
@pytest.fixture(autouse=True)
def _inline_post_pull():
"""Run post-pull in-process instead of re-execing a fresh interpreter.
cmd_update() is now a two-phase flow: phase 1 downloads code, then
_reexec_for_post_pull() replaces the process (os.execvp on POSIX) to run
phase 2 under the freshly-downloaded code. That hard process replacement
would nuke the pytest process mid-test. Patch the re-exec to call
_cmd_update_post_pull() directly so the whole flow stays in-process —
preserving the old "one cmd_update() call exercises everything" behavior
these tests rely on (config migration, skill sync, etc.).
"""
from hermes_cli import main as hm
def _inline(args, gateway_mode):
hm._cmd_update_post_pull(args, gateway_mode=gateway_mode)
with patch.object(hm, "_reexec_for_post_pull", side_effect=_inline):
yield
def _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1", dirty=False
):

View File

@@ -1,4 +1,4 @@
"""Regression: _update_via_zip must reject ZIP members with symlink mode.
"""Regression: _update_files_via_zip must reject ZIP members with symlink mode.
A symlink member in a downloaded update ZIP would let an attacker who can
serve / MITM the update mirror plant a symlink that extractall() then
@@ -32,7 +32,7 @@ def _build_normal_zip(zip_path: str) -> None:
zf.writestr("hermes-agent-main/README.md", "ok\n")
def test_update_via_zip_rejects_symlink_member(tmp_path, monkeypatch):
def test_update_files_via_zip_rejects_symlink_member(tmp_path, monkeypatch):
"""A symlink member in the update ZIP must raise before extractall."""
zip_path = tmp_path / "evil.zip"
_build_zip_with_symlink_member(
@@ -41,12 +41,12 @@ def test_update_via_zip_rejects_symlink_member(tmp_path, monkeypatch):
target="/etc/passwd",
)
from hermes_cli.main import _update_via_zip
from hermes_cli.main import _update_files_via_zip
args = type("Args", (), {})()
# Patch urlretrieve to "download" our pre-built malicious ZIP into the
# _update_via_zip tempdir. Capture the tempdir so we can prove no
# _update_files_via_zip tempdir. Capture the tempdir so we can prove no
# extraction happened.
captured = {}
original_mkdtemp = tempfile.mkdtemp
@@ -64,11 +64,11 @@ def test_update_via_zip_rejects_symlink_member(tmp_path, monkeypatch):
with patch("tempfile.mkdtemp", side_effect=capturing_mkdtemp), \
patch("urllib.request.urlretrieve", side_effect=fake_urlretrieve):
# _update_via_zip catches ValueError, prints the message, and exits 1.
# _update_files_via_zip catches ValueError, prints the message, and exits 1.
# That's the contract: a malicious ZIP must fail the update, not
# silently materialize a symlink.
with pytest.raises(SystemExit) as exc_info:
_update_via_zip(args)
_update_files_via_zip(args)
assert exc_info.value.code == 1
# Belt: confirm extractall never produced the link.
@@ -80,7 +80,7 @@ def test_update_via_zip_rejects_symlink_member(tmp_path, monkeypatch):
)
def test_update_via_zip_accepts_normal_member(tmp_path, monkeypatch, capsys):
def test_update_files_via_zip_accepts_normal_member(tmp_path, monkeypatch, capsys):
"""A ZIP with only regular file members must extract without raising.
Sanity check that the symlink reject didn't break the happy path. We
@@ -118,7 +118,7 @@ def test_update_via_zip_accepts_normal_member(tmp_path, monkeypatch, capsys):
patch("subprocess.check_call"):
fake_run.return_value = type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
try:
hermes_main._update_via_zip(args)
hermes_main._update_files_via_zip(args)
except SystemExit:
pass

View File

@@ -41,18 +41,13 @@ def _patch_managed_uv(request):
def _fake_ensure_uv():
path = shutil.which("uv")
return (path, False) # never freshly bootstrapped in tests
return path
def _fake_update_managed_uv():
return None # never actually self-update in tests
def _fake_rebuild_venv(*args, **kwargs):
return True # no-op in tests
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv):
yield
@@ -179,169 +174,12 @@ class TestRecommendedUpdateCommandForUvTool:
# ---------------------------------------------------------------------------
# _cmd_update_pip subprocess command
# NOTE: The _cmd_update_pip subprocess tests (uv tool upgrade / uv pip install
# / pipx upgrade / --system fallback / VIRTUAL_ENV overlay) were removed when
# the pip self-update path was deleted. ``hermes update`` no longer mutates a
# pip/uv/pipx-managed install — it errors with the recommended command (see
# tests/hermes_cli/test_cmd_update.py::TestCmdUpdatePip). The detection helper
# (is_uv_tool_install) and the recommendation-string helper
# (recommended_update_command_for_method) are still live and tested above,
# because they feed the user-facing "Run: <cmd>" guidance.
# ---------------------------------------------------------------------------
class TestCmdUpdatePipUsesUvTool:
@patch("subprocess.run")
def test_runs_uv_tool_upgrade_when_uv_tool_install(self, mock_run):
"""The actual subprocess invocation must switch to ``uv tool upgrade``."""
from hermes_cli.main import _cmd_update_pip
mock_run.return_value = subprocess.CompletedProcess(["uv"], 0, stdout="", stderr="")
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=True):
_cmd_update_pip(SimpleNamespace())
assert mock_run.call_args[0][0] == ["/usr/local/bin/uv", "tool", "upgrade", "hermes-agent"]
@patch("subprocess.run")
def test_runs_uv_pip_install_when_not_uv_tool(self, mock_run):
"""Existing behavior preserved when uv is present but Hermes isn't a tool install."""
from hermes_cli.main import _cmd_update_pip
mock_run.return_value = subprocess.CompletedProcess(["uv"], 0, stdout="", stderr="")
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
_cmd_update_pip(SimpleNamespace())
assert mock_run.call_args[0][0] == [
"/usr/local/bin/uv",
"pip",
"install",
"--upgrade",
"hermes-agent",
]
@patch("subprocess.run")
def test_falls_back_to_pip_when_no_uv(self, mock_run):
from hermes_cli.main import _cmd_update_pip
mock_run.return_value = subprocess.CompletedProcess(["pip"], 0, stdout="", stderr="")
with patch("shutil.which", return_value=None), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
_cmd_update_pip(SimpleNamespace())
cmd = mock_run.call_args[0][0]
assert cmd[1:] == ["-m", "pip", "install", "--upgrade", "hermes-agent"]
@patch("subprocess.run")
def test_exits_nonzero_on_subprocess_failure(self, mock_run):
from hermes_cli.main import _cmd_update_pip
mock_run.return_value = subprocess.CompletedProcess(["uv"], 1, stdout="", stderr="")
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=True):
with pytest.raises(SystemExit) as exc_info:
_cmd_update_pip(SimpleNamespace())
assert exc_info.value.code == 1
@patch("subprocess.run")
def test_uv_tool_install_without_uv_on_path_exits_with_hint(self, mock_run):
"""If the running interpreter looks like a uv-tool install but ``uv`` is
somehow missing from PATH, surface a clear hint instead of silently
falling back to ``python -m pip``, which would either fail (no venv)
or upgrade the wrong copy."""
from hermes_cli.main import _cmd_update_pip
with patch("shutil.which", return_value=None), \
patch("hermes_cli.config.is_uv_tool_install", return_value=True):
with pytest.raises(SystemExit) as exc_info:
_cmd_update_pip(SimpleNamespace())
assert exc_info.value.code == 1
mock_run.assert_not_called()
# ---------------------------------------------------------------------------
# pipx-managed installs, --system fallback, and VIRTUAL_ENV overlay
# (issue #29700 / #35031 family — consolidated update-path handling)
# ---------------------------------------------------------------------------
class TestCmdUpdatePipInstallLayouts:
"""The uv pip path must adapt to where the running interpreter lives:
- inside a venv (launcher shim) -> export VIRTUAL_ENV, no ``--system``
- bare pip outside any venv -> add ``--system``, no overlay
- pipx-managed -> ``pipx upgrade``
"""
@patch("subprocess.run")
def test_pipx_managed_uses_pipx_upgrade(self, mock_run, monkeypatch):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.setattr(hm.sys, "prefix", "/home/u/.local/pipx/venvs/hermes-agent")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
def _which(name):
return {"uv": "/usr/bin/uv", "pipx": "/usr/bin/pipx"}.get(name)
with patch("shutil.which", side_effect=_which), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
hm._cmd_update_pip(SimpleNamespace())
assert mock_run.call_args[0][0] == ["/usr/bin/pipx", "upgrade", "hermes-agent"]
# pipx upgrade ignores VIRTUAL_ENV; we must not set it.
assert "env" not in mock_run.call_args.kwargs
@patch("subprocess.run")
def test_pipx_layout_without_pipx_binary_treated_as_venv(
self, mock_run, monkeypatch
):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.setattr(hm.sys, "prefix", "/home/u/.local/pipx/venvs/hermes-agent")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
# pipx layout detected via prefix, but pipx binary missing on PATH.
def _which(name):
return "/usr/bin/uv" if name == "uv" else None
with patch("shutil.which", side_effect=_which), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
hm._cmd_update_pip(SimpleNamespace())
# prefix != base_prefix, so this is treated as a venv -> overlay, no --system.
assert mock_run.call_args[0][0] == [
"/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent",
]
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"].endswith("hermes-agent")
@patch("subprocess.run")
def test_bare_pip_outside_venv_adds_system(self, mock_run, monkeypatch):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
# No venv: prefix == base_prefix.
monkeypatch.setattr(hm.sys, "prefix", "/usr")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
with patch("shutil.which", return_value="/usr/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
hm._cmd_update_pip(SimpleNamespace())
assert mock_run.call_args[0][0] == [
"/usr/bin/uv", "pip", "install", "--system", "--upgrade", "hermes-agent",
]
assert "env" not in mock_run.call_args.kwargs
@patch("subprocess.run")
def test_venv_exports_virtualenv_and_omits_system(self, mock_run, monkeypatch):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/home/u/.hermes/hermes-agent/venv")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
with patch("shutil.which", return_value="/usr/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
hm._cmd_update_pip(SimpleNamespace())
cmd = mock_run.call_args[0][0]
assert "--system" not in cmd
assert cmd == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"]
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/home/u/.hermes/hermes-agent/venv"

View File

@@ -125,7 +125,7 @@ docker run -it --rm \
或者,如果你已通过 Docker Desktop 等方式在运行中的容器内打开了终端,直接运行:
```sh
/opt/hermes/.venv/bin/hermes
/opt/hermes/venv/bin/hermes
```
## 持久化卷