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:
nesquena-hermes
2026-05-10 23:20:29 -07:00
committed by GitHub
27 changed files with 1747 additions and 18 deletions
+1 -1
View File
@@ -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
+48
View File
@@ -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
+6 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+12
View File
@@ -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
+73
View File
@@ -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(),
}
+181
View File
@@ -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

+93
View File
@@ -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
+40
View File
@@ -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
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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;}
+140
View File
@@ -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",
+130
View File
@@ -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"
+14 -2
View File
@@ -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",
+241
View File
@@ -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")
+63
View File
@@ -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",
+128
View File
@@ -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."
)