diff --git a/.env.example b/.env.example index 19dff7dc..768eca50 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 629543fc..6c6f75e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `/.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 `/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..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=&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: ` + `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 `.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..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 diff --git a/README.md b/README.md index 951ec728..84864971 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/config.py b/api/config.py index d32d644a..0c241ce5 100644 --- a/api/config.py +++ b/api/config.py @@ -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"][]["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..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 = [] diff --git a/api/models.py b/api/models.py index 62099f05..b15d5531 100644 --- a/api/models.py +++ b/api/models.py @@ -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: diff --git a/api/providers.py b/api/providers.py index 78fc9a0e..a1291c20 100644 --- a/api/providers.py +++ b/api/providers.py @@ -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, {}) diff --git a/api/routes.py b/api/routes.py index 18354885..ca0c2dd2 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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, diff --git a/api/session_recovery.py b/api/session_recovery.py index 0e93033a..62f74026 100644 --- a/api/session_recovery.py +++ b/api/session_recovery.py @@ -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, diff --git a/api/streaming.py b/api/streaming.py index 7b29cd1e..565a454e 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -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 diff --git a/api/worktrees.py b/api/worktrees.py new file mode 100644 index 00000000..330a4385 --- /dev/null +++ b/api/worktrees.py @@ -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(), + } diff --git a/docs/onboarding.md b/docs/onboarding.md new file mode 100644 index 00000000..f6409f96 --- /dev/null +++ b/docs/onboarding.md @@ -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://:/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 `/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. diff --git a/docs/pr-media/1955/after-workspace-menu.png b/docs/pr-media/1955/after-workspace-menu.png new file mode 100644 index 00000000..a3db2769 Binary files /dev/null and b/docs/pr-media/1955/after-workspace-menu.png differ diff --git a/docs/pr-media/1955/before-workspace-menu.png b/docs/pr-media/1955/before-workspace-menu.png new file mode 100644 index 00000000..3906dce4 Binary files /dev/null and b/docs/pr-media/1955/before-workspace-menu.png differ diff --git a/server.py b/server.py index bbaf1cb8..610527e1 100644 --- a/server.py +++ b/server.py @@ -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 diff --git a/static/i18n.js b/static/i18n.js index 2dec4953..20820631 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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 diff --git a/static/panels.js b/static/panels.js index 06dc13eb..d2517582 100644 --- a/static/panels.js +++ b/static/panels.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'), diff --git a/static/sessions.js b/static/sessions.js index 2251b902..023cf845 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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'); diff --git a/static/style.css b/static/style.css index 0c2f6ea2..7e87920f 100644 --- a/static/style.css +++ b/static/style.css @@ -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;} diff --git a/tests/conftest.py b/tests/conftest.py index 4a9ac293..8b993538 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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", diff --git a/tests/test_conftest_network_isolation.py b/tests/test_conftest_network_isolation.py new file mode 100644 index 00000000..5e43e0bc --- /dev/null +++ b/tests/test_conftest_network_isolation.py @@ -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" diff --git a/tests/test_issue1499_onboarding_probe.py b/tests/test_issue1499_onboarding_probe.py index f63d3b40..2adefcb1 100644 --- a/tests/test_issue1499_onboarding_probe.py +++ b/tests/test_issue1499_onboarding_probe.py @@ -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", diff --git a/tests/test_issue1955_worktree_sessions.py b/tests/test_issue1955_worktree_sessions.py new file mode 100644 index 00000000..c1c623e7 --- /dev/null +++ b/tests/test_issue1955_worktree_sessions.py @@ -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()) diff --git a/tests/test_issue1955_worktree_ui_static.py b/tests/test_issue1955_worktree_ui_static.py new file mode 100644 index 00000000..d160d5a8 --- /dev/null +++ b/tests/test_issue1955_worktree_ui_static.py @@ -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 diff --git a/tests/test_pr1970_lmstudio_base_url_fallback.py b/tests/test_pr1970_lmstudio_base_url_fallback.py new file mode 100644 index 00000000..cb6c01d2 --- /dev/null +++ b/tests/test_pr1970_lmstudio_base_url_fallback.py @@ -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..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..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..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 diff --git a/tests/test_session_import_workspace_validation.py b/tests/test_session_import_workspace_validation.py new file mode 100644 index 00000000..318fcbdb --- /dev/null +++ b/tests/test_session_import_workspace_validation.py @@ -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") diff --git a/tests/test_session_save_mode.py b/tests/test_session_save_mode.py index 71c06753..836fca88 100644 --- a/tests/test_session_save_mode.py +++ b/tests/test_session_save_mode.py @@ -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", diff --git a/tests/test_state_db_worktree_recovery.py b/tests/test_state_db_worktree_recovery.py new file mode 100644 index 00000000..dc6993db --- /dev/null +++ b/tests/test_state_db_worktree_recovery.py @@ -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." + )