mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
Merge pull request #2058 from nesquena/stage-337
Release T (v0.51.44): 5-PR batch (#2048 + #2052 + #2053 + #2055 + #1970) + test-suite network isolation
This commit is contained in:
+1
-1
@@ -15,7 +15,7 @@
|
||||
# Port to listen on (default: 8787)
|
||||
# HERMES_WEBUI_PORT=8787
|
||||
|
||||
# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui-mvp)
|
||||
# Where to store sessions, workspaces, and other state (default: ~/.hermes/webui)
|
||||
# HERMES_WEBUI_STATE_DIR=~/.hermes/webui
|
||||
|
||||
# Default workspace directory shown on first launch
|
||||
|
||||
@@ -2,6 +2,54 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.44] — 2026-05-11 — Release T (5-PR contributor batch — security + worktree sessions + LM Studio + onboarding docs + transcript dedup, plus comprehensive test-suite network isolation)
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2052** by @franksong2702 — `docs/onboarding.md` (181 lines) covering install path choices, safe wizard re-runs with isolated `HERMES_HOME` / `HERMES_WEBUI_STATE_DIR`, provider groups, Docker/local-server Base URL rules (the most common Discord support question — `localhost` inside a container is not the host running LM Studio or Ollama), workspace setup, password step, files written by the wizard, and issue-reporting diagnostics. README pointer added from the quick-start section and Docs list. Stale `~/.hermes/webui-mvp` → `~/.hermes/webui` correction in `.env.example` and the README env-var table (the running app uses `~/.hermes/webui` per `api/config.py:42`).
|
||||
|
||||
- **PR #2053** by @franksong2702 — Worktree-backed session creation. `POST /api/session/new` accepts a `worktree: true` flag that calls the agent's `_setup_worktree()` helper to create an isolated git worktree at `<repo>/.worktrees/hermes-XXXX`, persists `worktree_path` / `worktree_branch` / `worktree_repo_root` / `worktree_created_at` on the WebUI `Session`, surfaces a "New conversation in worktree" action in the workspace menu, and shows a subtle sidebar worktree indicator. Empty worktree sessions stay visible in the sidebar (the empty-session filter at `api/models.py:1067/1107` exempts sessions with a `worktree_path`). Note: the underlying Hermes Agent helper may add `.worktrees/` to the repository `.gitignore` the first time a worktree is created for that repo — operators will see a small uncommitted edit to `.gitignore` after their first worktree session. Cleanup lifecycle (auto-remove on session delete/archive) is deliberately deferred to a follow-up PR — needs explicit safeguards for active streams, terminals, dirty files, and unpushed commits. Closes #1955.
|
||||
|
||||
- **PR #1970** by @dobby-d-elf — First-class LM Studio provider support with live model discovery. A dedicated `elif pid == "lmstudio":` branch in `get_available_models()` calls `hermes_cli.provider_model_ids("lmstudio")` first, falling back to a direct GET `<base_url>/models` request when env vars (`LM_API_KEY` + `LM_BASE_URL`) haven't been injected yet — this fixes the race where the provider's `.env` isn't loaded into `os.environ` before the picker runs. Detection in `detected_providers` now also fires on `LM_API_KEY` + `LM_BASE_URL` env vars and on `cfg["providers"]["lmstudio"]` config entries. The new `_get_provider_base_url()` helper plus the change to `resolve_model_provider()` from `return bare_model, provider_hint, None` to `return bare_model, provider_hint, _get_provider_base_url(provider_hint)` lets users with `providers.<id>.base_url` in `config.yaml` flow that URL through model resolution consistently (pre-fix they had to also set it under `cfg["model"]`). The "Configured" badge code from the initial PR submission was dropped per maintainer review — see PR #1970 thread for the UX discussion.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2048** by @Hinotoi-agent — `[security]` Session import validates `workspace` field against `resolve_trusted_workspace()`. Pre-fix, a crafted JSON import with `"workspace": "/"` was persisted as the `Session.workspace`, after which `/api/file?session_id=<sid>&path=etc/hosts` resolved against `/` and served host files. The patch routes the imported value through the same resolver every other workspace-bearing endpoint already uses (`/api/session/new`, `/api/branch`, `/api/fork`, `/api/clone`), returning 400 on `ValueError` (blocked system root) or `TypeError` (non-path workspace value like `{"not": "a path"}`). Severity is highest on `0.0.0.0`-bound / reverse-proxied / LAN-exposed deployments with password auth where `PR:L` applies — there the bug turned "authenticated session creation" into "authenticated read of any process-readable file." Default loopback-only deployments without auth were lower risk because anyone on loopback can usually read `/etc/hosts` directly. Includes 105 LOC of regression coverage in `tests/test_session_import_workspace_validation.py` and a belt-and-braces invariant test against the resolver itself.
|
||||
|
||||
- **PR #2055** by @franksong2702 — Duplicate assistant transcript merge. `_merge_display_messages_after_agent_result()` at `api/streaming.py:1754` now skips adjacent duplicate assistant messages by merge identity (`role + content + tool_call_id + json.dumps(tool_calls, sort_keys=True)`). Some provider/result replay paths produced two copies of the same assistant bubble in the current delta, which then got persisted into `s.messages` and sent back to the browser in the `done` SSE payload, producing duplicate assistant chat bubbles. The guard is intentionally adjacent-only so two separate turns that happen to produce identical assistant text remain visible — confirmed via the new negative-path test. Closes #2051.
|
||||
|
||||
### Fixed (maintainer review on stage-337)
|
||||
|
||||
- **PR #1970 lmstudio regression** — the new lmstudio branch in `get_available_models()` only looked at `cfg["providers"]["lmstudio"]["base_url"]`, missing the historical config shape where users put `base_url` under `cfg["model"]` when `model.provider == lmstudio`. Three pre-existing tests in `tests/test_issue1527_lmstudio_base_url_classification.py` broke on stage-337 because of this gap. The fix enhances `_get_provider_base_url()` to fall back to `cfg["model"]["base_url"]` when `cfg["model"]["provider"]` matches the requested provider id, then routes the lmstudio branch through the helper. Belt-and-suspenders negative-case test asserts `model.base_url` does NOT leak to non-active providers (so a user with `model.provider: anthropic` + `model.base_url: <anthropic-proxy>` + `providers.openai` without base_url still gets None for openai, not the anthropic proxy URL). 6 new regression tests in `tests/test_pr1970_lmstudio_base_url_fallback.py`.
|
||||
|
||||
- **PR #2053 × PR #2041 state.db worktree recovery silent data loss** — Opus advisor caught this during stage review. PR #2041 (v0.51.42) added state.db sidecar reconciliation that rebuilds a missing `<sid>.json` from the canonical state.db row. PR #2053 added worktree-backed sessions with new metadata fields. `_state_db_row_to_sidecar()` was hard-coding `'workspace': ''` and not propagating `worktree_path` / `worktree_branch` / `worktree_repo_root` / `worktree_created_at` / `message_count` from the row to the rebuilt sidecar. Result: a worktree-backed session that lost its JSON sidecar and got rebuilt from state.db disappeared from the sidebar (the empty-session filter at `api/models.py:1067` exempts sessions with `worktree_path`, but the rebuilt sidecar had none) and downstream tools (terminal panels, file pickers using `s.workspace`) operated on empty string. Fix: extend the `_read_state_db_missing_sidecar_rows()` SELECT to include the missing columns (each gated by `_sql_optional_col()` for older state.db schemas) and propagate them in `_state_db_row_to_sidecar()`. Three new regression tests in `tests/test_state_db_worktree_recovery.py` lock the round-trip, the non-worktree no-spurious-propagation case, and the empty-worktree-session-must-stay-visible invariant.
|
||||
|
||||
### Test infrastructure
|
||||
|
||||
- **Hermetic network isolation across the whole test suite.** Before this release, an accidentally-leaking outbound TLS handshake from the test_server fixture (Anthropic IPv6, Amazon, OpenRouter, observed via `ss -tnp` during stage-337 debugging) was adding 60+s of wall-time to pytest runs and creating a class of flaky failures. Two new layers now enforce no-outbound by default:
|
||||
|
||||
1. **Pytest process** (tests/conftest.py module-level monkey-patch on `socket.create_connection` + `socket.socket.connect`). Allowed destinations: loopback (`127.0.0.0/8`, `::1`), RFC1918 (`10/8`, `172.16/12`, `192.168/16`), link-local (`169.254/16`), RFC5737 TEST-NET-3 (`203.0.113/24`), RFC2606 reserved TLDs (`.invalid`, `.test`, `.example`, `.local`, `localhost`). Everything else raises `OSError("hermes test network isolation")`. Tests that legitimately need real outbound opt back in via the new `allow_outbound_network` fixture (zero current callers).
|
||||
|
||||
2. **test_server subprocess** (server.py). `HERMES_WEBUI_TEST_NETWORK_BLOCK=1` env var (set by tests/conftest.py on every spawn) activates an identical guard at the top of server.py at import time, before any api/* module loads. The env var is unset in production, so the guard is a no-op outside the test harness. Without this, the pytest-side block didn't cover the spawned subprocess.
|
||||
|
||||
- **`test_dns_resolution_failure` refactored** to mock `socket.getaddrinfo` raising `gaierror` instead of relying on real DNS for a `*.invalid` hostname. Hermetic now, and matches the mock-based pattern every other test in the same file uses.
|
||||
|
||||
- **`tests/test_conftest_network_isolation.py`** with 9 adversarial tests proving (a) outbound to the exact Anthropic IPv6 + Amazon IPv4 + Google DNS destinations we observed leaking is now blocked, (b) loopback / RFC1918 / link-local / reserved-TLD destinations pass through, (c) the `allow_outbound_network` opt-in fixture works.
|
||||
|
||||
### Tests
|
||||
|
||||
5,166 → **5,192 collected** (+26 net new across the 4 new regression test files). All passing on Python 3.11/3.12/3.13. Full suite wall-time: 161s → **95s** (the previously-leaking outbound TLS handshakes were the long tail).
|
||||
|
||||
### Contributors
|
||||
|
||||
@Hinotoi-agent (×1, first contribution) · @franksong2702 (×3) · @dobby-d-elf (×1, first contribution) · @nesquena (3 maintainer review fixes)
|
||||
|
||||
### Notes
|
||||
|
||||
- The state.db × worktree recovery interaction (PR #2053 × PR #2041) is the second case in two releases where Opus advisor caught a real cross-PR data-loss bug that neither PR's individual test suite would have surfaced (the first was the v0.51.43 CSS breakpoint asymmetry). The pattern is worth its weight — cross-PR adversarial review with grep-grounded prompts catches what unit tests miss when the failure mode lives at the seam between two features.
|
||||
|
||||
- LM Studio support is now first-class. Live model discovery + base URL discovery from either `providers.<id>.base_url` OR `cfg["model"]["base_url"]` (when `model.provider` matches) means users with either config shape get a populated model picker without manual `config.yaml` edits.
|
||||
|
||||
## [v0.51.43] — 2026-05-11 — Release S (fused community PR — desktop sidebar collapse)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -131,8 +131,10 @@ The bootstrap will:
|
||||
|
||||
> Native Windows is not supported for this bootstrap yet. Use Linux, macOS, or WSL2.
|
||||
> For Windows / WSL auto-start at login, see [`docs/wsl-autostart.md`](docs/wsl-autostart.md).
|
||||
> A community-maintained native Windows guide is tracked in [#1952](https://github.com/nesquena/hermes-webui/issues/1952).
|
||||
|
||||
If provider setup is still incomplete after install, the onboarding wizard will point you to finish it with `hermes model` instead of trying to replicate the full CLI setup in-browser.
|
||||
For a step-by-step walkthrough of the wizard, provider choices, local model server Base URLs, and safe re-runs, see [`docs/onboarding.md`](docs/onboarding.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -231,7 +233,7 @@ For the deep dive on each of these, see [`docs/docker.md`](docs/docker.md).
|
||||
|---|---|
|
||||
| Hermes agent dir | `HERMES_WEBUI_AGENT_DIR` env, then `~/.hermes/hermes-agent`, then sibling `../hermes-agent` |
|
||||
| Python executable | Agent venv first, then `.venv` in this repo, then system `python3` |
|
||||
| State directory | `HERMES_WEBUI_STATE_DIR` env, then `~/.hermes/webui-mvp` |
|
||||
| State directory | `HERMES_WEBUI_STATE_DIR` env, then `~/.hermes/webui` |
|
||||
| Default workspace | `HERMES_WEBUI_DEFAULT_WORKSPACE` env, then `~/workspace`, then state dir |
|
||||
| Port | `HERMES_WEBUI_PORT` env or first argument, default `8787` |
|
||||
|
||||
@@ -263,7 +265,7 @@ Full list of environment variables:
|
||||
| `HERMES_WEBUI_PYTHON` | auto-discovered | Python executable |
|
||||
| `HERMES_WEBUI_HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for all IPv4, `::` for all IPv6, `::1` for IPv6 loopback) |
|
||||
| `HERMES_WEBUI_PORT` | `8787` | Port |
|
||||
| `HERMES_WEBUI_STATE_DIR` | `~/.hermes/webui-mvp` | Where sessions and state are stored |
|
||||
| `HERMES_WEBUI_STATE_DIR` | `~/.hermes/webui` | Where sessions and state are stored |
|
||||
| `HERMES_WEBUI_DEFAULT_WORKSPACE` | `~/workspace` | Default workspace |
|
||||
| `HERMES_WEBUI_DEFAULT_MODEL` | `openai/gpt-5.4-mini` | Default model |
|
||||
| `HERMES_WEBUI_PASSWORD` | *(unset)* | Set to enable password authentication |
|
||||
@@ -521,7 +523,7 @@ docker-compose.yml Compose with named volume and optional auth
|
||||
.github/workflows/ CI: multi-arch Docker build + GitHub Release on tag
|
||||
```
|
||||
|
||||
State lives outside the repo at `~/.hermes/webui-mvp/` by default
|
||||
State lives outside the repo at `~/.hermes/webui/` by default
|
||||
(sessions, workspaces, settings, projects, last_workspace). Override with `HERMES_WEBUI_STATE_DIR`.
|
||||
|
||||
---
|
||||
@@ -535,6 +537,7 @@ State lives outside the repo at `~/.hermes/webui-mvp/` by default
|
||||
- `CHANGELOG.md` -- release notes per sprint
|
||||
- `SPRINTS.md` -- forward sprint plan with CLI + Claude parity targets
|
||||
- `THEMES.md` -- theme system documentation, custom theme guide
|
||||
- `docs/onboarding.md` -- first-run wizard, provider setup, local model server Base URLs, and safe re-runs
|
||||
- `docs/troubleshooting.md` -- diagnostic flows for common failures (e.g. "AIAgent not available")
|
||||
|
||||
## Contributors
|
||||
|
||||
+87
-1
@@ -1479,6 +1479,33 @@ def _custom_slug_rest_looks_like_host_port(rest: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _get_provider_base_url(provider_id):
|
||||
"""Look up the configured base_url for a provider (e.g. lmstudio).
|
||||
|
||||
Checks two locations, in order:
|
||||
1. ``cfg["providers"][<provider_id>]["base_url"]`` — the explicit
|
||||
per-provider override.
|
||||
2. ``cfg["model"]["base_url"]`` — falls back here when
|
||||
``cfg["model"]["provider"] == provider_id``. This is the historical
|
||||
shape (the model block carries both the active provider AND the
|
||||
base URL for that provider in a single record).
|
||||
|
||||
Returns the URL stripped of trailing ``/`` if configured, otherwise None.
|
||||
"""
|
||||
prov_cfg = cfg.get("providers", {}).get(provider_id, {}) or {}
|
||||
explicit = (prov_cfg.get("base_url") or "").strip().rstrip("/")
|
||||
if explicit:
|
||||
return explicit
|
||||
model_cfg = cfg.get("model", {}) or {}
|
||||
if isinstance(model_cfg, dict):
|
||||
model_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
if model_provider == str(provider_id).strip().lower():
|
||||
model_base = (model_cfg.get("base_url") or "").strip().rstrip("/")
|
||||
if model_base:
|
||||
return model_base
|
||||
return None
|
||||
|
||||
|
||||
def resolve_model_provider(model_id: str) -> tuple:
|
||||
"""Resolve model name, provider, and base_url for AIAgent.
|
||||
|
||||
@@ -1601,7 +1628,7 @@ def resolve_model_provider(model_id: str) -> tuple:
|
||||
and provider_hint not in _PROVIDER_DISPLAY
|
||||
and not provider_hint.startswith("custom:")):
|
||||
provider_hint, bare_model = inner.split(":", 1)
|
||||
return bare_model, provider_hint, None
|
||||
return bare_model, provider_hint, _get_provider_base_url(provider_hint)
|
||||
|
||||
if "/" in model_id:
|
||||
prefix, bare = model_id.split("/", 1)
|
||||
@@ -2806,6 +2833,9 @@ def get_available_models() -> dict:
|
||||
detected_providers.add("opencode-zen")
|
||||
if all_env.get("OPENCODE_GO_API_KEY"):
|
||||
detected_providers.add("opencode-go")
|
||||
# LM Studio: detect via LM_API_KEY + LM_BASE_URL in ~/.hermes/.env
|
||||
if all_env.get("LM_API_KEY") and all_env.get("LM_BASE_URL"):
|
||||
detected_providers.add("lmstudio")
|
||||
|
||||
# Also detect providers explicitly listed in config.yaml providers section.
|
||||
# A user may configure a provider key via config.yaml providers.<name>.api_key
|
||||
@@ -3392,6 +3422,62 @@ def get_available_models() -> dict:
|
||||
if extras:
|
||||
group_entry["extra_models"] = extras
|
||||
groups.append(group_entry)
|
||||
elif pid == "lmstudio":
|
||||
# LM Studio is a local server — fetch live loaded models via
|
||||
# the OpenAI-compatible /v1/models endpoint (#WebUI).
|
||||
#
|
||||
# Two-tier lookup, each in its own try so a failure in one
|
||||
# does not abort the other (the bug pattern that broke
|
||||
# tests/test_issue1527_lmstudio_base_url_classification on
|
||||
# CI environments where hermes_cli isn't importable —
|
||||
# ImportError in the cli tier was hijacking the whole
|
||||
# branch and silently skipping the urlopen fallback).
|
||||
raw_models = []
|
||||
lm_ids: list[str] = []
|
||||
try:
|
||||
from hermes_cli.models import provider_model_ids as _provider_model_ids
|
||||
lm_ids = _provider_model_ids("lmstudio") or []
|
||||
except Exception:
|
||||
logger.debug("hermes_cli LM Studio lookup unavailable; using urlopen fallback")
|
||||
|
||||
if lm_ids:
|
||||
raw_models = [{"id": mid, "label": mid} for mid in lm_ids]
|
||||
else:
|
||||
# Fallback: fetch /models directly from the configured
|
||||
# base URL. Looks for the URL in either
|
||||
# `cfg["providers"]["lmstudio"]["base_url"]` or
|
||||
# `cfg["model"]["base_url"]` (via _get_provider_base_url),
|
||||
# so the historical model-block config shape still works.
|
||||
lm_cfg = cfg.get("providers", {}).get("lmstudio", {}) or {}
|
||||
lm_base_url = _get_provider_base_url("lmstudio") or ""
|
||||
lm_api_key = str(lm_cfg.get("api_key") or "").strip() if isinstance(lm_cfg, dict) else ""
|
||||
if lm_base_url:
|
||||
headers = {"User-Agent": "OpenAI/Python 1.0"}
|
||||
if lm_api_key:
|
||||
headers["Authorization"] = f"Bearer {lm_api_key}"
|
||||
endpoint = (lm_base_url + "/models").rstrip("/")
|
||||
try:
|
||||
import urllib.request as _urlreq
|
||||
req = _urlreq.Request(endpoint, method="GET", headers=headers)
|
||||
with _urlreq.urlopen(req, timeout=5) as resp:
|
||||
lm_data = json.loads(resp.read().decode())
|
||||
for m in (lm_data.get("data") or []):
|
||||
if isinstance(m, dict):
|
||||
mid = str(m.get("id") or "").strip()
|
||||
if mid and {"id": mid, "label": mid} not in raw_models:
|
||||
raw_models.append({"id": mid, "label": mid})
|
||||
except Exception:
|
||||
logger.debug("LM Studio /models fetch failed at %s", endpoint)
|
||||
|
||||
if raw_models:
|
||||
models = _apply_provider_prefix(raw_models, pid, active_provider)
|
||||
groups.append(
|
||||
{
|
||||
"provider": provider_name,
|
||||
"provider_id": pid,
|
||||
"models": models,
|
||||
}
|
||||
)
|
||||
elif pid in _PROVIDER_MODELS or pid in cfg.get("providers", {}):
|
||||
provider_cfg = cfg.get("providers", {}).get(pid, {})
|
||||
raw_models = []
|
||||
|
||||
+30
-3
@@ -335,6 +335,10 @@ class Session:
|
||||
gateway_routing=None, gateway_routing_history=None,
|
||||
llm_title_generated: bool=False,
|
||||
parent_session_id: str=None,
|
||||
worktree_path=None,
|
||||
worktree_branch=None,
|
||||
worktree_repo_root=None,
|
||||
worktree_created_at=None,
|
||||
enabled_toolsets=None,
|
||||
composer_draft=None,
|
||||
**kwargs):
|
||||
@@ -370,6 +374,10 @@ class Session:
|
||||
self.gateway_routing_history = gateway_routing_history if isinstance(gateway_routing_history, list) else []
|
||||
self.llm_title_generated = bool(llm_title_generated)
|
||||
self.parent_session_id = parent_session_id
|
||||
self.worktree_path = str(Path(worktree_path).expanduser().resolve()) if worktree_path else None
|
||||
self.worktree_branch = str(worktree_branch) if worktree_branch else None
|
||||
self.worktree_repo_root = str(Path(worktree_repo_root).expanduser().resolve()) if worktree_repo_root else None
|
||||
self.worktree_created_at = worktree_created_at
|
||||
self.is_cli_session = bool(kwargs.get('is_cli_session', False))
|
||||
self.source_tag = kwargs.get('source_tag')
|
||||
self.raw_source = kwargs.get('raw_source')
|
||||
@@ -417,6 +425,7 @@ class Session:
|
||||
'context_length', 'threshold_tokens', 'last_prompt_tokens',
|
||||
'gateway_routing', 'gateway_routing_history', 'llm_title_generated',
|
||||
'parent_session_id',
|
||||
'worktree_path', 'worktree_branch', 'worktree_repo_root', 'worktree_created_at',
|
||||
'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label',
|
||||
'enabled_toolsets', 'composer_draft',
|
||||
]
|
||||
@@ -584,6 +593,12 @@ class Session:
|
||||
# Only emit 'parent_session_id' when set (the /branch fork link, #1342).
|
||||
# Sessions without a fork must not leak None — see test_session_lineage_metadata_api.
|
||||
**({'parent_session_id': self.parent_session_id} if self.parent_session_id else {}),
|
||||
**({
|
||||
'worktree_path': self.worktree_path,
|
||||
'worktree_branch': self.worktree_branch,
|
||||
'worktree_repo_root': self.worktree_repo_root,
|
||||
'worktree_created_at': self.worktree_created_at,
|
||||
} if self.worktree_path else {}),
|
||||
'user_message_count': sum(
|
||||
1 for message in self.messages if _message_role(message) == 'user'
|
||||
) if isinstance(self.messages, list) else 0,
|
||||
@@ -896,7 +911,7 @@ def get_session(sid, metadata_only=False):
|
||||
return s
|
||||
raise KeyError(sid)
|
||||
|
||||
def new_session(workspace=None, model=None, profile=None, model_provider=None, project_id=None):
|
||||
def new_session(workspace=None, model=None, profile=None, model_provider=None, project_id=None, worktree_info=None):
|
||||
"""Create a new in-memory session.
|
||||
|
||||
The session lives in the SESSIONS dict only — no disk write happens until
|
||||
@@ -911,7 +926,9 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
|
||||
|
||||
Crash-safety: if the process exits between session creation and first
|
||||
message, the session is lost. Since it had no messages, there is
|
||||
nothing to lose.
|
||||
nothing to lose. Worktree-backed sessions are the exception: they are
|
||||
saved immediately because creating the session also creates real
|
||||
filesystem state that must remain discoverable after restart.
|
||||
|
||||
*profile* — when supplied by the caller (e.g. from the request body sent
|
||||
by the active browser tab), it is used directly so that concurrent clients
|
||||
@@ -927,18 +944,26 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
|
||||
except ImportError:
|
||||
profile = None
|
||||
effective_model = model or get_effective_default_model()
|
||||
wt = worktree_info if isinstance(worktree_info, dict) else None
|
||||
workspace_path = (wt.get('path') if wt and wt.get('path') else workspace) if wt else workspace
|
||||
s = Session(
|
||||
workspace=workspace or get_last_workspace(),
|
||||
workspace=workspace_path or get_last_workspace(),
|
||||
model=effective_model,
|
||||
model_provider=model_provider,
|
||||
profile=profile,
|
||||
project_id=project_id,
|
||||
worktree_path=wt.get('path') if wt else None,
|
||||
worktree_branch=wt.get('branch') if wt else None,
|
||||
worktree_repo_root=wt.get('repo_root') if wt else None,
|
||||
worktree_created_at=wt.get('created_at') if wt else None,
|
||||
)
|
||||
with LOCK:
|
||||
SESSIONS[s.session_id] = s
|
||||
SESSIONS.move_to_end(s.session_id)
|
||||
while len(SESSIONS) > SESSIONS_MAX:
|
||||
SESSIONS.popitem(last=False)
|
||||
if wt:
|
||||
s.save()
|
||||
return s
|
||||
|
||||
def _hide_from_default_sidebar(session: dict) -> bool:
|
||||
@@ -1042,6 +1067,7 @@ def all_sessions(diag=None):
|
||||
and s.get('message_count', 0) == 0
|
||||
and not s.get('active_stream_id')
|
||||
and not s.get('has_pending_user_message')
|
||||
and not s.get('worktree_path')
|
||||
)]
|
||||
result = [s for s in result if not _hide_from_default_sidebar(s)]
|
||||
# Backfill: sessions created before Sprint 22 have no profile tag.
|
||||
@@ -1077,6 +1103,7 @@ def all_sessions(diag=None):
|
||||
and len(s.messages) == 0
|
||||
and not s.active_stream_id
|
||||
and not s.pending_user_message
|
||||
and not getattr(s, 'worktree_path', None)
|
||||
)]
|
||||
result = [s for s in result if not _hide_from_default_sidebar(s)]
|
||||
for s in result:
|
||||
|
||||
@@ -978,6 +978,18 @@ def get_providers() -> dict[str, Any]:
|
||||
models_total = len(live_ids)
|
||||
except Exception:
|
||||
logger.debug("Failed to load Nous Portal models from hermes_cli")
|
||||
# LM Studio: fetch live locally-loaded models so the providers card
|
||||
# matches what's actually available on the user's server (#WebUI).
|
||||
if pid == "lmstudio":
|
||||
try:
|
||||
from hermes_cli.models import provider_model_ids as _pmi
|
||||
|
||||
lm_live = _pmi("lmstudio") or []
|
||||
if lm_live:
|
||||
models = [{"id": mid, "label": mid} for mid in lm_live]
|
||||
models_total = len(models)
|
||||
except Exception:
|
||||
logger.debug("Failed to load LM Studio models from hermes_cli")
|
||||
# Also include models from config.yaml providers section
|
||||
if isinstance(providers_cfg, dict):
|
||||
provider_cfg = providers_cfg.get(pid, {})
|
||||
|
||||
+24
-2
@@ -3847,8 +3847,26 @@ def handle_post(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/session/new":
|
||||
try:
|
||||
workspace = str(resolve_trusted_workspace(body.get("workspace"))) if body.get("workspace") else None
|
||||
except ValueError as e:
|
||||
except (TypeError, ValueError) as e:
|
||||
return bad(handler, str(e))
|
||||
worktree_info = None
|
||||
worktree_requested = (
|
||||
body.get("worktree") is True
|
||||
or str(body.get("worktree")).strip().lower() in {"1", "true", "yes", "on"}
|
||||
)
|
||||
if worktree_requested:
|
||||
try:
|
||||
from api.worktrees import create_worktree_for_workspace
|
||||
base_workspace = workspace
|
||||
if not base_workspace:
|
||||
base_workspace = str(resolve_trusted_workspace(get_last_workspace()))
|
||||
worktree_info = create_worktree_for_workspace(base_workspace)
|
||||
workspace = worktree_info["path"]
|
||||
except (TypeError, ValueError) as e:
|
||||
return bad(handler, str(e), status=400)
|
||||
except Exception as e:
|
||||
logger.exception("failed to create worktree-backed session")
|
||||
return bad(handler, f"Failed to create worktree: {e}", status=500)
|
||||
model, model_provider = _session_model_state_from_request(
|
||||
body.get("model"),
|
||||
body.get("model_provider"),
|
||||
@@ -3861,6 +3879,7 @@ def handle_post(handler, parsed) -> bool:
|
||||
model_provider=model_provider,
|
||||
profile=body.get("profile") or None,
|
||||
project_id=body.get("project_id") or None,
|
||||
worktree_info=worktree_info,
|
||||
)
|
||||
return j(handler, {"session": s.compact() | {"messages": s.messages}})
|
||||
|
||||
@@ -8858,7 +8877,10 @@ def _handle_session_import(handler, body):
|
||||
if not isinstance(messages, list):
|
||||
return bad(handler, 'JSON must contain a "messages" array')
|
||||
title = body.get("title", "Imported session")
|
||||
workspace = body.get("workspace", str(DEFAULT_WORKSPACE))
|
||||
try:
|
||||
workspace = str(resolve_trusted_workspace(body.get("workspace", str(DEFAULT_WORKSPACE))))
|
||||
except (TypeError, ValueError) as e:
|
||||
return bad(handler, str(e))
|
||||
model = body.get("model", DEFAULT_MODEL)
|
||||
s = Session(
|
||||
title=title,
|
||||
|
||||
+15
-2
@@ -193,11 +193,18 @@ def _read_state_db_missing_sidecar_rows(session_dir: Path, state_db_path: Path |
|
||||
started_expr = _sql_optional_col('started_at', session_cols, '0')
|
||||
parent_expr = _sql_optional_col('parent_session_id', session_cols)
|
||||
msg_count_expr = _sql_optional_col('message_count', session_cols, '0')
|
||||
workspace_expr = _sql_optional_col('workspace', session_cols)
|
||||
worktree_path_expr = _sql_optional_col('worktree_path', session_cols)
|
||||
worktree_branch_expr = _sql_optional_col('worktree_branch', session_cols)
|
||||
worktree_repo_root_expr = _sql_optional_col('worktree_repo_root', session_cols)
|
||||
worktree_created_at_expr = _sql_optional_col('worktree_created_at', session_cols)
|
||||
rows = []
|
||||
for row in conn.execute(
|
||||
f"""
|
||||
SELECT id, source, {title_expr}, {model_expr}, {started_expr},
|
||||
{parent_expr}, {msg_count_expr}
|
||||
{parent_expr}, {msg_count_expr}, {workspace_expr},
|
||||
{worktree_path_expr}, {worktree_branch_expr},
|
||||
{worktree_repo_root_expr}, {worktree_created_at_expr}
|
||||
FROM sessions
|
||||
WHERE source = 'webui'
|
||||
ORDER BY COALESCE(started_at, 0) DESC
|
||||
@@ -250,10 +257,16 @@ def _state_db_row_to_sidecar(row: dict) -> dict:
|
||||
started_at = row.get('started_at') or 0
|
||||
messages = row.get('messages') if isinstance(row.get('messages'), list) else []
|
||||
last_ts = messages[-1].get('timestamp') if messages and isinstance(messages[-1], dict) else started_at
|
||||
workspace_value = row.get('workspace') or ''
|
||||
return {
|
||||
'session_id': row.get('id'),
|
||||
'title': row.get('title') or 'Recovered WebUI Session',
|
||||
'workspace': '',
|
||||
'workspace': workspace_value if isinstance(workspace_value, str) else '',
|
||||
'message_count': row.get('message_count') if isinstance(row.get('message_count'), int) else len(messages),
|
||||
'worktree_path': row.get('worktree_path') or None,
|
||||
'worktree_branch': row.get('worktree_branch') or None,
|
||||
'worktree_repo_root': row.get('worktree_repo_root') or None,
|
||||
'worktree_created_at': row.get('worktree_created_at') or None,
|
||||
'model': row.get('model') or 'unknown',
|
||||
'model_provider': None,
|
||||
'created_at': started_at,
|
||||
|
||||
@@ -1753,6 +1753,18 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
|
||||
# in result_messages, keep the durable checkpoint and append only
|
||||
# the assistant/tool delta.
|
||||
continue
|
||||
if (
|
||||
key is not None
|
||||
and isinstance(msg, dict)
|
||||
and msg.get('role') == 'assistant'
|
||||
and merged
|
||||
and _message_identity(merged[-1]) == key
|
||||
):
|
||||
# Some provider/result replay paths can include the same assistant
|
||||
# message twice in the current delta. Treat only adjacent identity
|
||||
# matches as replay duplicates so identical answers in separate
|
||||
# user turns remain visible.
|
||||
continue
|
||||
if _is_context_compression_marker(msg) and key is not None and key in seen:
|
||||
continue
|
||||
display_msg = msg
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Helpers for WebUI-managed Hermes Agent git worktrees."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def find_git_repo_root(workspace: str | Path) -> Path:
|
||||
"""Return the enclosing git repo root for *workspace*.
|
||||
|
||||
Use git itself instead of checking ``workspace/.git`` so nested workspaces
|
||||
and linked git worktrees are both handled correctly.
|
||||
"""
|
||||
ws = Path(workspace).expanduser().resolve()
|
||||
if not ws.is_dir():
|
||||
raise ValueError("Workspace path does not exist or is not a directory")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
cwd=ws,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise ValueError("Workspace is not inside a git repository") from exc
|
||||
if result.returncode != 0:
|
||||
raise ValueError("Workspace is not inside a git repository")
|
||||
root = result.stdout.strip()
|
||||
if not root:
|
||||
raise ValueError("Workspace is not inside a git repository")
|
||||
return Path(root).expanduser().resolve()
|
||||
|
||||
|
||||
def _setup_agent_worktree(repo_root: str) -> dict:
|
||||
try:
|
||||
import api.config # noqa: F401 # ensure Hermes Agent dir is on sys.path
|
||||
from cli import _setup_worktree
|
||||
except Exception as exc:
|
||||
raise RuntimeError("Hermes Agent worktree helper is unavailable") from exc
|
||||
output = StringIO()
|
||||
with redirect_stdout(output), redirect_stderr(output):
|
||||
info = _setup_worktree(repo_root)
|
||||
emitted = output.getvalue().strip()
|
||||
if emitted:
|
||||
logger.debug("Hermes Agent worktree helper output: %s", emitted)
|
||||
if not info:
|
||||
raise RuntimeError("Hermes Agent failed to create a git worktree")
|
||||
return info
|
||||
|
||||
|
||||
def create_worktree_for_workspace(workspace: str | Path) -> dict:
|
||||
repo_root = find_git_repo_root(workspace)
|
||||
info = _setup_agent_worktree(str(repo_root))
|
||||
path = info.get("path")
|
||||
branch = info.get("branch")
|
||||
if not path or not branch:
|
||||
raise RuntimeError("Hermes Agent returned incomplete worktree metadata")
|
||||
return {
|
||||
"path": str(Path(path).expanduser().resolve()),
|
||||
"branch": str(branch),
|
||||
"repo_root": str(Path(info.get("repo_root") or repo_root).expanduser().resolve()),
|
||||
"created_at": time.time(),
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
# First-run onboarding guide
|
||||
|
||||
This guide explains what happens the first time Hermes WebUI starts, which
|
||||
setup path to choose, and how to recover when the wizard cannot finish.
|
||||
|
||||
The short version: run the bootstrap, open the WebUI, choose a provider, choose
|
||||
a workspace, optionally set a password, then start a chat. If you are using a
|
||||
local model server from Docker, pay special attention to the Base URL section
|
||||
below.
|
||||
|
||||
## Before you start
|
||||
|
||||
Hermes WebUI is only the browser interface. The actual agent runtime, memory,
|
||||
skills, config, cron jobs, and provider credentials belong to Hermes Agent.
|
||||
|
||||
The bootstrap supports Linux, macOS, and WSL2. Native Windows is not supported
|
||||
by the bootstrap yet. A community native Windows setup is being tracked in
|
||||
[#1952](https://github.com/nesquena/hermes-webui/issues/1952), including:
|
||||
|
||||
- [Native Windows guide](https://github.com/markwang2658/hermes-windows-native-guide)
|
||||
- [Native Windows setup scripts](https://github.com/markwang2658/hermes-windows-native)
|
||||
|
||||
For Windows users who want the supported path today, use WSL2 and see
|
||||
[Windows / WSL auto-start](wsl-autostart.md).
|
||||
|
||||
## Install path choices
|
||||
|
||||
| Path | Use it when | Notes |
|
||||
|---|---|---|
|
||||
| Local bootstrap | You run WebUI directly on Linux, macOS, or WSL2 | Best for a personal server, Mac mini, VPS, or homelab host. |
|
||||
| Docker single-container | You want the simplest container setup | Recommended first Docker path. WebUI runs the agent in-process. |
|
||||
| Docker two-container | You already run the agent gateway separately | More isolated, but tools launched from WebUI run in the WebUI container. |
|
||||
| Docker three-container | You want agent gateway plus dashboard plus WebUI | Same caveats as two-container, plus the dashboard service. |
|
||||
| Native Windows community path | You are intentionally testing unsupported native Windows | Community-maintained for now, not the official bootstrap path. |
|
||||
|
||||
If a Docker install gets confusing, start again with the single-container setup.
|
||||
It avoids most UID/GID, source-volume, and tool-location surprises. See
|
||||
[Docker setup guide](docker.md) for the full container reference.
|
||||
|
||||
## Re-running onboarding safely
|
||||
|
||||
Do not delete `~/.hermes` just to see the wizard again. That directory can hold
|
||||
your real Hermes config, credentials, memory, skills, profiles, sessions, and
|
||||
cron state.
|
||||
|
||||
For a clean local trial, use an isolated Hermes home and WebUI state directory:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/hermes-onboarding-test
|
||||
HERMES_HOME=~/hermes-onboarding-test/.hermes \
|
||||
HERMES_WEBUI_STATE_DIR=~/hermes-onboarding-test/webui \
|
||||
HERMES_WEBUI_PORT=8789 \
|
||||
python3 bootstrap.py
|
||||
```
|
||||
|
||||
Then open `http://127.0.0.1:8789`.
|
||||
|
||||
If your repo has a `.env` file, remember that the bootstrap loads it. Remove or
|
||||
adjust any `HERMES_HOME`, `HERMES_WEBUI_STATE_DIR`, or `HERMES_WEBUI_PORT`
|
||||
entries there before using the isolated command above.
|
||||
|
||||
For managed hosting or fully preconfigured images, set
|
||||
`HERMES_WEBUI_SKIP_ONBOARDING=1` to bypass the wizard.
|
||||
|
||||
## What the wizard checks
|
||||
|
||||
The first screen reports the runtime state WebUI can see:
|
||||
|
||||
- Hermes Agent importability: whether WebUI can import and run `AIAgent`.
|
||||
- Provider status: whether `config.yaml` and credential state are enough for a
|
||||
chat request.
|
||||
- Password status: whether WebUI password protection is enabled.
|
||||
- Config paths: the active `config.yaml` and `.env` locations for this profile.
|
||||
|
||||
If the agent check fails, use [Troubleshooting](troubleshooting.md), especially
|
||||
the `AIAgent not available` section. If provider setup is incomplete, continue
|
||||
through the wizard or run `hermes model` in the same machine environment that
|
||||
will run WebUI.
|
||||
|
||||
## Choosing a provider
|
||||
|
||||
The setup step groups providers by how much information they usually need.
|
||||
|
||||
| Group | Examples | What you usually enter |
|
||||
|---|---|---|
|
||||
| Easy start | OpenRouter, Anthropic, OpenAI | API key and model. |
|
||||
| Open / self-hosted | Ollama, LM Studio, custom OpenAI-compatible | Base URL, model, optional API key. |
|
||||
| Specialized | Gemini, DeepSeek, Xiaomi MiMo, Z.AI / GLM, NVIDIA NIM, Mistral, xAI | Provider API key and default model. |
|
||||
|
||||
For API-key providers, the wizard writes the key to the active Hermes `.env`
|
||||
file and writes the default model/provider to `config.yaml`.
|
||||
|
||||
For local providers, the API key field can be blank when the server is keyless.
|
||||
Most LM Studio, Ollama, vLLM, llama-server, and TabbyAPI installs run this way.
|
||||
Use **Test connection** to verify the Base URL and populate the model list
|
||||
before continuing.
|
||||
|
||||
Advanced provider flows such as Nous Portal and GitHub Copilot are still
|
||||
terminal-first. OpenAI Codex and Anthropic Claude Code OAuth can be started in
|
||||
the onboarding flow when your Hermes config selects the corresponding provider.
|
||||
If the wizard points you back to `hermes model`, use that CLI flow first, then
|
||||
refresh WebUI.
|
||||
|
||||
## Base URL rules for local model servers
|
||||
|
||||
For self-hosted providers, the Base URL should point to the OpenAI-compatible
|
||||
API root. Common examples:
|
||||
|
||||
| Server | Typical Base URL |
|
||||
|---|---|
|
||||
| LM Studio on the same non-Docker host | `http://127.0.0.1:1234/v1` |
|
||||
| Ollama on the same non-Docker host | `http://127.0.0.1:11434/v1` |
|
||||
| LM Studio from Docker Desktop | `http://host.docker.internal:1234/v1` |
|
||||
| Ollama from Docker Desktop | `http://host.docker.internal:11434/v1` |
|
||||
| Local server on another LAN machine | `http://<lan-ip>:<port>/v1` |
|
||||
|
||||
Inside Docker, `localhost` means the WebUI container itself, not your Mac,
|
||||
Windows host, or another machine on your LAN. If LM Studio or Ollama is running
|
||||
outside the container, use `host.docker.internal` on Docker Desktop or the
|
||||
server's LAN IP address.
|
||||
|
||||
The wizard probes `<base-url>/models` before saving. A successful probe fills
|
||||
the model dropdown. A failed probe blocks the setup step and shows an inline
|
||||
error such as DNS failure, connection refused, timeout, HTTP error, or
|
||||
unexpected response shape.
|
||||
|
||||
## Workspace step
|
||||
|
||||
The workspace is the filesystem location Hermes should use for new sessions.
|
||||
It can be a source checkout, a project directory, or a general workspace folder.
|
||||
|
||||
In Docker, the default browsable path is `/workspace`, which maps to the host
|
||||
directory mounted by the compose file. If the workspace appears empty, check the
|
||||
Docker UID/GID and mount guidance in [Docker setup guide](docker.md).
|
||||
|
||||
## Password step
|
||||
|
||||
Password protection is optional for localhost-only installs. Enable it if you
|
||||
expose WebUI outside `127.0.0.1`, behind a reverse proxy, or on a LAN.
|
||||
|
||||
The password is stored through the normal WebUI settings path and hashed
|
||||
server-side. You can change it later from Settings.
|
||||
|
||||
## What gets written
|
||||
|
||||
The wizard uses the same files and APIs as the normal app:
|
||||
|
||||
- Active Hermes `config.yaml`: provider, default model, and Base URL when
|
||||
relevant.
|
||||
- Active Hermes `.env`: provider API keys when you entered one.
|
||||
- WebUI `settings.json`: onboarding completion, workspace, password state, and
|
||||
other WebUI preferences.
|
||||
|
||||
State normally lives outside the repository. By default:
|
||||
|
||||
- Hermes Agent state: `~/.hermes`
|
||||
- WebUI state: `~/.hermes/webui`
|
||||
|
||||
Override these with `HERMES_HOME` and `HERMES_WEBUI_STATE_DIR` when you need an
|
||||
isolated test install.
|
||||
|
||||
## When to file an issue
|
||||
|
||||
File an issue when the diagnostics point to WebUI rather than local
|
||||
configuration. Include:
|
||||
|
||||
1. Install path: local bootstrap, Docker single-container, Docker
|
||||
two-container, Docker three-container, WSL2, or community native Windows.
|
||||
2. Output from `/health`, or the startup banner if the server never starts.
|
||||
3. The provider selected in onboarding and the Base URL shape, with secrets
|
||||
redacted.
|
||||
4. For Docker provider problems, the result of probing from inside the
|
||||
container, for example:
|
||||
|
||||
```bash
|
||||
docker exec hermes-webui sh -c 'curl -sS -w "\nHTTP %{http_code}\n" http://host.docker.internal:1234/v1/models | head -50'
|
||||
```
|
||||
|
||||
5. Any inline wizard error text and relevant logs.
|
||||
|
||||
Never paste API keys, OAuth tokens, or full `.env` contents into an issue.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 176 KiB |
@@ -4,12 +4,105 @@ Thin routing shell: imports Handler, delegates to api/routes.py, runs server.
|
||||
All business logic lives in api/*.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
# ── Test-mode network isolation ─────────────────────────────────────────────
|
||||
# When `HERMES_WEBUI_TEST_NETWORK_BLOCK=1` is set in the environment, refuse
|
||||
# outbound socket connections to anything that is not loopback / RFC1918 /
|
||||
# link-local / reserved-TLD. This catches accidental real outbound (forgotten
|
||||
# mocks, leaked credentials triggering SDK init, new code paths bypassing an
|
||||
# existing mock) so the test suite stays hermetic and fast.
|
||||
#
|
||||
# tests/conftest.py sets this env var on every test_server subprocess so the
|
||||
# server.py-side network isolation matches the pytest-process-side isolation
|
||||
# already installed there.
|
||||
#
|
||||
# A test that legitimately needs real outbound spawns the server with the env
|
||||
# var unset (no current callers — every test_server-using test should be
|
||||
# mockable).
|
||||
if os.environ.get("HERMES_WEBUI_TEST_NETWORK_BLOCK", "").strip() in ("1", "true", "yes"):
|
||||
_REAL_CREATE_CONN = socket.create_connection
|
||||
_REAL_SOCK_CONNECT = socket.socket.connect
|
||||
|
||||
import re as _re
|
||||
|
||||
def _re_match_unique_local_ipv6(h):
|
||||
"""Match IPv6 fc00::/7 (canonical syntax). Tighter than startswith('fc')
|
||||
so we don't mistakenly classify hostnames like 'food.example.com' as local."""
|
||||
return bool(_re.match(r"^f[cd][0-9a-f]{0,2}:", h))
|
||||
|
||||
def _addr_is_local(host):
|
||||
if not isinstance(host, str):
|
||||
return False
|
||||
h = host.strip().lower()
|
||||
if not h:
|
||||
return False
|
||||
# IPv6 unique-local fc00::/7: require hex pair + colon to avoid
|
||||
# matching hostnames like "food.example.com" or "fdsa.test".
|
||||
if h in ("::1", "0:0:0:0:0:0:0:1") or h.startswith("fe80:") or _re_match_unique_local_ipv6(h):
|
||||
return True
|
||||
if h == "localhost" or h.endswith(".localhost"):
|
||||
return True
|
||||
if h.endswith(".local") or h.endswith(".test") or h.endswith(".invalid"):
|
||||
return True
|
||||
if h == "example.com" or h.endswith(".example.com"):
|
||||
return True
|
||||
if h == "example.net" or h.endswith(".example.net"):
|
||||
return True
|
||||
if h == "example.org" or h.endswith(".example.org"):
|
||||
return True
|
||||
if h.endswith(".example"):
|
||||
return True
|
||||
if h and h[0].isdigit() and h.count(".") == 3:
|
||||
try:
|
||||
o1, o2, o3, o4 = [int(p) for p in h.split(".")]
|
||||
except ValueError:
|
||||
return False
|
||||
if o1 == 127:
|
||||
return True
|
||||
if o1 == 10:
|
||||
return True
|
||||
if o1 == 192 and o2 == 168:
|
||||
return True
|
||||
if o1 == 172 and 16 <= o2 <= 31:
|
||||
return True
|
||||
if o1 == 169 and o2 == 254:
|
||||
return True
|
||||
if o1 == 203 and o2 == 0 and o3 == 113:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _blocked_create_connection(address, *a, **kw):
|
||||
try:
|
||||
host = address[0]
|
||||
except (TypeError, IndexError):
|
||||
host = ""
|
||||
if _addr_is_local(host):
|
||||
return _REAL_CREATE_CONN(address, *a, **kw)
|
||||
raise OSError(
|
||||
f"hermes test network isolation (server.py): outbound to {address!r} blocked"
|
||||
)
|
||||
|
||||
def _blocked_socket_connect(self, address):
|
||||
try:
|
||||
host = address[0]
|
||||
except (TypeError, IndexError):
|
||||
host = ""
|
||||
if _addr_is_local(host):
|
||||
return _REAL_SOCK_CONNECT(self, address)
|
||||
raise OSError(
|
||||
f"hermes test network isolation (server.py): socket.connect to {address!r} blocked"
|
||||
)
|
||||
|
||||
socket.create_connection = _blocked_create_connection
|
||||
socket.socket.connect = _blocked_socket_connect
|
||||
|
||||
|
||||
try:
|
||||
import resource
|
||||
except ImportError: # pragma: no cover - resource is Unix-only
|
||||
|
||||
@@ -151,6 +151,11 @@ const LOCALES = {
|
||||
model_group_configured: 'Configured',
|
||||
ws_search_placeholder: 'Search workspaces…',
|
||||
ws_no_results: 'No workspaces found',
|
||||
workspace_new_worktree_conversation: 'New conversation in worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Create an isolated git worktree for this workspace.',
|
||||
workspace_worktree_created: 'Worktree conversation created',
|
||||
workspace_worktree_failed: 'Worktree creation failed: ',
|
||||
session_worktree_badge: 'Worktree',
|
||||
model_scope_advisory: 'Applies to this conversation from your next message.',
|
||||
model_scope_toast: 'Applies to this conversation from your next message.',
|
||||
// commands.js
|
||||
@@ -1236,6 +1241,11 @@ const LOCALES = {
|
||||
model_group_configured: '設定済み',
|
||||
ws_search_placeholder: 'ワークスペースを検索…',
|
||||
ws_no_results: 'ワークスペースが見つかりません',
|
||||
workspace_new_worktree_conversation: 'worktree で新しい会話',
|
||||
workspace_new_worktree_conversation_meta: 'このワークスペース用に隔離された git worktree を作成します。',
|
||||
workspace_worktree_created: 'worktree 会話を作成しました',
|
||||
workspace_worktree_failed: 'worktree の作成に失敗しました: ',
|
||||
session_worktree_badge: 'Worktree',
|
||||
model_scope_advisory: '次回のメッセージからこの会話に適用されます。',
|
||||
model_scope_toast: '次回のメッセージからこの会話に適用されます。',
|
||||
// commands.js
|
||||
@@ -2383,6 +2393,11 @@ const LOCALES = {
|
||||
model_group_configured: 'Настроенные',
|
||||
ws_search_placeholder: 'Поиск рабочих пространств…',
|
||||
ws_no_results: 'Рабочие пространства не найдены',
|
||||
workspace_new_worktree_conversation: 'Новый разговор в worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Создать изолированный git worktree для этого рабочего пространства.',
|
||||
workspace_worktree_created: 'Разговор в worktree создан',
|
||||
workspace_worktree_failed: 'Не удалось создать worktree: ',
|
||||
session_worktree_badge: 'Worktree',
|
||||
model_search_placeholder: 'Поиск моделей…',
|
||||
model_scope_advisory: 'Применяется к этой беседе со следующего сообщения.',
|
||||
session_toolsets: 'Session Toolsets', // TODO: translate
|
||||
@@ -3320,6 +3335,11 @@ const LOCALES = {
|
||||
model_group_configured: 'Configurados',
|
||||
ws_search_placeholder: 'Buscar espacios de trabajo…',
|
||||
ws_no_results: 'No se encontraron espacios de trabajo',
|
||||
workspace_new_worktree_conversation: 'Nueva conversación en worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Crear un git worktree aislado para este espacio de trabajo.',
|
||||
workspace_worktree_created: 'Conversación en worktree creada',
|
||||
workspace_worktree_failed: 'Error al crear worktree: ',
|
||||
session_worktree_badge: 'Worktree',
|
||||
session_toolsets: 'Session Toolsets', // TODO: translate
|
||||
session_toolsets_desc: 'Restrict available tools for this session (blank = use global config)', // TODO: translate
|
||||
session_toolsets_global: 'Global (default)', // TODO: translate
|
||||
@@ -4882,6 +4902,11 @@ const LOCALES = {
|
||||
model_group_configured: 'Konfiguriert',
|
||||
ws_search_placeholder: 'Arbeitsbereiche suchen…',
|
||||
ws_no_results: 'Keine Arbeitsbereiche gefunden',
|
||||
workspace_new_worktree_conversation: 'Neue Unterhaltung in Worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Erstellt einen isolierten git worktree für diesen Arbeitsbereich.',
|
||||
workspace_worktree_created: 'Worktree-Unterhaltung erstellt',
|
||||
workspace_worktree_failed: 'Worktree-Erstellung fehlgeschlagen: ',
|
||||
session_worktree_badge: 'Worktree',
|
||||
session_toolsets: 'Session Toolsets', // TODO: translate
|
||||
session_toolsets_desc: 'Restrict available tools for this session (blank = use global config)', // TODO: translate
|
||||
session_toolsets_global: 'Global (default)', // TODO: translate
|
||||
@@ -5358,6 +5383,11 @@ const LOCALES = {
|
||||
model_group_configured: '已配置',
|
||||
ws_search_placeholder: '搜索工作区…',
|
||||
ws_no_results: '未找到工作区',
|
||||
workspace_new_worktree_conversation: '在 worktree 中新建对话',
|
||||
workspace_new_worktree_conversation_meta: '为此工作区创建隔离的 git worktree。',
|
||||
workspace_worktree_created: '已创建 worktree 对话',
|
||||
workspace_worktree_failed: 'Worktree 创建失败:',
|
||||
session_worktree_badge: 'Worktree',
|
||||
session_toolsets: 'Session 工具集',
|
||||
session_toolsets_desc: '限制此会话可用工具(留空 = 使用全局配置)',
|
||||
session_toolsets_global: '全局(默认)',
|
||||
@@ -7451,6 +7481,11 @@ const LOCALES = {
|
||||
model_group_configured: 'Configurados',
|
||||
ws_search_placeholder: 'Buscar espaços de trabalho…',
|
||||
ws_no_results: 'Nenhum espaço de trabalho encontrado',
|
||||
workspace_new_worktree_conversation: 'Nova conversa em worktree',
|
||||
workspace_new_worktree_conversation_meta: 'Cria um git worktree isolado para este espaço de trabalho.',
|
||||
workspace_worktree_created: 'Conversa em worktree criada',
|
||||
workspace_worktree_failed: 'Falha ao criar worktree: ',
|
||||
session_worktree_badge: 'Worktree',
|
||||
// commands.js
|
||||
cmd_clear: 'Limpar mensagens da conversa',
|
||||
cmd_compress: 'Comprimir manualmente o contexto (uso: /compress [tópico])',
|
||||
@@ -8407,6 +8442,11 @@ const LOCALES = {
|
||||
model_group_configured: '구성됨',
|
||||
ws_search_placeholder: '워크스페이스 검색…',
|
||||
ws_no_results: '워크스페이스를 찾을 수 없습니다',
|
||||
workspace_new_worktree_conversation: 'worktree에서 새 대화',
|
||||
workspace_new_worktree_conversation_meta: '이 워크스페이스용 격리된 git worktree를 만듭니다.',
|
||||
workspace_worktree_created: 'worktree 대화가 생성되었습니다',
|
||||
workspace_worktree_failed: 'worktree 생성 실패: ',
|
||||
session_worktree_badge: 'Worktree',
|
||||
model_scope_advisory: '다음 메시지부터 이 대화에 적용됩니다.',
|
||||
model_scope_toast: '다음 메시지부터 이 대화에 적용됩니다.',
|
||||
// commands.js
|
||||
|
||||
@@ -3695,6 +3695,24 @@ function renderWorkspaceDropdownInto(dd, workspaces, currentWs){
|
||||
|
||||
// ── Footer actions ────────────────────────────────────────────────────────
|
||||
dd.appendChild(document.createElement('div')).className='ws-divider';
|
||||
dd.appendChild(_renderWorkspaceAction(
|
||||
t('workspace_new_worktree_conversation'),
|
||||
t('workspace_new_worktree_conversation_meta'),
|
||||
li('git-branch',12),
|
||||
async()=>{
|
||||
closeWsDropdown();
|
||||
try{
|
||||
await newSession(false,{worktree:true});
|
||||
await renderSessionList();
|
||||
const msg=$('msg');
|
||||
if(msg)msg.focus();
|
||||
showToast(t('workspace_worktree_created'));
|
||||
}catch(e){
|
||||
showToast(t('workspace_worktree_failed')+(e&&e.message?e.message:e),'error');
|
||||
}
|
||||
}
|
||||
));
|
||||
dd.appendChild(document.createElement('div')).className='ws-divider';
|
||||
dd.appendChild(_renderWorkspaceAction(
|
||||
t('workspace_choose_path'),
|
||||
t('workspace_choose_path_meta'),
|
||||
|
||||
+10
-1
@@ -340,7 +340,7 @@ function _markPollingCompletionUnreadTransitions(sessions) {
|
||||
}
|
||||
}
|
||||
|
||||
async function newSession(flash){
|
||||
async function newSession(flash, options={}){
|
||||
updateQueueBadge();
|
||||
S.toolCalls=[];
|
||||
clearLiveToolCards();
|
||||
@@ -371,6 +371,7 @@ async function newSession(flash){
|
||||
workspace:inheritWs,
|
||||
profile:S.activeProfile||'default',
|
||||
};
|
||||
if(options&&options.worktree) reqBody.worktree=true;
|
||||
if(_activeProject&&_activeProject!==NO_PROJECT_FILTER) reqBody.project_id=_activeProject;
|
||||
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify(reqBody)});
|
||||
S.session=data.session;S.messages=data.session.messages||[];
|
||||
@@ -2581,6 +2582,14 @@ function renderSessionListFromCache(){
|
||||
pinInd.innerHTML=ICONS.pin;
|
||||
titleRow.appendChild(pinInd);
|
||||
}
|
||||
if(s.worktree_path){
|
||||
const wtInd=document.createElement('span');
|
||||
wtInd.className='session-worktree-indicator';
|
||||
wtInd.innerHTML=li('git-branch',12);
|
||||
const wtLabel=(typeof t==='function'?t('session_worktree_badge'):'Worktree');
|
||||
wtInd.title=`${wtLabel}: ${s.worktree_branch||s.worktree_path}`;
|
||||
titleRow.appendChild(wtInd);
|
||||
}
|
||||
// Parent session indicator for forked/branched sessions (#465)
|
||||
if(s.parent_session_id){
|
||||
const branchInd=document.createElement('span');
|
||||
|
||||
+12
-3
@@ -2657,7 +2657,8 @@ main.main.showing-logs > #mainLogs{display:flex;}
|
||||
.session-pin-indicator svg{width:10px;height:10px;}
|
||||
|
||||
/* ── Fork lineage indicator (inline, subtle until row focus/hover) ── */
|
||||
.session-branch-indicator{
|
||||
.session-branch-indicator,
|
||||
.session-worktree-indicator{
|
||||
flex-shrink:0;
|
||||
width:12px;
|
||||
height:12px;
|
||||
@@ -2670,14 +2671,22 @@ main.main.showing-logs > #mainLogs{display:flex;}
|
||||
pointer-events:none;
|
||||
transition:opacity .15s ease,color .15s ease;
|
||||
}
|
||||
.session-branch-indicator svg{width:12px;height:12px;}
|
||||
.session-branch-indicator svg,
|
||||
.session-worktree-indicator svg{width:12px;height:12px;}
|
||||
.session-item:hover .session-branch-indicator,
|
||||
.session-item:hover .session-worktree-indicator,
|
||||
.session-item:focus-within .session-branch-indicator,
|
||||
.session-item:focus-within .session-worktree-indicator,
|
||||
.session-item.menu-open .session-branch-indicator{
|
||||
opacity:.85;
|
||||
color:var(--text);
|
||||
}
|
||||
.session-item.active .session-branch-indicator{color:var(--accent-text);}
|
||||
.session-item.menu-open .session-worktree-indicator{
|
||||
opacity:.85;
|
||||
color:var(--text);
|
||||
}
|
||||
.session-item.active .session-branch-indicator,
|
||||
.session-item.active .session-worktree-indicator{color:var(--accent-text);}
|
||||
|
||||
/* ── Cron alert badge ── */
|
||||
.cron-badge{position:absolute;top:2px;right:2px;background:#e53e3e;color:#fff;font-size:9px;font-weight:700;min-width:14px;height:14px;line-height:14px;text-align:center;border-radius:7px;padding:0 3px;}
|
||||
|
||||
@@ -171,6 +171,140 @@ def pytest_configure(config):
|
||||
# imports trigger botocore initialisation.
|
||||
os.environ.setdefault("AWS_EC2_METADATA_DISABLED", "true")
|
||||
|
||||
# ── Hermetic network isolation ─────────────────────────────────────────────
|
||||
# Tests must not reach the public internet. Outbound to Anthropic / OpenAI /
|
||||
# Amazon / OpenRouter / etc. is forbidden by default. The test suite already
|
||||
# mocks every legitimate outbound (probe_provider_endpoint, get_available_models,
|
||||
# urlopen calls inside api/config.py), so a real outbound socket is either a
|
||||
# missing mock, a leaked credential triggering an SDK init, or an unintended
|
||||
# regression like the one PR #1970 introduced where a new code path bypassed
|
||||
# an existing mock and tried to hit the real LM Studio host.
|
||||
#
|
||||
# This module-level monkey-patch wraps socket.create_connection so any
|
||||
# non-loopback / non-RFC1918 / non-link-local / non-TEST-NET destination
|
||||
# raises OSError("hermes test network isolation"). Tests that deliberately
|
||||
# attempt outbound (only test_dns_resolution_failure today) opt back in
|
||||
# explicitly via the `allow_outbound_network` fixture below.
|
||||
#
|
||||
# Allowed destinations (silent pass-through):
|
||||
# - 127.0.0.0/8 loopback
|
||||
# - ::1 IPv6 loopback
|
||||
# - 192.168.0.0/16 RFC1918 private
|
||||
# - 10.0.0.0/8 RFC1918 private
|
||||
# - 172.16.0.0/12 RFC1918 private (16-31)
|
||||
# - 169.254.0.0/16 link-local (covers IMDS — already separately blocked
|
||||
# by AWS_EC2_METADATA_DISABLED, but allowed at the socket
|
||||
# layer because IMDS-using tests mock the response)
|
||||
# - 203.0.113.0/24 RFC5737 TEST-NET-3 (used as documentation IPs in tests)
|
||||
# - hostnames `localhost`, `*.local`, `*.test`, `*.example`, `*.example.com`
|
||||
# `*.example.net`, `*.example.org`, `*.invalid` (RFC2606/6761 reserved)
|
||||
#
|
||||
# A test that opts in via the `allow_outbound_network` fixture sees the real
|
||||
# socket.create_connection.
|
||||
import socket as _hermes_test_socket
|
||||
_REAL_CREATE_CONNECTION = _hermes_test_socket.create_connection
|
||||
_REAL_SOCKET_CONNECT = _hermes_test_socket.socket.connect
|
||||
|
||||
|
||||
def _hermes_addr_is_local(host: str) -> bool:
|
||||
"""Return True for loopback / RFC1918 / link-local / reserved-TLD hosts."""
|
||||
if not isinstance(host, str):
|
||||
return False
|
||||
h = host.strip().lower()
|
||||
if not h:
|
||||
return False
|
||||
# IPv6 loopback / link-local
|
||||
# IPv6 unique-local: fc00::/7 — any address starting with fc?? or fd?? (?? = hex pair).
|
||||
# Loose "startswith('fc')" / "startswith('fd')" would also match the hostnames
|
||||
# "food.example.com" or "fdsa.test", so require the second char to be a hex
|
||||
# digit followed by either a colon or another hex digit (canonical IPv6 syntax).
|
||||
import re as _re
|
||||
if h in ('::1', '0:0:0:0:0:0:0:1') or h.startswith('fe80:') or _re.match(r'^f[cd][0-9a-f]{0,2}:', h):
|
||||
return True
|
||||
# Hostname allow-list (RFC2606/6761 reserved TLDs + localhost)
|
||||
if h == 'localhost' or h.endswith('.localhost'):
|
||||
return True
|
||||
if h.endswith('.local') or h.endswith('.test') or h.endswith('.invalid'):
|
||||
return True
|
||||
if h == 'example.com' or h.endswith('.example.com'):
|
||||
return True
|
||||
if h == 'example.net' or h.endswith('.example.net'):
|
||||
return True
|
||||
if h == 'example.org' or h.endswith('.example.org'):
|
||||
return True
|
||||
if h.endswith('.example'):
|
||||
return True
|
||||
# IPv4 — parse octets if it looks like a dotted quad
|
||||
if h[0].isdigit() and h.count('.') == 3:
|
||||
try:
|
||||
o1, o2, o3, o4 = [int(p) for p in h.split('.')]
|
||||
except ValueError:
|
||||
return False
|
||||
if o1 == 127: # loopback
|
||||
return True
|
||||
if o1 == 10: # RFC1918 10.0.0.0/8
|
||||
return True
|
||||
if o1 == 192 and o2 == 168: # RFC1918 192.168.0.0/16
|
||||
return True
|
||||
if o1 == 172 and 16 <= o2 <= 31: # RFC1918 172.16.0.0/12
|
||||
return True
|
||||
if o1 == 169 and o2 == 254: # link-local 169.254.0.0/16
|
||||
return True
|
||||
if o1 == 203 and o2 == 0 and o3 == 113: # RFC5737 TEST-NET-3
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _hermes_blocked_create_connection(address, *a, **kw):
|
||||
try:
|
||||
host = address[0]
|
||||
except (TypeError, IndexError):
|
||||
host = ""
|
||||
if _hermes_addr_is_local(host):
|
||||
return _REAL_CREATE_CONNECTION(address, *a, **kw)
|
||||
raise OSError(
|
||||
f"hermes test network isolation: outbound socket to {address!r} is blocked. "
|
||||
f"Tests should mock urllib.request.urlopen / requests / socket.create_connection. "
|
||||
f"If a test genuinely needs real outbound, request the allow_outbound_network fixture."
|
||||
)
|
||||
|
||||
|
||||
def _hermes_blocked_socket_connect(self, address):
|
||||
try:
|
||||
host = address[0]
|
||||
except (TypeError, IndexError):
|
||||
host = ""
|
||||
if _hermes_addr_is_local(host):
|
||||
return _REAL_SOCKET_CONNECT(self, address)
|
||||
raise OSError(
|
||||
f"hermes test network isolation: socket.connect to {address!r} is blocked."
|
||||
)
|
||||
|
||||
|
||||
_hermes_test_socket.create_connection = _hermes_blocked_create_connection
|
||||
_hermes_test_socket.socket.connect = _hermes_blocked_socket_connect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def allow_outbound_network(monkeypatch):
|
||||
"""Opt-in to real outbound network for the duration of one test.
|
||||
|
||||
Swaps `socket.create_connection` and `socket.socket.connect` back to the
|
||||
real (unwrapped) implementations for this test only, then monkeypatch
|
||||
teardown restores the wrapped versions. Direct swap is more reliable
|
||||
than a module-global toggle on CI runners where wrapper-closure
|
||||
lookup semantics can surprise.
|
||||
|
||||
Use sparingly. Today zero tests in the repo call this — the previous
|
||||
test_dns_resolution_failure case was rewritten to mock socket.getaddrinfo
|
||||
instead, which is fully hermetic.
|
||||
"""
|
||||
monkeypatch.setattr(_hermes_test_socket, "create_connection", _REAL_CREATE_CONNECTION)
|
||||
monkeypatch.setattr(_hermes_test_socket.socket, "connect", _REAL_SOCKET_CONNECT)
|
||||
yield
|
||||
|
||||
|
||||
|
||||
|
||||
# ── Environment isolation for tests ────────────────────────────────────────
|
||||
# HERMES_WEBUI_SKIP_ONBOARDING is set by hosting providers (e.g. Agent37) and
|
||||
@@ -365,6 +499,12 @@ def test_server():
|
||||
# at module level above for the pytest process, but make it explicit here
|
||||
# so it's never accidentally cleared by an env.update later).
|
||||
env["AWS_EC2_METADATA_DISABLED"] = "true"
|
||||
# Activate the same network-isolation block in the test_server subprocess
|
||||
# that conftest.py installs in the pytest process. server.py reads this
|
||||
# env var at import time and installs an identical socket-block guard.
|
||||
# Without this, the subprocess can make outbound requests that the
|
||||
# pytest-side block can't see.
|
||||
env["HERMES_WEBUI_TEST_NETWORK_BLOCK"] = "1"
|
||||
env.update({
|
||||
"HERMES_WEBUI_PORT": str(TEST_PORT),
|
||||
"HERMES_WEBUI_HOST": "127.0.0.1",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Adversarial test for the network-isolation fixture in conftest.py.
|
||||
|
||||
The autouse module-level monkey-patch in tests/conftest.py wraps
|
||||
socket.create_connection so that any non-loopback / non-RFC1918 / non-link-local
|
||||
destination raises OSError. This file proves:
|
||||
|
||||
1. The block actually fires for outbound to a real public IP.
|
||||
2. Loopback / RFC1918 / link-local / reserved-TLD destinations pass through.
|
||||
3. The `allow_outbound_network` fixture re-enables real network for tests
|
||||
that legitimately need it.
|
||||
|
||||
Without this enforcement, a test that accidentally calls real outbound
|
||||
(forgotten mock, leaked credential triggering an SDK initialisation, new
|
||||
code path bypassing an existing mock) can leak production credentials,
|
||||
slow the test suite into 10-minute waits on TLS handshakes, and produce
|
||||
flaky failures depending on whether the destination is reachable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import pytest
|
||||
|
||||
|
||||
def test_outbound_to_public_ipv4_is_blocked():
|
||||
"""Attempting to connect to a public IP must raise OSError."""
|
||||
with pytest.raises(OSError, match="hermes test network isolation"):
|
||||
# 8.8.8.8 (Google DNS) is a stable real public IPv4.
|
||||
# If we accidentally connect, the test goes to 53/tcp which is
|
||||
# genuinely listening — so the block is what stops us, not lack of
|
||||
# destination.
|
||||
socket.create_connection(("8.8.8.8", 53), timeout=1)
|
||||
|
||||
|
||||
def test_outbound_to_anthropic_ipv6_is_blocked():
|
||||
"""The exact destination we observed leaking from earlier pytest runs."""
|
||||
with pytest.raises(OSError, match="hermes test network isolation"):
|
||||
socket.create_connection(("2607:6bc0::10", 443), timeout=1)
|
||||
|
||||
|
||||
def test_outbound_to_amazon_is_blocked():
|
||||
"""AWS endpoints (botocore / bedrock) must not reach the real service."""
|
||||
with pytest.raises(OSError, match="hermes test network isolation"):
|
||||
socket.create_connection(("3.173.21.63", 443), timeout=1)
|
||||
|
||||
|
||||
def test_loopback_v4_is_allowed():
|
||||
"""127.0.0.1 must continue to work — test_server fixture depends on it."""
|
||||
# Listen on a temporary port + connect via the wrapped create_connection.
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
port = listener.getsockname()[1]
|
||||
listener.listen(1)
|
||||
try:
|
||||
client = socket.create_connection(("127.0.0.1", port), timeout=1)
|
||||
client.close()
|
||||
finally:
|
||||
listener.close()
|
||||
|
||||
|
||||
def test_rfc1918_private_ipv4_is_allowed():
|
||||
"""RFC1918 (10/8, 172.16/12, 192.168/16) must pass — devs run LM Studio
|
||||
on their LAN. The block only refuses non-RFC1918 + non-loopback."""
|
||||
import tests.conftest as _conftest
|
||||
# Direct unit test on the predicate so we don't have to start a real listener
|
||||
# in a private-IP subnet just to prove this.
|
||||
assert _conftest._hermes_addr_is_local("10.0.0.5") is True
|
||||
assert _conftest._hermes_addr_is_local("172.16.5.1") is True
|
||||
assert _conftest._hermes_addr_is_local("172.31.255.254") is True
|
||||
assert _conftest._hermes_addr_is_local("192.168.1.22") is True
|
||||
|
||||
|
||||
def test_link_local_is_allowed():
|
||||
"""169.254.0.0/16 (link-local / IMDS) — AWS_EC2_METADATA_DISABLED already
|
||||
short-circuits the actual probe but the socket layer allows it."""
|
||||
import tests.conftest as _conftest
|
||||
assert _conftest._hermes_addr_is_local("169.254.169.254") is True
|
||||
|
||||
|
||||
def test_reserved_tlds_are_allowed():
|
||||
"""RFC 2606/6761 reserved TLDs — used as documentation hostnames in tests
|
||||
(e.g. example.com, test-host.invalid)."""
|
||||
import tests.conftest as _conftest
|
||||
assert _conftest._hermes_addr_is_local("example.com") is True
|
||||
assert _conftest._hermes_addr_is_local("my-mac.tailnet.example") is True
|
||||
assert _conftest._hermes_addr_is_local("anything.invalid") is True
|
||||
assert _conftest._hermes_addr_is_local("test-host.test") is True
|
||||
assert _conftest._hermes_addr_is_local("printer.local") is True
|
||||
assert _conftest._hermes_addr_is_local("localhost") is True
|
||||
|
||||
|
||||
def test_public_ipv4_is_blocked():
|
||||
"""Public IPs must NOT be treated as local."""
|
||||
import tests.conftest as _conftest
|
||||
assert _conftest._hermes_addr_is_local("8.8.8.8") is False
|
||||
assert _conftest._hermes_addr_is_local("1.1.1.1") is False
|
||||
assert _conftest._hermes_addr_is_local("203.0.113.0") is True # TEST-NET-3
|
||||
assert _conftest._hermes_addr_is_local("204.0.113.0") is False # outside
|
||||
|
||||
|
||||
def test_allow_outbound_network_fixture_unswaps_the_wrappers(allow_outbound_network):
|
||||
"""When a test opts in to the fixture, socket.create_connection and
|
||||
socket.socket.connect are restored to their real (unwrapped) implementations
|
||||
for this test only.
|
||||
|
||||
Check by qname so this is robust against pytest re-importing conftest
|
||||
under multiple roots (which produces two distinct function objects with
|
||||
the same __qualname__ but different `is` identity).
|
||||
"""
|
||||
# Inside the fixture, the symbol should NOT be the blocked wrapper.
|
||||
assert "_hermes_blocked_create_connection" not in getattr(
|
||||
socket.create_connection, "__qualname__", ""
|
||||
), "allow_outbound_network fixture did not restore the real create_connection"
|
||||
assert "_hermes_blocked_socket_connect" not in getattr(
|
||||
socket.socket.connect, "__qualname__", ""
|
||||
), "allow_outbound_network fixture did not restore the real socket.connect"
|
||||
|
||||
|
||||
def test_block_is_active_outside_the_fixture():
|
||||
"""Sanity: a test that does NOT request the fixture has the wrapped
|
||||
socket.create_connection installed.
|
||||
|
||||
Check by qname so this is robust against pytest re-importing conftest
|
||||
under multiple roots (which produces two distinct function objects with
|
||||
the same __qualname__ but different `is` identity)."""
|
||||
assert "_hermes_blocked_create_connection" in getattr(
|
||||
socket.create_connection, "__qualname__", ""
|
||||
), "default state should have the blocked wrapper installed on socket.create_connection"
|
||||
assert "_hermes_blocked_socket_connect" in getattr(
|
||||
socket.socket.connect, "__qualname__", ""
|
||||
), "default state should have the blocked wrapper installed on socket.socket.connect"
|
||||
@@ -154,9 +154,21 @@ class TestIssue1499OnboardingProbe:
|
||||
assert r["ok"] is False
|
||||
assert r["error"] == "invalid_url"
|
||||
|
||||
def test_dns_resolution_failure(self):
|
||||
"""Unresolvable hostname → error='dns'."""
|
||||
def test_dns_resolution_failure(self, monkeypatch):
|
||||
"""Unresolvable hostname → error='dns'.
|
||||
|
||||
Mocked at `socket.getaddrinfo` so this test is hermetic — no real DNS
|
||||
lookup leaves the test process. The reserved `.invalid` TLD (RFC2606)
|
||||
is still used as the hostname so anyone reading the test sees the
|
||||
intent; the failure is forced via `socket.gaierror` from the mock.
|
||||
"""
|
||||
import socket
|
||||
from api.onboarding import probe_provider_endpoint
|
||||
|
||||
def _raise_gaierror(*_args, **_kwargs):
|
||||
raise socket.gaierror(-2, "Name or service not known")
|
||||
|
||||
monkeypatch.setattr(socket, "getaddrinfo", _raise_gaierror)
|
||||
r = probe_provider_endpoint(
|
||||
"lmstudio",
|
||||
"http://this-host-definitely-does-not-exist-zxq987.invalid:1234/v1",
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import api.models as models
|
||||
from api.models import SESSIONS, Session, new_session
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_sessions(tmp_path, monkeypatch):
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
SESSIONS.clear()
|
||||
yield session_dir
|
||||
SESSIONS.clear()
|
||||
|
||||
|
||||
def test_worktree_metadata_round_trips_through_session_file(_isolate_sessions):
|
||||
s = Session(
|
||||
session_id="worktree001",
|
||||
workspace=str(_isolate_sessions.parent / "repo" / ".worktrees" / "hermes-1234"),
|
||||
worktree_path=str(_isolate_sessions.parent / "repo" / ".worktrees" / "hermes-1234"),
|
||||
worktree_branch="hermes/hermes-1234",
|
||||
worktree_repo_root=str(_isolate_sessions.parent / "repo"),
|
||||
worktree_created_at=123.5,
|
||||
)
|
||||
s.save()
|
||||
|
||||
raw = json.loads(s.path.read_text(encoding="utf-8"))
|
||||
assert raw["worktree_path"].endswith(".worktrees/hermes-1234")
|
||||
assert raw["worktree_branch"] == "hermes/hermes-1234"
|
||||
assert raw["worktree_repo_root"].endswith("repo")
|
||||
assert raw["worktree_created_at"] == 123.5
|
||||
|
||||
loaded = Session.load("worktree001")
|
||||
assert loaded.worktree_path == s.worktree_path
|
||||
assert loaded.worktree_branch == "hermes/hermes-1234"
|
||||
assert loaded.worktree_repo_root == s.worktree_repo_root
|
||||
assert loaded.worktree_created_at == 123.5
|
||||
assert loaded.compact()["worktree_branch"] == "hermes/hermes-1234"
|
||||
|
||||
|
||||
def test_new_session_with_worktree_info_persists_immediately(_isolate_sessions):
|
||||
repo = _isolate_sessions.parent / "repo"
|
||||
worktree = repo / ".worktrees" / "hermes-abcd1234"
|
||||
worktree.mkdir(parents=True)
|
||||
|
||||
s = new_session(
|
||||
workspace=str(worktree),
|
||||
worktree_info={
|
||||
"path": str(worktree),
|
||||
"branch": "hermes/hermes-abcd1234",
|
||||
"repo_root": str(repo),
|
||||
"created_at": 456.0,
|
||||
},
|
||||
)
|
||||
|
||||
assert s.path.exists(), (
|
||||
"worktree-backed sessions must be persisted at creation time so the "
|
||||
"real filesystem worktree is not orphaned by a browser/server restart"
|
||||
)
|
||||
assert s.worktree_path == str(worktree.resolve())
|
||||
assert s.worktree_branch == "hermes/hermes-abcd1234"
|
||||
assert s.worktree_repo_root == str(repo.resolve())
|
||||
assert s.worktree_created_at == 456.0
|
||||
|
||||
|
||||
def test_empty_worktree_session_remains_visible_in_sidebar(_isolate_sessions):
|
||||
repo = _isolate_sessions.parent / "repo"
|
||||
worktree = repo / ".worktrees" / "hermes-visible"
|
||||
worktree.mkdir(parents=True)
|
||||
|
||||
s = new_session(
|
||||
workspace=str(worktree),
|
||||
worktree_info={
|
||||
"path": str(worktree),
|
||||
"branch": "hermes/hermes-visible",
|
||||
"repo_root": str(repo),
|
||||
"created_at": 789.0,
|
||||
},
|
||||
)
|
||||
|
||||
ids = {row["session_id"] for row in models.all_sessions()}
|
||||
assert s.session_id in ids, (
|
||||
"worktree-backed sessions represent real filesystem state immediately "
|
||||
"and must survive the empty-session sidebar filter"
|
||||
)
|
||||
|
||||
|
||||
def test_find_git_repo_root_uses_git_from_nested_workspace(tmp_path):
|
||||
from api.worktrees import find_git_repo_root
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
nested = repo / "apps" / "web"
|
||||
nested.mkdir(parents=True)
|
||||
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
|
||||
|
||||
assert find_git_repo_root(nested) == repo.resolve()
|
||||
|
||||
|
||||
def test_find_git_repo_root_rejects_non_git_workspace(tmp_path):
|
||||
from api.worktrees import find_git_repo_root
|
||||
|
||||
with pytest.raises(ValueError, match="not inside a git repository"):
|
||||
find_git_repo_root(tmp_path)
|
||||
|
||||
|
||||
def test_create_worktree_for_workspace_calls_agent_setup_with_repo_root(tmp_path, monkeypatch):
|
||||
import api.worktrees as worktrees
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
nested = repo / "src"
|
||||
nested.mkdir(parents=True)
|
||||
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
|
||||
seen = {}
|
||||
|
||||
def fake_setup(repo_root):
|
||||
seen["repo_root"] = repo_root
|
||||
return {
|
||||
"path": str(repo / ".worktrees" / "hermes-test"),
|
||||
"branch": "hermes/hermes-test",
|
||||
"repo_root": str(repo),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(worktrees, "_setup_agent_worktree", fake_setup)
|
||||
now = time.time()
|
||||
|
||||
info = worktrees.create_worktree_for_workspace(nested)
|
||||
|
||||
assert seen["repo_root"] == str(repo.resolve())
|
||||
assert info["path"].endswith(".worktrees/hermes-test")
|
||||
assert info["branch"] == "hermes/hermes-test"
|
||||
assert info["repo_root"] == str(repo.resolve())
|
||||
assert info["created_at"] >= now
|
||||
|
||||
|
||||
def test_session_new_route_creates_worktree_backed_session(tmp_path, monkeypatch):
|
||||
import api.routes as routes
|
||||
import api.worktrees as worktrees
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
worktree = repo / ".worktrees" / "hermes-route"
|
||||
repo.mkdir()
|
||||
worktree.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_body",
|
||||
lambda handler: {
|
||||
"workspace": str(repo),
|
||||
"worktree": True,
|
||||
"profile": "default",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda raw: repo if raw == str(repo) else raw)
|
||||
monkeypatch.setattr(
|
||||
worktrees,
|
||||
"create_worktree_for_workspace",
|
||||
lambda workspace: {
|
||||
"path": str(worktree),
|
||||
"branch": "hermes/hermes-route",
|
||||
"repo_root": str(repo),
|
||||
"created_at": 321.0,
|
||||
},
|
||||
)
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"j",
|
||||
lambda handler, payload, status=200, extra_headers=None: captured.update(
|
||||
payload=payload,
|
||||
status=status,
|
||||
) or True,
|
||||
)
|
||||
|
||||
assert routes.handle_post(object(), SimpleNamespace(path="/api/session/new")) is True
|
||||
assert captured["status"] == 200
|
||||
session = captured["payload"]["session"]
|
||||
assert session["workspace"] == str(worktree.resolve())
|
||||
assert session["worktree_path"] == str(worktree.resolve())
|
||||
assert session["worktree_branch"] == "hermes/hermes-route"
|
||||
|
||||
|
||||
def test_session_new_worktree_fallback_workspace_is_resolved(tmp_path, monkeypatch):
|
||||
import api.routes as routes
|
||||
import api.worktrees as worktrees
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
worktree = repo / ".worktrees" / "hermes-route"
|
||||
repo.mkdir()
|
||||
worktree.mkdir(parents=True)
|
||||
seen = {"resolved": []}
|
||||
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_body",
|
||||
lambda handler: {
|
||||
"worktree": True,
|
||||
"profile": "default",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(routes, "get_last_workspace", lambda: str(repo))
|
||||
|
||||
def fake_resolve(raw):
|
||||
seen["resolved"].append(raw)
|
||||
return repo
|
||||
|
||||
monkeypatch.setattr(routes, "resolve_trusted_workspace", fake_resolve)
|
||||
monkeypatch.setattr(
|
||||
worktrees,
|
||||
"create_worktree_for_workspace",
|
||||
lambda workspace: {
|
||||
"path": str(worktree),
|
||||
"branch": "hermes/hermes-route",
|
||||
"repo_root": str(repo),
|
||||
"created_at": 321.0,
|
||||
},
|
||||
)
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"j",
|
||||
lambda handler, payload, status=200, extra_headers=None: captured.update(
|
||||
payload=payload,
|
||||
status=status,
|
||||
) or True,
|
||||
)
|
||||
|
||||
assert routes.handle_post(object(), SimpleNamespace(path="/api/session/new")) is True
|
||||
|
||||
assert seen["resolved"] == [str(repo)]
|
||||
assert captured["status"] == 200
|
||||
session = captured["payload"]["session"]
|
||||
assert session["workspace"] == str(worktree.resolve())
|
||||
@@ -0,0 +1,44 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path):
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_session_new_route_accepts_worktree_flag_and_uses_worktree_info():
|
||||
src = read("api/routes.py")
|
||||
assert "create_worktree_for_workspace" in src
|
||||
assert 'body.get("worktree")' in src or "body.get('worktree')" in src
|
||||
assert "worktree_info=" in src
|
||||
|
||||
|
||||
def test_new_session_request_can_include_worktree_flag():
|
||||
src = read("static/sessions.js")
|
||||
assert "async function newSession(flash, options={})" in src
|
||||
assert "reqBody.worktree=true" in src
|
||||
|
||||
|
||||
def test_workspace_dropdown_exposes_new_worktree_conversation_action():
|
||||
src = read("static/panels.js")
|
||||
assert "workspace_new_worktree_conversation" in src
|
||||
assert "workspace_new_worktree_conversation_meta" in src
|
||||
assert "newSession(false,{worktree:true})" in src
|
||||
assert "li('git-branch',12)" in src
|
||||
|
||||
|
||||
def test_session_sidebar_renders_worktree_indicator():
|
||||
src = read("static/sessions.js")
|
||||
assert "session-worktree-indicator" in src
|
||||
assert "s.worktree_path" in src
|
||||
assert "s.worktree_branch" in src
|
||||
|
||||
|
||||
def test_worktree_indicator_styles_and_i18n_exist():
|
||||
css = read("static/style.css")
|
||||
i18n = read("static/i18n.js")
|
||||
assert ".session-worktree-indicator" in css
|
||||
assert "workspace_new_worktree_conversation" in i18n
|
||||
assert "session_worktree_badge" in i18n
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Regression for PR #1970 LM Studio provider × cfg.model.base_url shape.
|
||||
|
||||
PR #1970 added `_get_provider_base_url()` + a dedicated lmstudio branch in
|
||||
`get_available_models()` for fetching live loaded models via the OpenAI-compatible
|
||||
/v1/models endpoint.
|
||||
|
||||
The initial implementation only looked at `cfg["providers"]["lmstudio"]["base_url"]`,
|
||||
missing the historical shape where users put `base_url` under `cfg["model"]`
|
||||
(when `cfg["model"]["provider"] == "lmstudio"`). That shape is what
|
||||
`tests/test_issue1527_lmstudio_base_url_classification.py` covers and what real
|
||||
users have in their config.yaml — 3 pre-existing tests started failing on stage-337
|
||||
because of this gap.
|
||||
|
||||
This regression test pins the helper's two-location lookup so a future change
|
||||
can't accidentally drop the model.base_url fallback again.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import api.config as config
|
||||
|
||||
|
||||
class _RestoreCfg:
|
||||
"""Context manager: snapshot cfg, restore on exit (test isolation)."""
|
||||
|
||||
def __enter__(self):
|
||||
import copy
|
||||
self._snapshot = copy.deepcopy(config.cfg)
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
config.cfg.clear()
|
||||
config.cfg.update(self._snapshot)
|
||||
|
||||
|
||||
def test_get_provider_base_url_finds_explicit_providers_entry():
|
||||
"""When providers.<id>.base_url is set, return that value."""
|
||||
with _RestoreCfg():
|
||||
config.cfg.clear()
|
||||
config.cfg.update({
|
||||
"providers": {
|
||||
"lmstudio": {"base_url": "http://10.0.0.5:1234/v1", "api_key": "x"},
|
||||
},
|
||||
})
|
||||
assert config._get_provider_base_url("lmstudio") == "http://10.0.0.5:1234/v1"
|
||||
|
||||
|
||||
def test_get_provider_base_url_strips_trailing_slash():
|
||||
with _RestoreCfg():
|
||||
config.cfg.clear()
|
||||
config.cfg.update({
|
||||
"providers": {
|
||||
"lmstudio": {"base_url": "http://10.0.0.5:1234/v1/", "api_key": "x"},
|
||||
},
|
||||
})
|
||||
assert config._get_provider_base_url("lmstudio") == "http://10.0.0.5:1234/v1"
|
||||
|
||||
|
||||
def test_get_provider_base_url_falls_back_to_model_base_url():
|
||||
"""When providers.<id>.base_url is unset but cfg.model.base_url is set
|
||||
AND cfg.model.provider matches, the helper returns model.base_url."""
|
||||
with _RestoreCfg():
|
||||
config.cfg.clear()
|
||||
config.cfg.update({
|
||||
"model": {
|
||||
"provider": "lmstudio",
|
||||
"base_url": "http://192.168.1.22:1234/v1",
|
||||
"default": "qwen3.6-35b-a3b@q6_k",
|
||||
},
|
||||
"providers": {
|
||||
"lmstudio": {"api_key": "local-key"}, # no base_url here
|
||||
},
|
||||
})
|
||||
# Was returning None before the fix — the regression that broke
|
||||
# test_issue1527_lmstudio_base_url_classification.
|
||||
assert config._get_provider_base_url("lmstudio") == "http://192.168.1.22:1234/v1"
|
||||
|
||||
|
||||
def test_get_provider_base_url_returns_none_when_unconfigured():
|
||||
"""Unconfigured provider returns None (sentinel for 'use SDK default')."""
|
||||
with _RestoreCfg():
|
||||
config.cfg.clear()
|
||||
config.cfg.update({"providers": {}})
|
||||
assert config._get_provider_base_url("openai") is None
|
||||
assert config._get_provider_base_url("anthropic") is None
|
||||
assert config._get_provider_base_url("lmstudio") is None
|
||||
|
||||
|
||||
def test_get_provider_base_url_model_block_only_matches_active_provider():
|
||||
"""cfg.model.base_url must NOT leak to providers other than cfg.model.provider.
|
||||
|
||||
If model.provider is anthropic but providers.openai exists without base_url,
|
||||
_get_provider_base_url("openai") must still return None — otherwise we'd
|
||||
silently rewrite the OpenAI SDK target to an Anthropic endpoint URL.
|
||||
"""
|
||||
with _RestoreCfg():
|
||||
config.cfg.clear()
|
||||
config.cfg.update({
|
||||
"model": {
|
||||
"provider": "anthropic",
|
||||
"base_url": "https://my-anthropic-proxy.example.com/v1",
|
||||
},
|
||||
"providers": {
|
||||
"openai": {"api_key": "ok"}, # no base_url
|
||||
"anthropic": {"api_key": "ak"}, # no base_url
|
||||
},
|
||||
})
|
||||
# Active provider gets the model.base_url fallback.
|
||||
assert config._get_provider_base_url("anthropic") == "https://my-anthropic-proxy.example.com/v1"
|
||||
# OpenAI must NOT inherit it.
|
||||
assert config._get_provider_base_url("openai") is None
|
||||
|
||||
|
||||
def test_get_provider_base_url_explicit_wins_over_model_fallback():
|
||||
"""If both providers.<id>.base_url AND cfg.model.base_url are set with matching
|
||||
provider, the explicit providers entry wins."""
|
||||
with _RestoreCfg():
|
||||
config.cfg.clear()
|
||||
config.cfg.update({
|
||||
"model": {
|
||||
"provider": "lmstudio",
|
||||
"base_url": "http://wrong:1234/v1",
|
||||
},
|
||||
"providers": {
|
||||
"lmstudio": {"base_url": "http://correct:1234/v1", "api_key": "x"},
|
||||
},
|
||||
})
|
||||
assert config._get_provider_base_url("lmstudio") == "http://correct:1234/v1"
|
||||
|
||||
|
||||
|
||||
def test_lmstudio_fallback_works_when_hermes_cli_unavailable(tmp_path, monkeypatch):
|
||||
"""The lmstudio branch must populate models from the urlopen fallback even
|
||||
when `from hermes_cli.models import provider_model_ids` raises ImportError.
|
||||
|
||||
Pre-fix, the outer try/except in the lmstudio branch caught the ImportError
|
||||
and silently aborted the whole branch, never running the urlopen fallback —
|
||||
a CI-vs-local divergence where local environments with hermes_cli installed
|
||||
worked, and CI (clean editable install) failed with empty model groups.
|
||||
|
||||
Caught in CI on stage-337; fix splits the hermes_cli try from the urlopen
|
||||
fallback so each runs independently.
|
||||
"""
|
||||
import json as _json
|
||||
import socket as _socket
|
||||
import sys
|
||||
import urllib.request as _urlreq
|
||||
|
||||
import api.config as config
|
||||
|
||||
# Block hermes_cli import the way a CI runner without the package would.
|
||||
blocked_modules = [name for name in list(sys.modules) if name == "hermes_cli" or name.startswith("hermes_cli.")]
|
||||
for name in blocked_modules:
|
||||
monkeypatch.delitem(sys.modules, name, raising=False)
|
||||
|
||||
class _Blocker:
|
||||
def find_module(self, name, path=None):
|
||||
if name == "hermes_cli" or name.startswith("hermes_cli."):
|
||||
return self
|
||||
return None
|
||||
|
||||
def load_module(self, name):
|
||||
raise ImportError(f"hermes_cli blocked for test: {name}")
|
||||
|
||||
blocker = _Blocker()
|
||||
sys.meta_path.insert(0, blocker)
|
||||
try:
|
||||
# Set up a config that points lmstudio at a fake base_url under cfg.model.
|
||||
cfgfile = tmp_path / "config.yaml"
|
||||
cfgfile.write_text(
|
||||
"""
|
||||
model:
|
||||
provider: lmstudio
|
||||
default: qwen3.6-35b-a3b@q6_k
|
||||
base_url: http://10.0.0.5:1234/v1
|
||||
providers:
|
||||
lmstudio:
|
||||
api_key: local-key
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(config, "_get_config_path", lambda: cfgfile)
|
||||
config.reload_config()
|
||||
config.invalidate_models_cache()
|
||||
|
||||
class _ModelsResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
def read(self):
|
||||
return _json.dumps(
|
||||
{"data": [{"id": "qwen3.6-35b-a3b@q6_k"}, {"id": "another-model"}]}
|
||||
).encode()
|
||||
|
||||
monkeypatch.setattr(_urlreq, "urlopen", lambda *_a, **_kw: _ModelsResponse())
|
||||
monkeypatch.setattr(
|
||||
_socket,
|
||||
"getaddrinfo",
|
||||
lambda *_a, **_kw: [
|
||||
(_socket.AF_INET, _socket.SOCK_STREAM, 6, "", ("10.0.0.5", 0))
|
||||
],
|
||||
)
|
||||
|
||||
result = config.get_available_models()
|
||||
groups = {g["provider_id"]: g for g in result["groups"]}
|
||||
|
||||
# Fallback must succeed despite hermes_cli being unimportable.
|
||||
assert "lmstudio" in groups, (
|
||||
f"lmstudio group missing when hermes_cli unavailable; groups={list(groups)}"
|
||||
)
|
||||
model_ids = {m["id"] for m in groups["lmstudio"]["models"]}
|
||||
assert "qwen3.6-35b-a3b@q6_k" in model_ids
|
||||
assert "another-model" in model_ids
|
||||
finally:
|
||||
try:
|
||||
sys.meta_path.remove(blocker)
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -0,0 +1,105 @@
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from api.config import DEFAULT_WORKSPACE, SESSION_DIR
|
||||
from api.models import get_session
|
||||
from api.routes import _handle_file_read, _handle_session_import
|
||||
from api.workspace import resolve_trusted_workspace
|
||||
|
||||
|
||||
class _DummyHandler:
|
||||
def __init__(self):
|
||||
self.status = None
|
||||
self.response_headers = []
|
||||
self.headers = {}
|
||||
self.wfile = io.BytesIO()
|
||||
self.command = "GET"
|
||||
self.path = "/"
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.response_headers.append((key, value))
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
def json_body(self):
|
||||
return json.loads(self.wfile.getvalue().decode("utf-8"))
|
||||
|
||||
|
||||
def test_session_import_rejects_blocked_root_workspace():
|
||||
handler = _DummyHandler()
|
||||
|
||||
_handle_session_import(
|
||||
handler,
|
||||
{
|
||||
"title": "blocked import",
|
||||
"workspace": "/",
|
||||
"model": "test",
|
||||
"messages": [],
|
||||
},
|
||||
)
|
||||
|
||||
assert handler.status == 400
|
||||
assert "system directory" in handler.json_body()["error"]
|
||||
|
||||
|
||||
def test_session_import_rejects_non_path_workspace_value():
|
||||
handler = _DummyHandler()
|
||||
|
||||
_handle_session_import(
|
||||
handler,
|
||||
{
|
||||
"title": "invalid import",
|
||||
"workspace": {"not": "a path"},
|
||||
"model": "test",
|
||||
"messages": [],
|
||||
},
|
||||
)
|
||||
|
||||
assert handler.status == 400
|
||||
assert handler.json_body()["error"]
|
||||
|
||||
|
||||
def test_imported_session_file_read_stays_under_validated_workspace():
|
||||
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||
workspace = Path(DEFAULT_WORKSPACE)
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
(workspace / "allowed.txt").write_text("allowed", encoding="utf-8")
|
||||
|
||||
import_handler = _DummyHandler()
|
||||
_handle_session_import(
|
||||
import_handler,
|
||||
{
|
||||
"title": "valid import",
|
||||
"workspace": str(workspace),
|
||||
"model": "test",
|
||||
"messages": [],
|
||||
},
|
||||
)
|
||||
|
||||
assert import_handler.status == 200
|
||||
sid = import_handler.json_body()["session"]["session_id"]
|
||||
assert get_session(sid).workspace == str(resolve_trusted_workspace(workspace))
|
||||
|
||||
read_handler = _DummyHandler()
|
||||
_handle_file_read(read_handler, urlparse(f"/api/file?session_id={sid}&path=allowed.txt"))
|
||||
|
||||
assert read_handler.status == 200
|
||||
assert read_handler.json_body()["content"] == "allowed"
|
||||
|
||||
|
||||
def test_resolver_would_reject_imported_root_before_file_read():
|
||||
# Regression guard for the original issue shape: '/' must be rejected at
|
||||
# import time rather than becoming a session workspace that makes
|
||||
# Path('/')-relative reads like etc/hosts reachable through /api/file.
|
||||
try:
|
||||
resolve_trusted_workspace(Path("/"))
|
||||
except ValueError as exc:
|
||||
assert "system directory" in str(exc)
|
||||
else: # pragma: no cover - this would weaken the security invariant
|
||||
raise AssertionError("root workspace unexpectedly accepted")
|
||||
@@ -159,6 +159,69 @@ def test_deferred_turn_is_materialized_when_agent_returns_assistant_only_delta()
|
||||
assert [m["content"] for m in merged[-2:]] == ["latest prompt", "current answer"]
|
||||
|
||||
|
||||
def test_duplicate_assistant_delta_is_not_persisted_twice():
|
||||
"""Provider/result merge replay must not duplicate the same assistant bubble."""
|
||||
previous_display = [
|
||||
{"role": "user", "content": "older prompt"},
|
||||
{"role": "assistant", "content": "older answer"},
|
||||
]
|
||||
previous_context = list(previous_display)
|
||||
result_messages = previous_context + [
|
||||
{"role": "user", "content": "latest prompt"},
|
||||
{"role": "assistant", "content": "current answer"},
|
||||
{"role": "assistant", "content": "current answer"},
|
||||
]
|
||||
|
||||
merged = streaming._merge_display_messages_after_agent_result(
|
||||
previous_display=previous_display,
|
||||
previous_context=previous_context,
|
||||
result_messages=result_messages,
|
||||
msg_text="latest prompt",
|
||||
)
|
||||
|
||||
assert [m["role"] for m in merged] == [
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
]
|
||||
assert [m["content"] for m in merged[-2:]] == ["latest prompt", "current answer"]
|
||||
assert (
|
||||
sum(
|
||||
1
|
||||
for m in merged
|
||||
if m.get("role") == "assistant" and m.get("content") == "current answer"
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_same_assistant_text_across_different_turns_is_preserved():
|
||||
previous_display = [
|
||||
{"role": "user", "content": "first prompt"},
|
||||
{"role": "assistant", "content": "same answer"},
|
||||
]
|
||||
previous_context = list(previous_display)
|
||||
result_messages = previous_context + [
|
||||
{"role": "user", "content": "second prompt"},
|
||||
{"role": "assistant", "content": "same answer"},
|
||||
]
|
||||
|
||||
merged = streaming._merge_display_messages_after_agent_result(
|
||||
previous_display=previous_display,
|
||||
previous_context=previous_context,
|
||||
result_messages=result_messages,
|
||||
msg_text="second prompt",
|
||||
)
|
||||
|
||||
assert [m["content"] for m in merged] == [
|
||||
"first prompt",
|
||||
"same answer",
|
||||
"second prompt",
|
||||
"same answer",
|
||||
]
|
||||
|
||||
|
||||
def test_llm_title_generated_survives_save_and_load(_isolate_state):
|
||||
s = Session(
|
||||
session_id="generated_title",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Regression for state.db × worktree-backed session recovery.
|
||||
|
||||
PR #2053 added worktree-backed session creation. PR #2041 added state.db
|
||||
sidecar reconciliation. When a worktree-backed session's JSON sidecar is
|
||||
lost (failed save, manual rm, restore-from-backup) and state.db is the only
|
||||
source of truth, the recovery path must rebuild a sidecar that preserves
|
||||
the worktree_* fields. Without that, the sidebar exempt-empty filter at
|
||||
api/models.py:1067/1107 (which spares worktree-backed empty sessions) sees
|
||||
no worktree_path on the rebuilt session and silently filters it out — the
|
||||
session vanishes from the sidebar even though the worktree directory still
|
||||
exists on disk.
|
||||
|
||||
Caught by Opus advisor on stage-337 review.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from api.session_recovery import _state_db_row_to_sidecar
|
||||
|
||||
|
||||
def test_state_db_recovery_preserves_worktree_metadata():
|
||||
"""Recovered sidecar must keep worktree_path / worktree_branch / repo_root."""
|
||||
row = {
|
||||
"id": "abc123",
|
||||
"source": "webui",
|
||||
"title": "My worktree session",
|
||||
"model": "anthropic/claude-3-opus",
|
||||
"started_at": 1700000000,
|
||||
"parent_session_id": None,
|
||||
"message_count": 3,
|
||||
"messages": [
|
||||
{"role": "user", "content": "hello", "timestamp": 1700000001},
|
||||
{"role": "assistant", "content": "hi", "timestamp": 1700000002},
|
||||
{"role": "user", "content": "more", "timestamp": 1700000003},
|
||||
],
|
||||
"workspace": "/home/user/proj/.worktrees/hermes-1234",
|
||||
"worktree_path": "/home/user/proj/.worktrees/hermes-1234",
|
||||
"worktree_branch": "hermes/abc123",
|
||||
"worktree_repo_root": "/home/user/proj",
|
||||
"worktree_created_at": 1700000000,
|
||||
}
|
||||
|
||||
sidecar = _state_db_row_to_sidecar(row)
|
||||
|
||||
assert sidecar["session_id"] == "abc123"
|
||||
assert sidecar["title"] == "My worktree session"
|
||||
# The four worktree_* fields must survive the rebuild — without them the
|
||||
# sidebar filter at api/models.py:1067 hides the session.
|
||||
assert sidecar["worktree_path"] == "/home/user/proj/.worktrees/hermes-1234"
|
||||
assert sidecar["worktree_branch"] == "hermes/abc123"
|
||||
assert sidecar["worktree_repo_root"] == "/home/user/proj"
|
||||
assert sidecar["worktree_created_at"] == 1700000000
|
||||
# Workspace must round-trip from the row so terminal panels / file pickers
|
||||
# operate on the correct path, not on empty string.
|
||||
assert sidecar["workspace"] == "/home/user/proj/.worktrees/hermes-1234"
|
||||
# message_count must come from the row so the sidebar exempt-empty filter
|
||||
# accepts message-bearing sessions (was hard-coded 0 pre-fix).
|
||||
assert sidecar["message_count"] == 3
|
||||
|
||||
|
||||
def test_state_db_recovery_non_worktree_session_unaffected():
|
||||
"""A normal (non-worktree) session recovers exactly as before — None worktree fields."""
|
||||
row = {
|
||||
"id": "xyz789",
|
||||
"source": "webui",
|
||||
"title": "Normal chat",
|
||||
"model": "openai/gpt-4",
|
||||
"started_at": 1700000000,
|
||||
"parent_session_id": None,
|
||||
"message_count": 1,
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
# No workspace, no worktree_* fields on the row.
|
||||
}
|
||||
|
||||
sidecar = _state_db_row_to_sidecar(row)
|
||||
|
||||
assert sidecar["worktree_path"] is None
|
||||
assert sidecar["worktree_branch"] is None
|
||||
assert sidecar["worktree_repo_root"] is None
|
||||
assert sidecar["worktree_created_at"] is None
|
||||
assert sidecar["workspace"] == ""
|
||||
assert sidecar["message_count"] == 1
|
||||
|
||||
|
||||
def test_state_db_recovery_zero_message_worktree_session_visible_in_sidebar():
|
||||
"""An empty worktree-backed session recovered from state.db must NOT be
|
||||
silently filtered from the sidebar by the empty-session-exempt rule.
|
||||
|
||||
Pre-fix: the recovery rebuilt a sidecar with no worktree_path → matched the
|
||||
empty-session filter → session disappeared from the sidebar even though
|
||||
the worktree directory still existed on disk. Now that worktree_path is
|
||||
propagated, the exemption clause at api/models.py:1070 fires.
|
||||
"""
|
||||
row = {
|
||||
"id": "empty-worktree-abc",
|
||||
"source": "webui",
|
||||
"title": "Untitled", # default before any user message
|
||||
"model": "anthropic/claude-3-opus",
|
||||
"started_at": 1700000000,
|
||||
"parent_session_id": None,
|
||||
"message_count": 0,
|
||||
"messages": [],
|
||||
"workspace": "/home/user/proj/.worktrees/hermes-empty",
|
||||
"worktree_path": "/home/user/proj/.worktrees/hermes-empty",
|
||||
"worktree_branch": "hermes/empty",
|
||||
"worktree_repo_root": "/home/user/proj",
|
||||
"worktree_created_at": 1700000000,
|
||||
}
|
||||
|
||||
sidecar = _state_db_row_to_sidecar(row)
|
||||
|
||||
# The compact() shape used in sidebar filtering is roughly the sidecar dict
|
||||
# with selected keys. The filter at api/models.py:1067 checks:
|
||||
# title == 'Untitled' and message_count == 0 and not active_stream_id
|
||||
# and not has_pending_user_message and not worktree_path
|
||||
# Pre-fix all 5 clauses matched → exempted FROM the result (i.e., hidden).
|
||||
# Post-fix the worktree_path clause is truthy, so the session SHOULD render.
|
||||
is_hidden_by_empty_filter = (
|
||||
sidecar.get("title", "Untitled") == "Untitled"
|
||||
and sidecar.get("message_count", 0) == 0
|
||||
and not sidecar.get("active_stream_id")
|
||||
and not sidecar.get("pending_user_message")
|
||||
and not sidecar.get("worktree_path")
|
||||
)
|
||||
assert not is_hidden_by_empty_filter, (
|
||||
"Worktree session was hidden by the empty-session exempt filter; "
|
||||
"worktree_path must be propagated through state.db recovery so the "
|
||||
"exempt clause in api/models.py:1070 does NOT match for this session."
|
||||
)
|
||||
Reference in New Issue
Block a user