mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 20:29:00 +08:00
Compare commits
7 Commits
fix/photon
...
ethie/wind
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef1a933e29 | ||
|
|
1e5ae386ee | ||
|
|
77fef76924 | ||
|
|
55879ebf1f | ||
|
|
692146939e | ||
|
|
27135e0e6a | ||
|
|
f774e9c6f5 |
2
.github/workflows/docker-lint.yml
vendored
2
.github/workflows/docker-lint.yml
vendored
@@ -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:
|
||||
|
||||
6
.github/workflows/docker-publish.yml
vendored
6
.github/workflows/docker-publish.yml
vendored
@@ -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
|
||||
|
||||
14
.github/workflows/tests.yml
vendored
14
.github/workflows/tests.yml
vendored
@@ -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
6
.gitignore
vendored
@@ -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
137
AGENTS.md
@@ -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 120s–2h.
|
||||
@@ -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
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -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
315
PLAN.md
Normal 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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
2468
hermes_cli/main.py
2468
hermes_cli/main.py
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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
|
||||
'';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
157
tests/hermes_cli/test_post_pull_stamp.py
Normal file
157
tests/hermes_cli/test_post_pull_stamp.py
Normal 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()
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -125,7 +125,7 @@ docker run -it --rm \
|
||||
或者,如果你已通过 Docker Desktop 等方式在运行中的容器内打开了终端,直接运行:
|
||||
|
||||
```sh
|
||||
/opt/hermes/.venv/bin/hermes
|
||||
/opt/hermes/venv/bin/hermes
|
||||
```
|
||||
|
||||
## 持久化卷
|
||||
|
||||
Reference in New Issue
Block a user