Merge pull request #2665 from nesquena/release/stage-394

Release v0.51.101 (Release BY / stage-394 / 2-PR deep-review batch)
This commit is contained in:
nesquena-hermes
2026-05-20 17:09:59 -07:00
committed by GitHub
15 changed files with 3270 additions and 5 deletions
+19
View File
@@ -4,6 +4,25 @@
## [Unreleased]
## [v0.51.101] — 2026-05-20 — Release BY (stage-394 — 2-PR deep-review batch — workspace Git backend + sidebar tab visibility toggle)
### Added
- **PR #2625** by @stocky789 — Add backend Git operations for the workspace panel. New `api/workspace_git.py` module exposes read-only ops (`/api/git/status`, `/api/git/branches`, `/api/git/diff`, `/api/git/commit-message[-selected]`) unconditionally and mutating ops (`stage`, `unstage`, `discard`, `commit`, `commit-selected`, `checkout`, `stash-checkout`, `pull`, `push`) only when `HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE=1` is set in the environment — default OFF so existing deployments are unaffected. All subprocess calls use `["git", *args]` with `shell=False`, all branch/ref names go through `git check-ref-format --branch` validation before flowing to `git switch -c`, and `subprocess.env` is scrubbed of `GIT_DIR`/`GIT_WORK_TREE`/`GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM`/`GIT_CONFIG_COUNT`/`GIT_CONFIG_PARAMETERS` plus the full `GIT_CONFIG_KEY_*`/`GIT_CONFIG_VALUE_*` namespace before every invocation. `GIT_INDEX_FILE` is intentionally preserved to drive selected-file commits through a private temporary index. Paths are bound to the workspace root via `safe_resolve_ws()` + `Path.relative_to()` enforcement (rejects `..` traversal and symlinked escapes); active-stream gate prevents mutations during a running agent turn. Documented in `docs/workspace-git.md` with the full trust model (hooks-as-RCE warning, default-allowed vs gated lists, env-scrub enumeration). Frontend UI ships in a follow-up PR.
- **PR #2636** by @FrancescoFarinola — Per-tab sidebar visibility toggle in Settings → Appearance. Power users can hide unused rail tabs (Tasks, Kanban, Skills, Memory, Spaces, Profiles, Todos, Insights, Logs) while keeping Chat and Settings always reachable. Settings is per-profile so each profile can have its own hidden-tabs preference; an inline `<script>` in `<head>` applies `nav-tab-hidden` from `localStorage` before first paint so toggled-off tabs don't flash visible on reload. Default off — no tabs are hidden out of the box; existing deployments are unaffected. Chips use `role="switch"` + `aria-checked` for clear screen-reader narration, and the container has `role="group"` + `aria-labelledby` pointing at its label. Backend validator strips `chat` and `settings` from `hidden_tabs` at save time as a belt-and-suspenders against tampered POSTs. Profile switch reconciliation: `_refreshProfileSwitchBackground` re-fetches `/api/settings` and re-applies `hidden_tabs` after a profile change so the new profile's preference takes effect immediately.
### Maintainer additions during stage
- `_refreshProfileSwitchBackground` profile-switch reconciliation for #2636 (Profile A's hidden-tabs no longer bleeds into Profile B until Settings is opened).
- `role="switch"` + `aria-checked` chip a11y for #2636 (was `aria-pressed` — confusing polarity for users where chip-off looks like the off state).
- Server-side `hidden_tabs` validator strip of `chat`/`settings` for #2636.
- CSS contrast fix for #2636`color: #1a1a1a` + `font-weight: 600` on filled chips (was `color: var(--bg-page)` which resolved to white in dark theme and was barely readable on the gold accent).
- 3 new regression tests for the #2636 maintainer additions (profile-switch wiring, chat/settings server-side strip, a11y switch role).
### UX approval
PR #2636 went through the full multi-viewport screenshot gate (390 mobile, 1280 laptop, 1440 desktop, 1920 wide; both light and dark themes; default-on and 3-off mixed states; rail-effect proof showing hidden tabs collapse cleanly). Approved via Telegram for merge.
## [v0.51.100] — 2026-05-20 — Release BX (stage-393 — 3-PR deep-review batch — lazy journal recovery retry + faster profile-switch + cross-tab session list SSE sync)
### Fixed
+13
View File
@@ -4323,6 +4323,7 @@ _SETTINGS_DEFAULTS = {
"font_size": "default", # small | default | large | xlarge
"session_jump_buttons": False, # show Start/End transcript jump pills
"session_endless_scroll": False, # auto-load older transcript pages while scrolling upward
"hidden_tabs": [], # sidebar tab panel names hidden by user (e.g. ["tasks","kanban"]); chat and settings are always visible
"language": "en", # UI locale code; must match a key in static/i18n.js LOCALES
"bot_name": os.getenv(
"HERMES_WEBUI_BOT_NAME", "Hermes"
@@ -4514,6 +4515,18 @@ def save_settings(settings: dict) -> dict:
not isinstance(v, str) or not _SETTINGS_LANG_RE.match(v)
):
continue
# Validate hidden_tabs: must be a list of non-empty strings.
# Belt-and-suspenders strip of "chat" and "settings" so a
# malicious POST cannot lock the user out of the always-visible
# nav tabs even though the client also filters them at apply time.
# Stage-394 follow-up to #2636 deep review.
if k == "hidden_tabs":
if not isinstance(v, list):
continue
v = [
s for s in v
if isinstance(s, str) and s.strip() and s not in {"chat", "settings"}
]
# Coerce bool keys
if k in _SETTINGS_BOOL_KEYS:
v = bool(v)
+549 -4
View File
@@ -2227,6 +2227,7 @@ from api.workspace import (
get_last_workspace,
set_last_workspace,
list_dir,
dir_signature,
list_workspace_suggestions,
read_file_content,
safe_resolve_ws,
@@ -4147,6 +4148,15 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/list":
return _handle_list_dir(handler, parsed)
if parsed.path == "/api/git/status":
return _handle_git_status(handler, parsed)
if parsed.path == "/api/git/branches":
return _handle_git_branches(handler, parsed)
if parsed.path == "/api/git/diff":
return _handle_git_diff(handler, parsed)
if parsed.path == "/api/personalities":
# Read personalities from config.yaml agent.personalities section
# (matches hermes-agent CLI behavior, not filesystem SOUL.md approach)
@@ -4178,9 +4188,22 @@ def handle_get(handler, parsed) -> bool:
s = get_session(sid)
except KeyError:
return bad(handler, "Session not found", 404)
from api.workspace import git_info_for_workspace
from api.workspace_git import GitWorkspaceError, git_status
info = git_info_for_workspace(Path(s.workspace))
try:
status = git_status(Path(s.workspace))
except GitWorkspaceError as e:
return _git_bad(handler, e)
totals = status.get("totals") or {}
info = None if not status.get("is_git") else {
"branch": status.get("branch"),
"dirty": totals.get("changed", 0),
"modified": (totals.get("staged", 0) or 0) + (totals.get("unstaged", 0) or 0),
"untracked": totals.get("untracked", 0),
"ahead": status.get("ahead", 0),
"behind": status.get("behind", 0),
"is_git": True,
}
return j(handler, {"git": info})
if parsed.path == "/api/commands":
@@ -5328,6 +5351,43 @@ def handle_post(handler, parsed) -> bool:
with cron_profile_context():
return _handle_cron_resume(handler, body)
# ── Git workspace ops (POST) ──
if parsed.path == "/api/git/stage":
return _handle_git_stage(handler, body)
if parsed.path == "/api/git/unstage":
return _handle_git_unstage(handler, body)
if parsed.path == "/api/git/discard":
return _handle_git_discard(handler, body)
if parsed.path == "/api/git/commit-message":
return _handle_git_commit_message(handler, body)
if parsed.path == "/api/git/commit-message-selected":
return _handle_git_commit_message_selected(handler, body)
if parsed.path == "/api/git/commit":
return _handle_git_commit(handler, body)
if parsed.path == "/api/git/commit-selected":
return _handle_git_commit_selected(handler, body)
if parsed.path == "/api/git/fetch":
return _handle_git_remote_action(handler, body, "fetch")
if parsed.path == "/api/git/pull":
return _handle_git_remote_action(handler, body, "pull")
if parsed.path == "/api/git/push":
return _handle_git_remote_action(handler, body, "push")
if parsed.path == "/api/git/checkout":
return _handle_git_checkout(handler, body)
if parsed.path == "/api/git/stash-checkout":
return _handle_git_stash_checkout(handler, body)
# ── File ops (POST) ──
if parsed.path == "/api/file/delete":
return _handle_file_delete(handler, body)
@@ -6242,11 +6302,14 @@ def _handle_list_dir(handler, parsed):
except Exception:
return bad(handler, "Session not found", 404)
try:
rel_path = qs.get("path", ["."])[0]
entries = list_dir(Path(workspace), rel_path)
return j(
handler,
{
"entries": list_dir(Path(workspace), qs.get("path", ["."])[0]),
"path": qs.get("path", ["."])[0],
"entries": entries,
"signature": dir_signature(Path(workspace), rel_path, entries),
"path": rel_path,
},
)
except (FileNotFoundError, ValueError) as e:
@@ -8753,6 +8816,488 @@ def _handle_cron_resume(handler, body):
return bad(handler, "Job not found", 404)
def _git_session(handler, session_id: str):
if not session_id:
bad(handler, "session_id required")
return None
try:
return get_session(session_id)
except KeyError:
bad(handler, "Session not found", 404)
return None
def _git_session_workspace(handler, session_id: str):
session = _git_session(handler, session_id)
if session is None:
return None
return Path(session.workspace)
def _git_session_and_workspace(handler, session_id: str):
session = _git_session(handler, session_id)
if session is None:
return None, None
return session, Path(session.workspace)
def _git_locked_by_active_stream(session) -> bool:
stream_id = getattr(session, "active_stream_id", None)
if not stream_id:
return False
try:
from api.config import STREAMS, STREAMS_LOCK
with STREAMS_LOCK:
return stream_id in STREAMS
except Exception:
return False
def _git_reject_destructive_if_unsafe(handler, session) -> bool:
from api.workspace_git import (
GitWorkspaceError,
WORKSPACE_GIT_DESTRUCTIVE_ENV,
workspace_git_destructive_enabled,
)
if not workspace_git_destructive_enabled():
_git_bad(
handler,
GitWorkspaceError(
f"Destructive workspace Git operations are disabled. Set {WORKSPACE_GIT_DESTRUCTIVE_ENV}=1 to enable them.",
"destructive_git_disabled",
),
status=403,
)
return True
if _git_locked_by_active_stream(session):
_git_bad(
handler,
GitWorkspaceError(
"A session run is active. Wait for it to finish before running this Git operation.",
"active_stream",
),
status=409,
)
return True
return False
def _handle_git_status(handler, parsed):
qs = parse_qs(parsed.query)
workspace = _git_session_workspace(handler, qs.get("session_id", [""])[0])
if workspace is None:
return True
try:
from api.workspace_git import GitWorkspaceError, git_status
return j(handler, {"git": git_status(workspace)})
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_git_branches(handler, parsed):
qs = parse_qs(parsed.query)
workspace = _git_session_workspace(handler, qs.get("session_id", [""])[0])
if workspace is None:
return True
try:
from api.workspace_git import GitWorkspaceError, git_branches
return j(handler, {"branches": git_branches(workspace)})
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_git_diff(handler, parsed):
qs = parse_qs(parsed.query)
workspace = _git_session_workspace(handler, qs.get("session_id", [""])[0])
if workspace is None:
return True
path = qs.get("path", [""])[0]
kind = qs.get("kind", ["unstaged"])[0]
if not path:
return bad(handler, "path required")
try:
from api.workspace_git import GitWorkspaceError, git_diff
return j(handler, {"diff": git_diff(workspace, path, kind)})
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _git_bad(handler, err, status: int = 400):
return j(
handler,
{
"error": _sanitize_error(err),
"code": getattr(err, "code", "git_failed") or "git_failed",
},
status=status,
)
def _git_paths_from_body(body) -> list[str]:
raw_paths = body.get("paths")
if raw_paths is None and body.get("path"):
raw_paths = [body.get("path")]
if isinstance(raw_paths, str):
raw_paths = [raw_paths]
if not isinstance(raw_paths, list):
raise ValueError("paths must be a list")
return [str(path) for path in raw_paths]
def _handle_git_stage(handler, body):
try:
require(body, "session_id")
paths = _git_paths_from_body(body)
session, workspace = _git_session_and_workspace(handler, body["session_id"])
if workspace is None:
return True
if _git_reject_destructive_if_unsafe(handler, session):
return True
from api.workspace_git import GitWorkspaceError, git_stage
return j(handler, {"ok": True, "git": git_stage(workspace, paths)})
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_git_unstage(handler, body):
try:
require(body, "session_id")
paths = _git_paths_from_body(body)
session, workspace = _git_session_and_workspace(handler, body["session_id"])
if workspace is None:
return True
if _git_reject_destructive_if_unsafe(handler, session):
return True
from api.workspace_git import GitWorkspaceError, git_unstage
return j(handler, {"ok": True, "git": git_unstage(workspace, paths)})
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_git_discard(handler, body):
try:
require(body, "session_id")
paths = _git_paths_from_body(body)
session, workspace = _git_session_and_workspace(handler, body["session_id"])
if workspace is None:
return True
if _git_reject_destructive_if_unsafe(handler, session):
return True
from api.workspace_git import GitWorkspaceError, git_discard
return j(
handler,
{
"ok": True,
"git": git_discard(
workspace,
paths,
delete_untracked=bool(body.get("delete_untracked")),
),
},
)
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _llm_git_commit_message(system_prompt: str, user_prompt: str, session=None) -> str:
from api import profiles as profiles_api
active_profile = profiles_api.get_active_profile_name() or "default"
with profiles_api.profile_env_for_background_worker(
active_profile,
"git commit message",
logger_override=logger,
):
from api.config import (
get_effective_default_model,
model_with_provider_context,
resolve_custom_provider_connection,
resolve_model_provider,
)
session_model = str(getattr(session, "model", "") or "").strip()
session_provider = str(getattr(session, "model_provider", "") or "").strip() or None
model_for_resolution = (
model_with_provider_context(session_model, session_provider)
if session_model
else get_effective_default_model()
)
_main_model, _main_provider, _main_base_url = resolve_model_provider(model_for_resolution)
_main_api_key = None
try:
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
from hermes_cli.runtime_provider import resolve_runtime_provider
_rt = resolve_runtime_provider_with_anthropic_env_lock(
resolve_runtime_provider,
requested=_main_provider,
)
_main_api_key = _rt.get("api_key")
if not _main_provider:
_main_provider = _rt.get("provider")
if not _main_base_url:
_main_base_url = _rt.get("base_url")
except Exception as _e:
logger.debug("git commit message runtime provider resolution failed: %s", _e)
if isinstance(_main_provider, str) and _main_provider.startswith("custom:"):
_cp_key, _cp_base = resolve_custom_provider_connection(_main_provider)
if not _main_api_key and _cp_key:
_main_api_key = _cp_key
if not _main_base_url and _cp_base:
_main_base_url = _cp_base
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
main_runtime = {
"provider": _main_provider,
"model": _main_model,
"base_url": _main_base_url,
"api_key": _main_api_key,
}
try:
from agent.auxiliary_client import get_text_auxiliary_client
aux_client, aux_model = get_text_auxiliary_client(
"compression",
main_runtime=main_runtime,
)
if aux_client is not None and aux_model:
response = aux_client.chat.completions.create(
model=aux_model,
messages=messages,
)
return str(response.choices[0].message.content or "").strip()
except Exception as _e:
logger.debug("git commit message auxiliary model failed; falling back to main model: %s", _e)
from run_agent import AIAgent
agent = AIAgent(
model=_main_model,
provider=_main_provider,
base_url=_main_base_url,
api_key=_main_api_key,
platform="webui",
quiet_mode=True,
enabled_toolsets=[],
session_id=f"git-commit-message-{uuid.uuid4().hex[:8]}",
)
result = agent.run_conversation(
user_message=user_prompt,
system_message=system_prompt,
conversation_history=[],
task_id=f"git-commit-message-{uuid.uuid4().hex[:8]}",
)
return str(result.get("final_response") or "").strip()
def _handle_git_commit_message(handler, body):
from api.workspace_git import (
GitWorkspaceError,
clean_generated_commit_message,
staged_commit_message_prompt,
)
try:
require(body, "session_id")
session = get_session(body["session_id"])
workspace = Path(session.workspace)
prompt = staged_commit_message_prompt(workspace)
message = clean_generated_commit_message(
_llm_git_commit_message(prompt["system_prompt"], prompt["user_prompt"], session=session)
)
if not message:
raise GitWorkspaceError("No commit message was generated")
return j(handler, {"ok": True, "message": message, "truncated": bool(prompt.get("truncated"))})
except KeyError:
return bad(handler, "Session not found", 404)
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
except Exception as e:
logger.exception("git commit message generation failed")
return bad(handler, _sanitize_error(e), 500)
def _handle_git_commit_message_selected(handler, body):
from api.workspace_git import (
GitWorkspaceError,
clean_generated_commit_message,
selected_commit_message_prompt,
)
try:
require(body, "session_id")
paths = _git_paths_from_body(body)
session = get_session(body["session_id"])
workspace = Path(session.workspace)
prompt = selected_commit_message_prompt(workspace, paths)
message = clean_generated_commit_message(
_llm_git_commit_message(prompt["system_prompt"], prompt["user_prompt"], session=session)
)
if not message:
raise GitWorkspaceError("No commit message was generated")
return j(handler, {"ok": True, "message": message, "truncated": bool(prompt.get("truncated"))})
except KeyError:
return bad(handler, "Session not found", 404)
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
except Exception as e:
logger.exception("selected git commit message generation failed")
return bad(handler, _sanitize_error(e), 500)
def _handle_git_commit(handler, body):
try:
require(body, "session_id", "message")
session, workspace = _git_session_and_workspace(handler, body["session_id"])
if workspace is None:
return True
if _git_reject_destructive_if_unsafe(handler, session):
return True
from api.workspace_git import GitWorkspaceError, git_commit
return j(handler, git_commit(workspace, body.get("message", "")))
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_git_commit_selected(handler, body):
try:
require(body, "session_id", "message")
paths = _git_paths_from_body(body)
session, workspace = _git_session_and_workspace(handler, body["session_id"])
if workspace is None:
return True
if _git_reject_destructive_if_unsafe(handler, session):
return True
from api.workspace_git import GitWorkspaceError, git_commit_selected
return j(handler, git_commit_selected(workspace, body.get("message", ""), paths))
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_git_remote_action(handler, body, action: str):
try:
require(body, "session_id")
session, workspace = _git_session_and_workspace(handler, body["session_id"])
if workspace is None:
return True
if action in {"pull", "push"} and _git_reject_destructive_if_unsafe(handler, session):
return True
from api.workspace_git import GitWorkspaceError, git_fetch, git_pull, git_push
actions = {
"fetch": git_fetch,
"pull": git_pull,
"push": git_push,
}
return j(handler, actions[action](workspace))
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_git_checkout(handler, body):
try:
require(body, "session_id", "ref", "mode")
session, workspace = _git_session_and_workspace(handler, body["session_id"])
if workspace is None:
return True
if _git_reject_destructive_if_unsafe(handler, session):
return True
from api.workspace_git import GitWorkspaceError, git_checkout
result = git_checkout(
workspace,
str(body.get("ref", "")),
str(body.get("mode", "local")),
new_branch=body.get("new_branch"),
track=bool(body.get("track")),
dirty_mode=str(body.get("dirty_mode", "block")),
)
return j(
handler,
{
"ok": True,
"git": result.get("status"),
"branches": result.get("branches"),
"current_branch": result.get("current_branch"),
"message": result.get("message", ""),
},
)
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_git_stash_checkout(handler, body):
try:
require(body, "session_id", "ref", "mode")
session, workspace = _git_session_and_workspace(handler, body["session_id"])
if workspace is None:
return True
if _git_reject_destructive_if_unsafe(handler, session):
return True
from api.workspace_git import GitWorkspaceError, git_stash_and_checkout
result = git_stash_and_checkout(
workspace,
str(body.get("ref", "")),
str(body.get("mode", "local")),
new_branch=body.get("new_branch"),
track=bool(body.get("track")),
)
return j(
handler,
{
"ok": True,
"git": result.get("status"),
"branches": result.get("branches"),
"current_branch": result.get("current_branch"),
"message": result.get("message", ""),
"stash_name": result.get("stash_name", ""),
"stashed": bool(result.get("stashed")),
"restored_stash": result.get("restored_stash"),
"restore_failed": bool(result.get("restore_failed")),
"restore_error": result.get("restore_error", ""),
"restore_stash": result.get("restore_stash"),
},
)
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_file_delete(handler, body):
try:
require(body, "session_id", "path")
+40 -1
View File
@@ -7,6 +7,7 @@ profile has its own workspace configuration. State files live at
``{profile_home}/webui_state/last_workspace.txt``. The global STATE_DIR
paths are used as fallback when no profile module is available.
"""
import hashlib
import json
import logging
import os
@@ -714,12 +715,18 @@ def list_dir(workspace: Path, rel: str='.'):
display_path = str(Path(item.name))
if rel and rel != '.':
display_path = rel + '/' + display_path
try:
item_stat = item.lstat()
mtime_ns = item_stat.st_mtime_ns
except OSError:
mtime_ns = None
entry = {
'name': item.name,
'path': display_path,
'type': 'symlink',
'target': str(link_target),
'is_dir': is_dir,
'mtime_ns': mtime_ns,
}
if not is_dir:
try:
@@ -733,17 +740,49 @@ def list_dir(workspace: Path, rel: str='.'):
entry_path = item.name
if rel and rel != '.':
entry_path = rel + '/' + item.name
try:
item_stat = item.stat()
size = item_stat.st_size if item.is_file() else None
mtime_ns = item_stat.st_mtime_ns
except OSError:
size = None
mtime_ns = None
entries.append({
'name': item.name,
'path': entry_path,
'type': 'dir' if item.is_dir() else 'file',
'size': item.stat().st_size if item.is_file() else None,
'size': size,
'mtime_ns': mtime_ns,
})
if len(entries) >= 200:
break
return entries
def dir_signature(workspace: Path, rel: str = '.', entries: list[dict] | None = None) -> str:
"""Return a cheap, stable signature for a listed workspace directory.
The signature is based only on bounded directory-entry metadata already used
by the workspace tree: names, displayed paths, entry type, file sizes,
mtimes, and symlink targets. It intentionally does not read file contents.
"""
if entries is None:
entries = list_dir(workspace, rel)
payload = []
for entry in entries:
payload.append({
'name': entry.get('name'),
'path': entry.get('path'),
'type': entry.get('type'),
'is_dir': entry.get('is_dir'),
'size': entry.get('size'),
'mtime_ns': entry.get('mtime_ns'),
'target': entry.get('target'),
})
raw = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
def read_file_content(workspace: Path, rel: str) -> dict:
target = safe_resolve_ws(workspace, rel)
if not target.is_file():
+1261
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
# Workspace Git controls
Workspace Git controls let the browser inspect Git state for the active session workspace. By default,
WebUI can read status, list branches, show diffs, fetch remote refs, and generate commit-message
suggestions. Actions that modify the repository, index, or worktree are disabled unless the WebUI
process is started with `HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE=1`.
> **Trust model - read this first.** Once mutating Git actions are enabled, a browser action can run
> Git commands inside a mounted workspace. Some Git commands can also run repository hook code from
> `.git/hooks/` or a configured `core.hooksPath`. That hook code runs as the WebUI process user, with
> the WebUI process permissions and environment. Treat hooks that download or execute code as code
> execution by the WebUI process user.
## What works by default
Without `HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE=1`, WebUI can:
- show repository status
- list branches
- show file diffs
- fetch from the configured remote
- generate commit-message suggestions
Fetch may update remote-tracking refs, but it does not change the worktree, merge branches, create
commits, or push changes.
Commit-message generation may send staged or selected diff context to the configured model provider.
The UI labels this before generation.
Diffs for untracked files are size checked before WebUI reads file contents. Large or binary files
return metadata instead of inline diff text.
## What requires explicit enablement
These actions are blocked unless `HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE=1` is set:
- stage and unstage
- discard changes
- commit and selected-file commit
- pull and push
- checkout
- branch switching that parks and restores local changes
Leave the flag unset for deployments where WebUI should only inspect mounted workspaces. Set it only
when browser users are trusted to modify those repositories from WebUI.
When the flag is enabled, branch switching parks and restores local changes automatically. If the
branch being left has local changes, WebUI parks those changes in a WebUI-owned Git stash, switches
branches, and then restores any WebUI-owned stash for the branch being entered. If Git cannot restore
the stash cleanly, WebUI leaves the stash in place and reports the restore failure instead of dropping
it.
## Workspace and path scope
The browser does not send an arbitrary repository path. Git requests carry a session id and, when
needed, workspace-relative file paths. The server resolves the session workspace, checks each path
against that workspace, and then builds Git pathspecs from the checked paths.
Git commands run through `subprocess.run` with `shell=False`. Local status and diff commands use a 5
second timeout. Remote operations such as fetch, pull, and push use a 60 second timeout.
Before any Git subprocess starts, WebUI removes inherited `GIT_DIR`, `GIT_WORK_TREE`,
`GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_COUNT`, `GIT_CONFIG_PARAMETERS`, and injected
`GIT_CONFIG_KEY_*` / `GIT_CONFIG_VALUE_*` values from the environment. Those variables can redirect
Git to a different repository or inject config, so WebUI does not trust them from the parent process.
`GIT_INDEX_FILE` is the intentional exception. Selected-file commits use a temporary index so WebUI
can commit only the requested files, then remove the temporary index afterward.
## Coordination with active runs
Mutating Git actions are rejected while the same session has a live stream. The API returns
`active_stream` instead of racing a running agent that may be writing files in the same workspace.
Mutating Git actions also take a per-repository lock. If another Git mutation is already running for
that repository, the API returns `operation_in_progress`.
## Hook and remote behavior
WebUI does not bypass Git hooks. Hook code may come from `.git/hooks/` or from a configured
`core.hooksPath`.
Commit actions run normal commit hooks such as `pre-commit`, `commit-msg`, and `post-commit`. Push can
run `pre-push`. Pull uses `--ff-only`, so it does not create a merge commit, but it is still a Git
operation running under the WebUI process.
Push keeps Git's normal non-fast-forward protection and reports non-fast-forward rejection separately
from general Git failures.
If a hook fails, the API returns a structured Git error instead of hiding the failure. Other classified
failures include authentication errors, missing upstream branches, conflicts, dirty worktrees, invalid
refs, missing Git binaries, and timeouts.
+20
View File
@@ -283,6 +283,26 @@ function expandSidebar(){
}catch(_){}
_syncSidebarAria();
})();
// ── Boot-time tab visibility ────────────────────────────────────────────────
// Apply hidden tabs from localStorage. The primary flash-prevention is an
// inline <script> in index.html (after sidebar-nav) that runs synchronously
// before first paint. This IIFE is a secondary fallback: it ensures consistency
// after panels.js is loaded and handles the active-tab switch. No-op if
// panels.js hasn't loaded yet (typeof guard).
(function _restoreTabVisibility(){
try{
if(typeof _applyTabVisibility==='function'&&typeof _getHiddenTabs==='function'){
_applyTabVisibility(_getHiddenTabs());
}
var active=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]')
||document.querySelector('.sidebar-nav .nav-tab.active[data-panel]');
if(active&&active.classList.contains('nav-tab-hidden')){
var chatBtn=document.querySelector('.rail .rail-btn.nav-tab[data-panel="chat"]');
if(chatBtn)chatBtn.classList.add('active');
if(active)active.classList.remove('active');
}
}catch(_){}
})();
function toggleMobileFiles(){
toggleWorkspacePanel();
}
+33
View File
@@ -511,6 +511,9 @@ const LOCALES = {
settings_label_session_endless_scroll: 'Load older messages while scrolling up',
settings_desc_session_endless_scroll: 'When enabled, older messages load automatically as you scroll upward. When disabled, use the older-messages button.',
settings_label_tab_visibility: 'Sidebar tabs',
settings_desc_tab_visibility: 'Choose which tabs appear in the sidebar and rail. Chat and Settings are always visible.',
open_in_browser: 'Open in browser',
settings_dropdown_conversation: 'Conversation',
settings_dropdown_appearance: 'Appearance',
@@ -1735,6 +1738,9 @@ const LOCALES = {
settings_label_session_endless_scroll: 'Carica messaggi precedenti scorrendo in alto',
settings_desc_session_endless_scroll: 'Se abilitato, i messaggi precedenti si caricano automaticamente scorrendo in alto. Se disabilitato, usa il pulsante messaggi precedenti.',
settings_label_tab_visibility: 'Schede della barra laterale',
settings_desc_tab_visibility: 'Scegli quali schede mostrare nella barra laterale e nel rail. Chat e Impostazioni sono sempre visibili.',
open_in_browser: 'Apri nel browser',
settings_dropdown_conversation: 'Conversazione',
settings_dropdown_appearance: 'Aspetto',
@@ -2951,6 +2957,9 @@ const LOCALES = {
settings_label_session_endless_scroll: '上スクロールで古いメッセージを読み込む',
settings_desc_session_endless_scroll: '有効にすると、上にスクロールしたとき古いメッセージを自動で読み込みます。無効の場合は古いメッセージボタンを使います。',
settings_label_tab_visibility: 'サイドバータブ',
settings_desc_tab_visibility: 'サイドバーとレールに表示するタブを選択します。チャットと設定は常に表示されます。',
open_in_browser: 'ブラウザで開く',
settings_dropdown_conversation: '会話',
settings_dropdown_appearance: '外観',
@@ -4688,6 +4697,9 @@ const LOCALES = {
settings_label_session_endless_scroll: 'Загружать старые сообщения при прокрутке вверх',
settings_desc_session_endless_scroll: 'Если включено, старые сообщения загружаются автоматически при прокрутке вверх. Если выключено, используйте кнопку загрузки старых сообщений.',
settings_label_tab_visibility: 'Вкладки боковой панели',
settings_desc_tab_visibility: 'Выберите, какие вкладки отображаются на боковой панели и в рейле. Чат и настройки всегда видны.',
open_in_browser: 'Открыть в браузере',
settings_section_system_title: 'System',
settings_tab_appearance: 'Appearance',
@@ -5831,6 +5843,9 @@ const LOCALES = {
settings_label_session_endless_scroll: 'Cargar mensajes antiguos al desplazarse hacia arriba',
settings_desc_session_endless_scroll: 'Si está activado, los mensajes antiguos se cargan automáticamente al desplazarte hacia arriba. Si está desactivado, usa el botón de mensajes antiguos.',
settings_label_tab_visibility: 'Pestañas de la barra lateral',
settings_desc_tab_visibility: 'Elige qué pestañas aparecen en la barra lateral y el rail. Chat y Configuración siempre están visibles.',
open_in_browser: 'Abrir en el navegador',
settings_section_system_title: 'System',
settings_tab_appearance: 'Appearance',
@@ -6706,6 +6721,9 @@ const LOCALES = {
settings_desc_session_endless_scroll: 'Wenn aktiviert, werden ältere Nachrichten beim Hochscrollen automatisch geladen. Wenn deaktiviert, nutzt du den Button für ältere Nachrichten.',
settings_label_tab_visibility: 'Seitenleiste-Tabs',
settings_desc_tab_visibility: 'Wähle, welche Tabs in der Seitenleiste und im Rail angezeigt werden. Chat und Einstellungen sind immer sichtbar.',
workspace_drag_hint: 'Ziehen zum Neuordnen',
workspace_reorder_failed: 'Neuordnen fehlgeschlagen',
open_in_browser: 'Im Browser öffnen',
@@ -8147,6 +8165,9 @@ const LOCALES = {
settings_label_session_endless_scroll: '向上滚动时加载更早的消息',
settings_desc_session_endless_scroll: '启用后,向上滚动时会自动加载更早的消息。禁用时请使用加载更早消息按钮。',
settings_label_tab_visibility: '侧边栏标签',
settings_desc_tab_visibility: '选择在侧边栏和导航栏中显示哪些标签。聊天和设置始终可见。',
open_in_browser: '在浏览器中打开',
settings_section_system_title: '系统',
settings_tab_appearance: '外观',
@@ -8613,6 +8634,9 @@ const LOCALES = {
settings_label_session_endless_scroll: '向上捲動時載入較早訊息',
settings_desc_session_endless_scroll: '啟用後,向上捲動時會自動載入較早訊息。停用時請使用載入較早訊息按鈕。',
settings_label_tab_visibility: '側邊欄標籤',
settings_desc_tab_visibility: '選擇在側邊欄和導航列中顯示哪些標籤。聊天和設定始終可見。',
open_in_browser: '在瀏覽器中開啓',
settings_dropdown_conversation: '對話',
settings_dropdown_appearance: '外觀',
@@ -9915,6 +9939,9 @@ const LOCALES = {
settings_label_session_endless_scroll: 'Carregar mensagens antigas ao rolar para cima',
settings_desc_session_endless_scroll: 'Quando ativado, mensagens antigas carregam automaticamente ao rolar para cima. Quando desativado, use o botão de mensagens antigas.',
settings_label_tab_visibility: 'Abas da barra lateral',
settings_desc_tab_visibility: 'Escolha quais abas aparecem na barra lateral e no rail. Chat e Configurações estão sempre visíveis.',
open_in_browser: 'Abrir no navegador',
settings_dropdown_conversation: 'Conversa',
settings_dropdown_appearance: 'Aparência',
@@ -11034,6 +11061,9 @@ const LOCALES = {
settings_label_session_endless_scroll: '위로 스크롤할 때 이전 메시지 불러오기',
settings_desc_session_endless_scroll: '활성화하면 위로 스크롤할 때 이전 메시지를 자동으로 불러옵니다. 비활성화하면 이전 메시지 버튼을 사용합니다.',
settings_label_tab_visibility: '사이드바 탭',
settings_desc_tab_visibility: '사이드바와 레일에 표시할 탭을 선택하세요. 채팅과 설정은 항상 표시됩니다.',
open_in_browser: '브라우저에서 열기',
settings_dropdown_conversation: '대화',
settings_dropdown_appearance: '외형',
@@ -12168,6 +12198,9 @@ const LOCALES = {
settings_desc_session_jump_buttons: 'Affichez les boutons flottants de début et de fin lors de la lecture de longs historiques de session.',
settings_label_session_endless_scroll: 'Charger les anciens messages en faisant défiler vers le haut',
settings_desc_session_endless_scroll: 'Lorsqu\'ils sont activés, les anciens messages se chargent automatiquement lorsque vous faites défiler vers le haut. Lorsqu\'il est désactivé, utilisez le bouton des messages plus anciens.',
settings_label_tab_visibility: 'Onglets de la barre latérale',
settings_desc_tab_visibility: 'Choisissez quels onglets apparaissent dans la barre latérale et le rail. Chat et Paramètres sont toujours visibles.',
open_in_browser: 'Ouvrir dans le navigateur',
settings_dropdown_conversation: 'Conversation',
settings_dropdown_appearance: 'Apparence',
+10
View File
@@ -178,6 +178,11 @@
<!-- Settings button mirrored here for mobile (rail is desktop-only via @media >=768px). Keep in sync with rail entry. -->
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="settings" onclick="switchPanel('settings',{fromRailClick:true})" data-tooltip="Settings" data-i18n-title="tab_settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
</div>
<!-- Flash-prevention: apply hidden tab visibility from localStorage synchronously
before any paint. Mirrors the theme/skin/font-size inline scripts in <head>.
The boot.js _restoreTabVisibility IIFE is a secondary fallback that also
handles the active-tab switch. -->
<script>(function(){try{var h=localStorage.getItem('hermes-webui-hidden-tabs');if(!h)return;var p=JSON.parse(h);if(!Array.isArray(p))return;var a=new Set(['chat','settings']);p.forEach(function(id){if(a.has(id))return;document.querySelectorAll('[data-panel="'+id+'"]').forEach(function(el){el.classList.add('nav-tab-hidden');});});try{var ac=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]')||document.querySelector('.sidebar-nav .nav-tab.active[data-panel]');if(ac&&ac.classList.contains('nav-tab-hidden')){ac.classList.remove('active');var c=document.querySelector('.rail .rail-btn.nav-tab[data-panel="chat"]')||document.querySelector('.sidebar-nav .nav-tab[data-panel="chat"]');if(c)c.classList.add('active');}}catch(_){}}catch(e){}})();</script>
<!-- Chat panel -->
<div class="panel-view active" id="panelChat">
<div class="panel-head">
@@ -971,6 +976,11 @@
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_session_endless_scroll">When enabled, older messages load automatically as you scroll upward. When disabled, use the older-messages button.</div>
</div>
<div class="settings-field">
<label id="tabVisibilityLabel" data-i18n="settings_label_tab_visibility">Sidebar tabs</label>
<div id="tabVisibilityChips" class="tab-visibility-chips" role="group" aria-labelledby="tabVisibilityLabel"></div>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_tab_visibility">Choose which tabs appear in the sidebar and rail. Chat and Settings are always visible.</div>
</div>
<div id="settingsAppearanceAutosaveStatus" class="settings-autosave-status" aria-live="polite"></div>
</div>
<div class="settings-pane" id="settingsPanePreferences">
+104
View File
@@ -4508,6 +4508,17 @@ function _refreshProfileSwitchBackground(gen){
if (gen !== _profileSwitchGeneration) return;
if (S.session && typeof syncTopbar === 'function') syncTopbar();
}).catch(()=>{});
// Reconcile per-profile sidebar tab visibility. hidden_tabs is a per-profile
// appearance setting; without this fetch, Profile A's hidden-tabs choice
// would remain in effect under Profile B until the user opens Settings.
// Stage-394 follow-up to #2636 deep review.
Promise.resolve(api('/api/settings')).then(function(s){
if (gen !== _profileSwitchGeneration) return;
var hidden = (s && Array.isArray(s.hidden_tabs)) ? s.hidden_tabs : [];
hidden = hidden.filter(function(x){ return typeof x === 'string' && x.trim(); });
if (typeof _setHiddenTabs === 'function') _setHiddenTabs(hidden);
if (typeof _applyTabVisibility === 'function') _applyTabVisibility(hidden);
}).catch(function(){});
}
async function loadProfilesPanel() {
@@ -5113,6 +5124,86 @@ let _settingsAppearanceAutosaveRetryPayload = null;
let _settingsPreferencesAutosaveTimer = null;
let _settingsPreferencesAutosaveRetryPayload = null;
// ── Sidebar tab visibility ─────────────────────────────────────────────────
const _ALWAYS_VISIBLE_TABS = new Set(['chat','settings']);
const _HIDDEN_TABS_LS_KEY = 'hermes-webui-hidden-tabs';
function _getHiddenTabs(){
try{var h=localStorage.getItem(_HIDDEN_TABS_LS_KEY);if(h){var p=JSON.parse(h);if(Array.isArray(p))return p;}}catch(e){}
return[];
}
function _setHiddenTabs(panels){
try{localStorage.setItem(_HIDDEN_TABS_LS_KEY,JSON.stringify(panels));}catch(e){}
}
function _applyTabVisibility(hidden){
if(!Array.isArray(hidden)) hidden=[];
// Hide/unhide all [data-panel] elements (sidebar-nav buttons + rail buttons)
document.querySelectorAll('[data-panel]').forEach(function(el){
var panel=el.dataset.panel;
if(!panel)return;
var shouldHide=hidden.indexOf(panel)!==-1;
// Never hide always-visible panels (chat, settings) even if present in hidden_tabs
if(_ALWAYS_VISIBLE_TABS.has(panel)) shouldHide=false;
el.classList.toggle('nav-tab-hidden',shouldHide);
});
// If the currently active tab is hidden, switch to chat
var activeRail=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]');
var activeSidebar=document.querySelector('.sidebar-nav .nav-tab.active[data-panel]');
var activeEl=activeRail||activeSidebar;
if(activeEl&&activeEl.classList.contains('nav-tab-hidden')){
if(typeof switchPanel==='function') switchPanel('chat');
}
}
function _renderTabVisibilityChips(){
var container=$('tabVisibilityChips');
if(!container)return;
var hidden=_getHiddenTabs();
// Scan rail buttons to discover all available panels (skip always-visible + dashboard-link)
var tabs=document.querySelectorAll('.rail .rail-btn.nav-tab[data-panel]');
container.innerHTML='';
tabs.forEach(function(tab){
var panel=tab.dataset.panel;
if(!panel||_ALWAYS_VISIBLE_TABS.has(panel))return;
if(tab.classList.contains('dashboard-link'))return;
var label=tab.dataset.tooltip||tab.dataset.label||panel;
// Capitalize first letter
label=label.charAt(0).toUpperCase()+label.slice(1);
var chip=document.createElement('button');
chip.type='button';
chip.className='tab-visibility-chip';
var isOff=hidden.indexOf(panel)!==-1;
if(isOff)chip.classList.add('chip-off');
chip.textContent=label;
chip.setAttribute('data-tab-panel',panel);
// Use role="switch" + aria-checked instead of aria-pressed so screen
// readers narrate "Tasks switch on/off" (matches user mental model) rather
// than "Tasks toggle button pressed/not-pressed" (where the polarity is
// confusing because chip-off looks like the "off" state).
chip.setAttribute('role','switch');
chip.setAttribute('aria-checked',isOff?'false':'true');
chip.onclick=function(){_toggleTabVisibilityChip(panel);};
container.appendChild(chip);
});
}
function _toggleTabVisibilityChip(panel){
if(_ALWAYS_VISIBLE_TABS.has(panel))return;
var hidden=_getHiddenTabs();
var idx=hidden.indexOf(panel);
if(idx!==-1){
hidden.splice(idx,1);
}else{
hidden.push(panel);
}
_setHiddenTabs(hidden);
_applyTabVisibility(hidden);
_renderTabVisibilityChips();
_scheduleAppearanceAutosave();
}
function switchSettingsSection(name){
const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='plugins'||name==='system')?name:'conversation';
_settingsSection=section;
@@ -5240,6 +5331,7 @@ function _appearancePayloadFromUi(){
font_size: ($('settingsFontSize')||{}).value || localStorage.getItem('hermes-font-size') || 'default',
session_jump_buttons: !!($('settingsSessionJumpButtons')||{}).checked,
session_endless_scroll: !!($('settingsSessionEndlessScroll')||{}).checked,
hidden_tabs: _getHiddenTabs(),
};
}
@@ -5495,6 +5587,18 @@ async function loadSettingsPanel(){
_scheduleAppearanceAutosave();
};
}
// Tab visibility chips (dynamically populated from DOM)
var hiddenTabs=[];
if(Array.isArray(settings.hidden_tabs)){
// Server value takes priority — even an empty array means "no tabs hidden"
hiddenTabs=settings.hidden_tabs.filter(function(s){return typeof s==='string'&&s.trim();});
}else{
// Server has no hidden_tabs key — fall back to localStorage
hiddenTabs=_getHiddenTabs();
}
_setHiddenTabs(hiddenTabs);
_applyTabVisibility(hiddenTabs);
_renderTabVisibilityChips();
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'))
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
+11
View File
@@ -2774,6 +2774,17 @@ main.main.showing-logs > #mainLogs{display:flex;}
#mainSettings .settings-field > div[style*="color:var(--muted)"],
#mainSettings .settings-field > div[style*="color: var(--muted)"]{font-size:12px;color:var(--muted);line-height:1.5;}
/* Sidebar tab visibility — hide nav-tab and matching rail-btn when toggled off by user */
.nav-tab-hidden{display:none!important;}
/* Tab visibility chips in Settings > Appearance */
.tab-visibility-chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:4px;}
.tab-visibility-chip{font-size:12px;line-height:1;padding:5px 10px;border-radius:var(--radius-pill,999px);border:1px solid var(--accent,var(--link));background:var(--accent,var(--link));color:#1a1a1a;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,color .15s,opacity .15s;white-space:nowrap;user-select:none;}
.tab-visibility-chip:hover{filter:brightness(0.92);}
.tab-visibility-chip:focus-visible{outline:2px solid var(--link,var(--accent));outline-offset:2px;}
.tab-visibility-chip.chip-off{background:transparent;color:var(--muted);border-color:var(--border);font-weight:400;}
.tab-visibility-chip.chip-off:hover{border-color:var(--muted);color:var(--text);}
/* Selects & text/password inputs uniform. Uses !important to win
over legacy inline styles that were written for the modal era. */
#mainSettings select,
+1
View File
@@ -536,6 +536,7 @@ def test_server():
# pytest-side block can't see.
env["HERMES_WEBUI_TEST_NETWORK_BLOCK"] = "1"
env.update({
"HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE": "1",
"HERMES_WEBUI_PORT": str(TEST_PORT),
"HERMES_WEBUI_HOST": "127.0.0.1",
"HERMES_WEBUI_STATE_DIR": str(TEST_STATE_DIR),
+163
View File
@@ -0,0 +1,163 @@
"""Regression tests for sidebar tab visibility feature.
Covers backend validation round-trip, frontend static contracts,
i18n coverage, and the key integration points that have broken before.
"""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
def test_backend_round_trip_and_validation(monkeypatch, tmp_path):
"""hidden_tabs defaults to [], saves/reloads, rejects non-list, filters empty strings."""
import api.config as config
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
loaded = config.load_settings()
assert loaded["hidden_tabs"] == [], "default must be empty list"
saved = config.save_settings({"hidden_tabs": ["kanban", "insights"]})
assert saved["hidden_tabs"] == ["kanban", "insights"]
assert config.load_settings()["hidden_tabs"] == ["kanban", "insights"]
# Non-list is rejected, default preserved
bad = config.save_settings({"hidden_tabs": "not-a-list"})
assert bad["hidden_tabs"] == ["kanban", "insights"]
# Empty strings filtered, empty list clears
saved = config.save_settings({"hidden_tabs": ["kanban", "", " ", "logs"]})
assert saved["hidden_tabs"] == ["kanban", "logs"]
cleared = config.save_settings({"hidden_tabs": []})
assert cleared["hidden_tabs"] == []
# Must NOT be in bool keys (would corrupt the list)
assert "hidden_tabs" not in config._SETTINGS_BOOL_KEYS
assert "hidden_tabs" in config._SETTINGS_ALLOWED_KEYS
def test_frontend_static_contracts():
"""All required HTML, JS, CSS, and boot elements exist with correct wiring."""
# HTML: container in Appearance pane
assert 'id="tabVisibilityChips"' in INDEX_HTML
assert 'data-i18n="settings_label_tab_visibility"' in INDEX_HTML
assert 'data-i18n="settings_desc_tab_visibility"' in INDEX_HTML
appearance_start = INDEX_HTML.find('id="settingsPaneAppearance"')
prefs_start = INDEX_HTML.find('id="settingsPanePreferences"', appearance_start + 1)
chips_pos = INDEX_HTML.find('id="tabVisibilityChips"')
assert appearance_start < chips_pos < prefs_start, \
"tabVisibilityChips must be inside Appearance pane"
# JS: constants, functions, and wiring
assert "_ALWAYS_VISIBLE_TABS" in PANELS_JS
assert "'chat'" in PANELS_JS.split("_ALWAYS_VISIBLE_TABS")[1][:80]
assert "'settings'" in PANELS_JS.split("_ALWAYS_VISIBLE_TABS")[1][:80]
assert "_HIDDEN_TABS_LS_KEY" in PANELS_JS
assert "hermes-webui-hidden-tabs" in PANELS_JS
for fn in ("_getHiddenTabs", "_setHiddenTabs", "_applyTabVisibility",
"_renderTabVisibilityChips", "_toggleTabVisibilityChip"):
assert f"function {fn}(" in PANELS_JS, f"panels.js must define {fn}()"
# Toggle must autosave and respect always-visible tabs
toggle_block = PANELS_JS[PANELS_JS.find("function _toggleTabVisibilityChip"):]
toggle_body = toggle_block[:toggle_block.find("\nfunction ", 1) or 2000]
assert "_scheduleAppearanceAutosave" in toggle_body
assert "_ALWAYS_VISIBLE_TABS" in toggle_body
# Appearance payload must include hidden_tabs
payload_block = PANELS_JS[PANELS_JS.find("function _appearancePayloadFromUi"):]
payload_body = payload_block[:payload_block.find("\nfunction ", 1) or 2000]
assert "hidden_tabs" in payload_body
assert "_getHiddenTabs" in payload_body
# CSS: hidden class and chip styles
assert ".nav-tab-hidden" in STYLE_CSS
assert "display:none" in STYLE_CSS.split(".nav-tab-hidden")[1][:80].replace(" ", "")
assert ".tab-visibility-chip" in STYLE_CSS
# No flash-prevention script in <head> (DOM elements don't exist at that point)
head_end = INDEX_HTML.find("</head>")
assert "hermes-webui-hidden-tabs" not in INDEX_HTML[:head_end]
def test_boot_restores_visibility_from_localstorage():
"""boot.js must call _applyTabVisibility at boot time so hidden tabs take effect."""
assert "_restoreTabVisibility" in BOOT_JS
block = BOOT_JS[BOOT_JS.find("_restoreTabVisibility"):][:1500]
assert "_applyTabVisibility" in block, \
"boot.js must call _applyTabVisibility so tabs are hidden before first paint"
def test_i18n_coverage():
"""Label and description keys must exist in all locales with matching counts."""
label_count = I18N_JS.count("settings_label_tab_visibility")
desc_count = I18N_JS.count("settings_desc_tab_visibility")
assert label_count >= 11, f"Expected ≥11 locales, found {label_count}"
assert desc_count >= 11, f"Expected ≥11 locales, found {desc_count}"
assert label_count == desc_count, \
f"Label ({label_count}) and desc ({desc_count}) counts must match"
def test_backend_rejects_chat_and_settings_in_hidden_tabs(monkeypatch, tmp_path):
"""Server-side belt-and-suspenders: a malicious POST that tries to hide
`chat` or `settings` (the always-visible nav tabs) must be filtered out
server-side, not just client-side. The client already applies the same
filter at apply time, but the server should not let a tampered payload
persist the forbidden values."""
import api.config as config
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
saved = config.save_settings({"hidden_tabs": ["chat", "kanban", "settings", "logs"]})
assert saved["hidden_tabs"] == ["kanban", "logs"], \
"chat and settings must be stripped server-side"
# Even an all-forbidden payload reduces to empty (not rejected — empty is fine)
saved = config.save_settings({"hidden_tabs": ["chat", "settings"]})
assert saved["hidden_tabs"] == []
def test_profile_switch_reconciles_hidden_tabs():
"""When a user switches profiles, the new profile's hidden_tabs value
must be applied the per-profile settings.json is the source of truth,
not the previous profile's localStorage value. Stage-394 added a
/api/settings refetch in _refreshProfileSwitchBackground; verify it stays
wired (the API call + the _applyTabVisibility call)."""
bg_start = PANELS_JS.find("function _refreshProfileSwitchBackground")
assert bg_start >= 0, "_refreshProfileSwitchBackground not found"
bg_end = PANELS_JS.find("\nfunction ", bg_start + 1)
if bg_end < 0:
bg_end = bg_start + 4000
bg_body = PANELS_JS[bg_start:bg_end]
assert "/api/settings" in bg_body, \
"profile-switch background refresh must re-fetch settings for the new profile"
assert "_applyTabVisibility" in bg_body, \
"profile-switch background refresh must re-apply tab visibility"
assert "hidden_tabs" in bg_body, \
"profile-switch background refresh must read hidden_tabs from server response"
def test_chip_a11y_uses_switch_role_with_aria_checked():
"""Chips should use role=switch + aria-checked instead of plain
aria-pressed. The pressed/not-pressed wording is confusing for a toggle
that visually represents an on/off switch; role=switch + aria-checked
matches user mental model."""
render_block = PANELS_JS[PANELS_JS.find("function _renderTabVisibilityChips"):]
body = render_block[:render_block.find("\nfunction ", 1) or 3000]
assert "role" in body and "'switch'" in body, \
"chip should declare role='switch' for clearer screen-reader narration"
assert "aria-checked" in body, "chip should use aria-checked to match role=switch"
# Group container also has role=group + aria-labelledby
assert 'role="group"' in INDEX_HTML, "chip container needs role=group"
assert 'aria-labelledby="tabVisibilityLabel"' in INDEX_HTML, \
"chip container needs aria-labelledby pointing at the label"
# Focus-visible style exists
assert ".tab-visibility-chip:focus-visible" in STYLE_CSS, \
"chip needs a :focus-visible style for keyboard nav"
+26
View File
@@ -0,0 +1,26 @@
from api.workspace import dir_signature, list_dir
def test_directory_signature_is_metadata_only_and_changes_with_entries(tmp_path):
(tmp_path / "alpha.txt").write_text("one", encoding="utf-8")
entries = list_dir(tmp_path, ".")
sig1 = dir_signature(tmp_path, ".", entries)
assert isinstance(sig1, str)
assert len(sig1) == 64
assert all("mtime_ns" in entry for entry in entries)
(tmp_path / "beta.txt").write_text("two", encoding="utf-8")
entries2 = list_dir(tmp_path, ".")
sig2 = dir_signature(tmp_path, ".", entries2)
assert sig2 != sig1
def test_directory_signature_can_be_computed_from_supplied_entries(tmp_path):
(tmp_path / "alpha.txt").write_text("one", encoding="utf-8")
entries = list_dir(tmp_path, ".")
assert dir_signature(tmp_path, ".", entries) == dir_signature(tmp_path, ".", entries)
+928
View File
@@ -0,0 +1,928 @@
import json
import pathlib
import subprocess
import types
import uuid
import urllib.error
import urllib.parse
import urllib.request
from io import BytesIO
import pytest
from tests._pytest_port import BASE
ROOT = pathlib.Path(__file__).parent.parent
def _git(cwd, *args):
result = subprocess.run(
["git", *args],
cwd=str(cwd),
shell=False,
text=True,
capture_output=True,
timeout=20,
)
assert result.returncode == 0, result.stderr or result.stdout
return result.stdout
def _init_repo(path):
path.mkdir(parents=True, exist_ok=True)
_git(path, "init")
_git(path, "config", "user.email", "hermes-tests@example.invalid")
_git(path, "config", "user.name", "Hermes Tests")
return path
def _commit_all(path, message="initial"):
_git(path, "add", ".")
_git(path, "commit", "-m", message)
def _get(path):
try:
with urllib.request.urlopen(BASE + path, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
def _post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(
BASE + path,
data=data,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
def _make_session(created_list, ws=None):
body = {}
if ws:
body["workspace"] = str(ws)
data, status = _post("/api/session/new", body)
assert status == 200
sid = data["session"]["session_id"]
created_list.append(sid)
return sid, pathlib.Path(data["session"]["workspace"])
class _CaptureHandler:
def __init__(self):
self.status = None
self.headers = {}
self.response_headers = []
self.wfile = BytesIO()
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 payload(self):
return json.loads(self.wfile.getvalue().decode("utf-8"))
def test_git_status_non_git_workspace(tmp_path):
from api.workspace_git import git_status
ws = tmp_path / "plain"
ws.mkdir()
assert git_status(ws) == {"is_git": False}
def test_git_status_handles_staged_unstaged_untracked_deleted_and_renamed(tmp_path):
from api.workspace_git import git_status
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
(repo / "delete-me.txt").write_text("bye\n", encoding="utf-8")
(repo / "old name.txt").write_text("move\n", encoding="utf-8")
_commit_all(repo)
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
(repo / "staged.txt").write_text("staged\n", encoding="utf-8")
_git(repo, "add", "staged.txt")
(repo / "delete-me.txt").unlink()
_git(repo, "mv", "old name.txt", "new name.txt")
(repo / "untracked space.txt").write_text("new\nfile\n", encoding="utf-8")
status = git_status(repo)
by_path = {item["path"]: item for item in status["files"]}
assert status["is_git"] is True
assert by_path["tracked.txt"]["unstaged"] is True
assert by_path["staged.txt"]["staged"] is True
assert by_path["delete-me.txt"]["status"] == "D"
assert by_path["new name.txt"]["old_path"] == "old name.txt"
assert by_path["untracked space.txt"]["untracked"] is True
assert by_path["untracked space.txt"]["additions"] == 2
assert status["totals"]["changed"] >= 5
def test_git_status_reports_ignored_files_without_counting_them_as_changes(tmp_path):
from api.workspace_git import git_status
repo = _init_repo(tmp_path / "repo")
(repo / ".gitignore").write_text("*.log\nbuild/\n", encoding="utf-8")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
(repo / "debug.log").write_text("ignored log\n", encoding="utf-8")
build = repo / "build"
build.mkdir()
(build / "artifact.txt").write_text("ignored artifact\n", encoding="utf-8")
status = git_status(repo)
by_path = {item["path"]: item for item in status["files"]}
assert by_path["tracked.txt"]["unstaged"] is True
assert by_path["debug.log"]["ignored"] is True
assert by_path["debug.log"]["status"] == "Ignored"
assert by_path["build/"]["ignored"] is True
assert by_path["build/"]["staged"] is False
assert by_path["build/"]["untracked"] is False
assert status["totals"]["changed"] == 1
assert status["totals"]["untracked"] == 0
def test_git_status_ignores_crlf_only_worktree_noise(tmp_path):
from api.workspace_git import git_status
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8", newline="\n")
_commit_all(repo)
(repo / "tracked.txt").write_text("one\r\ntwo\r\n", encoding="utf-8", newline="")
raw = _git(repo, "status", "--porcelain", "--", "tracked.txt")
assert raw.startswith(" M")
status = git_status(repo)
assert status["totals"]["changed"] == 0
assert status["files"] == []
assert status["noise_filtering"]["active"] is True
assert status["noise_filtering"]["crlf_only"] == 1
def test_git_status_keeps_real_edit_with_crlf_endings(tmp_path):
from api.workspace_git import git_status
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8", newline="\n")
_commit_all(repo)
(repo / "tracked.txt").write_text("one\r\ntwo\r\nthree\r\n", encoding="utf-8", newline="")
status = git_status(repo)
by_path = {item["path"]: item for item in status["files"]}
assert status["totals"]["changed"] == 1
assert by_path["tracked.txt"]["unstaged"] is True
assert by_path["tracked.txt"]["additions"] == 1
assert by_path["tracked.txt"]["deletions"] == 0
def test_git_status_ignores_filemode_only_noise(tmp_path):
from api.workspace_git import git_status
repo = _init_repo(tmp_path / "repo")
script = repo / "script.sh"
script.write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
_commit_all(repo)
_git(repo, "update-index", "--chmod=+x", "script.sh")
raw = _git(repo, "status", "--porcelain", "--", "script.sh")
assert "script.sh" in raw
status = git_status(repo)
assert status["totals"]["changed"] == 0
assert status["files"] == []
assert status["noise_filtering"]["active"] is True
def test_git_status_scopes_nested_workspace_to_that_directory(tmp_path):
from api.workspace_git import git_status
repo = _init_repo(tmp_path / "repo")
nested = repo / "app"
nested.mkdir()
(nested / "inside.txt").write_text("inside\n", encoding="utf-8")
(repo / "outside.txt").write_text("outside\n", encoding="utf-8")
_commit_all(repo)
(nested / "inside.txt").write_text("inside\nchanged\n", encoding="utf-8")
(repo / "outside.txt").write_text("outside\nchanged\n", encoding="utf-8")
status = git_status(nested)
paths = {item["path"] for item in status["files"]}
assert paths == {"inside.txt"}
def test_git_diff_generates_untracked_text_diff_and_blocks_escape(tmp_path):
from api.workspace_git import GitWorkspaceError, git_diff
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
(repo / "new file.txt").write_text("hello\nworld\n", encoding="utf-8")
diff = git_diff(repo, "new file.txt", "unstaged")
assert diff["binary"] is False
assert "+++ b/new file.txt" in diff["diff"]
assert "+hello" in diff["diff"]
with pytest.raises(GitWorkspaceError):
git_diff(repo, "../outside.txt", "unstaged")
def test_git_status_reports_untracked_files_inside_directories(tmp_path):
from api.workspace_git import git_discard, git_status
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
nested = repo / "newdir"
nested.mkdir()
(nested / "a.txt").write_text("hello\n", encoding="utf-8")
status = git_status(repo)
paths = {item["path"] for item in status["files"]}
assert "newdir/a.txt" in paths
assert "newdir/" not in paths
git_discard(repo, ["newdir/a.txt"], delete_untracked=True)
assert not (nested / "a.txt").exists()
def test_git_status_reports_ignored_files_without_counting_them_as_changed(tmp_path):
from api.workspace_git import git_status
repo = _init_repo(tmp_path / "repo")
(repo / ".gitignore").write_text("*.log\nbuild/\n", encoding="utf-8")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
(repo / "debug.log").write_text("ignored log\n", encoding="utf-8")
build = repo / "build"
build.mkdir()
(build / "artifact.txt").write_text("ignored artifact\n", encoding="utf-8")
status = git_status(repo)
by_path = {item["path"]: item for item in status["files"]}
assert by_path["tracked.txt"]["unstaged"] is True
assert by_path["debug.log"]["ignored"] is True
assert by_path["debug.log"]["status"] == "Ignored"
assert by_path["debug.log"]["staged"] is False
assert by_path["debug.log"]["unstaged"] is False
assert by_path["debug.log"]["untracked"] is False
assert any(item["ignored"] and item["path"].startswith("build") for item in status["files"])
assert status["totals"]["changed"] == 1
assert status["totals"]["untracked"] == 0
def test_git_diff_large_untracked_file_is_bounded(tmp_path):
from api.workspace_git import DIFF_SIZE_LIMIT, git_diff, git_status
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
large = repo / "large.txt"
large.write_text("x" * (DIFF_SIZE_LIMIT + 1), encoding="utf-8")
status = git_status(repo)
by_path = {item["path"]: item for item in status["files"]}
assert by_path["large.txt"]["untracked"] is True
assert by_path["large.txt"]["additions"] == 0
diff = git_diff(repo, "large.txt", "unstaged")
assert diff["too_large"] is True
assert diff["diff"] == ""
def test_git_stage_unstage_discard_and_commit(tmp_path):
from api.workspace_git import git_commit, git_discard, git_stage, git_status, git_unstage
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
staged = git_stage(repo, ["tracked.txt"])
assert staged["totals"]["staged"] == 1
unstaged = git_unstage(repo, ["tracked.txt"])
assert unstaged["totals"]["staged"] == 0
assert unstaged["totals"]["unstaged"] == 1
git_discard(repo, ["tracked.txt"])
assert git_status(repo)["totals"]["changed"] == 0
(repo / "tracked.txt").write_text("one\nthree\n", encoding="utf-8")
git_stage(repo, ["tracked.txt"])
committed = git_commit(repo, "Update tracked file")
assert committed["ok"] is True
assert committed["commit"]
assert committed["status"]["totals"]["changed"] == 0
def test_git_commit_selected_ignores_unrelated_real_index(tmp_path):
from api.workspace_git import git_commit_selected, git_status
repo = _init_repo(tmp_path / "repo")
(repo / "selected.txt").write_text("one\n", encoding="utf-8")
(repo / "staged.txt").write_text("alpha\n", encoding="utf-8")
_commit_all(repo)
(repo / "selected.txt").write_text("one\ntwo\n", encoding="utf-8")
(repo / "staged.txt").write_text("alpha\nbeta\n", encoding="utf-8")
_git(repo, "add", "staged.txt")
committed = git_commit_selected(repo, "Commit selected only", ["selected.txt"])
assert committed["ok"] is True
assert committed["paths"] == ["selected.txt"]
assert _git(repo, "show", "--name-only", "--format=", "HEAD").splitlines() == ["selected.txt"]
by_path = {item["path"]: item for item in git_status(repo)["files"]}
assert "selected.txt" not in by_path
assert by_path["staged.txt"]["staged"] is True
def test_git_commit_selected_supports_initial_commit(tmp_path):
from api.workspace_git import git_commit_selected, git_status
repo = _init_repo(tmp_path / "repo")
(repo / "first.txt").write_text("first\n", encoding="utf-8")
committed = git_commit_selected(repo, "Initial selected commit", ["first.txt"])
assert committed["ok"] is True
assert _git(repo, "show", "--name-only", "--format=", "HEAD").splitlines() == ["first.txt"]
assert git_status(repo)["totals"]["changed"] == 0
def test_git_commit_selected_preserves_rename_semantics(tmp_path):
from api.workspace_git import git_commit_selected, git_status
repo = _init_repo(tmp_path / "repo")
(repo / "old.txt").write_text("old\n", encoding="utf-8")
_commit_all(repo)
_git(repo, "mv", "old.txt", "new.txt")
committed = git_commit_selected(repo, "Rename selected file", ["new.txt"])
assert committed["ok"] is True
assert _git(repo, "ls-tree", "--name-only", "HEAD").splitlines() == ["new.txt"]
assert "old.txt" not in _git(repo, "status", "--porcelain=v2")
assert git_status(repo)["totals"]["changed"] == 0
def test_git_commit_selected_handles_untracked_and_mixed_paths(tmp_path):
from api.workspace_git import git_commit_selected
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
(repo / "new.txt").write_text("new\n", encoding="utf-8")
committed = git_commit_selected(repo, "Commit mixed selected files", ["tracked.txt", "new.txt"])
assert committed["ok"] is True
assert set(_git(repo, "show", "--name-only", "--format=", "HEAD").splitlines()) == {
"tracked.txt",
"new.txt",
}
def test_git_commit_selected_respects_nested_workspace_scope(tmp_path):
from api.workspace_git import GitWorkspaceError, git_commit_selected
repo = _init_repo(tmp_path / "repo")
nested = repo / "app"
nested.mkdir()
(nested / "inside.txt").write_text("inside\n", encoding="utf-8")
(repo / "outside.txt").write_text("outside\n", encoding="utf-8")
_commit_all(repo)
(nested / "inside.txt").write_text("inside\nchanged\n", encoding="utf-8")
(repo / "outside.txt").write_text("outside\nchanged\n", encoding="utf-8")
committed = git_commit_selected(nested, "Nested selected commit", ["inside.txt"])
assert committed["paths"] == ["inside.txt"]
assert _git(repo, "show", "--name-only", "--format=", "HEAD").splitlines() == ["app/inside.txt"]
with pytest.raises(GitWorkspaceError) as outside:
git_commit_selected(nested, "Outside", ["../outside.txt"])
assert outside.value.code == "path_outside_workspace"
def test_git_commit_selected_rejects_conflicts_and_path_traversal(tmp_path):
from api.workspace_git import GitWorkspaceError, git_commit_selected
repo = _init_repo(tmp_path / "repo")
(repo / "conflict.txt").write_text("base\n", encoding="utf-8")
_commit_all(repo)
_git(repo, "checkout", "-b", "side")
(repo / "conflict.txt").write_text("side\n", encoding="utf-8")
_commit_all(repo, "side")
_git(repo, "checkout", "master")
(repo / "conflict.txt").write_text("main\n", encoding="utf-8")
_commit_all(repo, "main")
subprocess.run(["git", "merge", "side"], cwd=repo, shell=False, text=True, capture_output=True, timeout=20)
with pytest.raises(GitWorkspaceError) as conflict:
git_commit_selected(repo, "Nope", ["conflict.txt"])
assert conflict.value.code == "conflict"
with pytest.raises(GitWorkspaceError) as traversal:
git_commit_selected(repo, "Nope", ["../outside.txt"])
assert traversal.value.code == "path_outside_workspace"
def test_selected_commit_message_prompt_uses_selected_diff(tmp_path):
from api.workspace_git import selected_commit_message_prompt
repo = _init_repo(tmp_path / "repo")
(repo / "selected.txt").write_text("one\n", encoding="utf-8")
(repo / "other.txt").write_text("alpha\n", encoding="utf-8")
_commit_all(repo)
(repo / "selected.txt").write_text("one\ntwo\n", encoding="utf-8")
(repo / "other.txt").write_text("alpha\nbeta\n", encoding="utf-8")
prompt = selected_commit_message_prompt(repo, ["selected.txt"])
assert "selected.txt" in prompt["user_prompt"]
assert "+two" in prompt["user_prompt"]
assert "other.txt" not in prompt["user_prompt"]
assert "beta" not in prompt["user_prompt"]
def test_staged_commit_message_prompt_uses_only_staged_diff(tmp_path):
from api.workspace_git import (
GitWorkspaceError,
clean_generated_commit_message,
staged_commit_message_prompt,
)
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
(repo / "tracked.txt").write_text("one\nstaged\n", encoding="utf-8")
_git(repo, "add", "tracked.txt")
(repo / "tracked.txt").write_text("one\nstaged\nunstaged\n", encoding="utf-8")
prompt = staged_commit_message_prompt(repo)
assert prompt["truncated"] is False
assert "tracked.txt" in prompt["user_prompt"]
assert "+staged" in prompt["user_prompt"]
assert "unstaged" not in prompt["user_prompt"]
assert "Never mention AI, Cursor, Zed, agents" in prompt["system_prompt"]
_git(repo, "restore", "--staged", "tracked.txt")
with pytest.raises(GitWorkspaceError):
staged_commit_message_prompt(repo)
assert clean_generated_commit_message("```text\nSubject\n\n- Body\n```") == "Subject\n\n- Body"
def test_git_fetch_pull_and_push_with_upstream(tmp_path):
from api.workspace_git import git_fetch, git_pull, git_push, git_status
remote = tmp_path / "remote.git"
_git(tmp_path, "init", "--bare", str(remote))
origin = _init_repo(tmp_path / "origin")
(origin / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(origin)
_git(origin, "remote", "add", "origin", str(remote))
_git(origin, "push", "-u", "origin", "HEAD")
clone = tmp_path / "clone"
_git(tmp_path, "clone", str(remote), str(clone))
_git(clone, "config", "user.email", "hermes-tests@example.invalid")
_git(clone, "config", "user.name", "Hermes Tests")
(origin / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
_commit_all(origin, "Remote update")
_git(origin, "push")
fetched = git_fetch(clone)
assert fetched["status"]["behind"] == 1
pulled = git_pull(clone)
assert pulled["status"]["behind"] == 0
assert (clone / "tracked.txt").read_text(encoding="utf-8") == "one\ntwo\n"
(clone / "tracked.txt").write_text("one\ntwo\nthree\n", encoding="utf-8")
_git(clone, "add", "tracked.txt")
_git(clone, "commit", "-m", "Local update")
assert git_status(clone)["ahead"] == 1
pushed = git_push(clone)
assert pushed["status"]["ahead"] == 0
def test_git_branches_lists_local_remote_and_upstream(tmp_path):
from api.workspace_git import git_branches
remote = tmp_path / "remote.git"
_git(tmp_path, "init", "--bare", str(remote))
origin = _init_repo(tmp_path / "origin")
(origin / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(origin)
_git(origin, "branch", "-M", "main")
_git(origin, "remote", "add", "origin", str(remote))
_git(origin, "push", "-u", "origin", "main")
_git(remote, "symbolic-ref", "HEAD", "refs/heads/main")
clone = tmp_path / "clone"
_git(tmp_path, "clone", str(remote), str(clone))
branches = git_branches(clone)
assert branches["current"] == "main"
assert branches["detached"] is False
assert any(item["name"] == "main" and item["upstream"] == "origin/main" for item in branches["local"])
main = next(item for item in branches["local"] if item["name"] == "main")
assert "updated_relative" in main and "author" in main and "subject" in main
assert any(item["name"] == "origin/main" for item in branches["remote"])
assert not any(item["name"] == "origin" for item in branches["remote"])
def test_git_checkout_local_new_remote_dirty_and_invalid_refs(tmp_path):
from api.workspace_git import GitWorkspaceError, git_branches, git_checkout
remote = tmp_path / "remote.git"
_git(tmp_path, "init", "--bare", str(remote))
origin = _init_repo(tmp_path / "origin")
(origin / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(origin)
_git(origin, "branch", "-M", "main")
_git(origin, "remote", "add", "origin", str(remote))
_git(origin, "push", "-u", "origin", "main")
_git(remote, "symbolic-ref", "HEAD", "refs/heads/main")
_git(origin, "checkout", "-b", "remote-feature")
(origin / "remote.txt").write_text("remote\n", encoding="utf-8")
_commit_all(origin, "remote feature")
_git(origin, "push", "-u", "origin", "remote-feature")
clone = tmp_path / "clone"
_git(tmp_path, "clone", str(remote), str(clone))
_git(clone, "config", "user.email", "hermes-tests@example.invalid")
_git(clone, "config", "user.name", "Hermes Tests")
created = git_checkout(clone, "main", "new", new_branch="local-work")
assert created["current_branch"] == "local-work"
assert git_branches(clone)["current"] == "local-work"
switched = git_checkout(clone, "main", "local")
assert switched["current_branch"] == "main"
tracked = git_checkout(clone, "origin/remote-feature", "remote", new_branch="remote-feature", track=True)
assert tracked["current_branch"] == "remote-feature"
assert git_branches(clone)["upstream"] == "origin/remote-feature"
(clone / "tracked.txt").write_text("dirty\n", encoding="utf-8")
with pytest.raises(GitWorkspaceError) as dirty:
git_checkout(clone, "main", "local")
assert dirty.value.code == "dirty_worktree"
_git(clone, "restore", "tracked.txt")
with pytest.raises(GitWorkspaceError) as invalid:
git_checkout(clone, "does-not-exist", "local")
assert invalid.value.code in {"invalid_ref", "git_failed"}
def test_git_checkout_detached_requires_explicit_mode(tmp_path):
from api.workspace_git import git_branches, git_checkout
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
sha = _git(repo, "rev-parse", "--short", "HEAD").strip()
result = git_checkout(repo, sha, "detached")
assert result["ok"] is True
branches = git_branches(repo)
assert branches["detached"] is True
assert branches["current"] == sha
def test_git_stash_and_checkout_is_explicit(tmp_path):
from api.workspace_git import git_stash_and_checkout, git_status
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
_git(repo, "checkout", "-b", "target")
_git(repo, "checkout", "master")
(repo / "tracked.txt").write_text("dirty\n", encoding="utf-8")
result = git_stash_and_checkout(repo, "target", "local")
assert result["ok"] is True
assert result["stashed"] is True
assert result["stash_name"].startswith("hermes-webui branch switch")
assert result["current_branch"] == "target"
assert git_status(repo)["totals"]["changed"] == 0
assert "hermes-webui branch switch to target" in _git(repo, "stash", "list")
def test_git_stash_and_checkout_restores_branch_changes_when_returning(tmp_path):
from api.workspace_git import git_stash_and_checkout, git_status
repo = _init_repo(tmp_path / "repo")
_git(repo, "branch", "-M", "main")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
_git(repo, "checkout", "-b", "feature")
_git(repo, "checkout", "main")
(repo / "tracked.txt").write_text("main dirty\n", encoding="utf-8")
(repo / "main-only.txt").write_text("untracked on main\n", encoding="utf-8")
to_feature = git_stash_and_checkout(repo, "feature", "local")
assert to_feature["ok"] is True
assert to_feature["stashed"] is True
assert to_feature["current_branch"] == "feature"
assert git_status(repo)["totals"]["changed"] == 0
assert not (repo / "main-only.txt").exists()
(repo / "feature-only.txt").write_text("untracked on feature\n", encoding="utf-8")
to_main = git_stash_and_checkout(repo, "main", "local")
assert to_main["ok"] is True
assert to_main["stashed"] is True
assert to_main["current_branch"] == "main"
assert to_main["restored_stash"]["branch"] == "main"
assert (repo / "tracked.txt").read_text(encoding="utf-8") == "main dirty\n"
assert (repo / "main-only.txt").read_text(encoding="utf-8") == "untracked on main\n"
assert not (repo / "feature-only.txt").exists()
stash_list = _git(repo, "stash", "list")
assert "On main: hermes-webui branch switch" not in stash_list
assert "On feature: hermes-webui branch switch" in stash_list
def test_git_stash_and_checkout_reports_restore_conflicts_without_dropping_stash(tmp_path):
from api.workspace_git import git_stash_and_checkout
repo = _init_repo(tmp_path / "repo")
_git(repo, "branch", "-M", "main")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
_git(repo, "checkout", "-b", "feature")
_git(repo, "checkout", "main")
(repo / "tracked.txt").write_text("main dirty\n", encoding="utf-8")
git_stash_and_checkout(repo, "feature", "local")
_git(repo, "checkout", "main")
(repo / "tracked.txt").write_text("main changed while parked\n", encoding="utf-8")
_commit_all(repo, "advance main")
_git(repo, "checkout", "feature")
result = git_stash_and_checkout(repo, "main", "local")
assert result["ok"] is True
assert result["current_branch"] == "main"
assert result["restore_failed"] is True
assert result["restore_stash"]["branch"] == "main"
assert "On main: hermes-webui branch switch" in _git(repo, "stash", "list")
def test_git_stash_checkout_validates_before_stashing(tmp_path):
from api.workspace_git import GitWorkspaceError, git_stash_and_checkout
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
(repo / "tracked.txt").write_text("dirty\n", encoding="utf-8")
with pytest.raises(GitWorkspaceError) as invalid:
git_stash_and_checkout(repo, "missing-branch", "local")
assert invalid.value.code == "invalid_ref"
assert "M tracked.txt" in _git(repo, "status", "--porcelain")
assert _git(repo, "stash", "list") == ""
def test_git_routes_status_diff_stage_unstage_discard_commit(cleanup_test_sessions):
sid, base_ws = _make_session(cleanup_test_sessions)
repo = base_ws / f"git-route-{uuid.uuid4().hex[:8]}"
_init_repo(repo)
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
_post("/api/session/update", {"session_id": sid, "workspace": str(repo), "model": "openai/gpt-5.4-mini"})
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
status, code = _get(f"/api/git/status?session_id={sid}")
assert code == 200
assert status["git"]["totals"]["unstaged"] == 1
diff, code = _get(
f"/api/git/diff?session_id={sid}&path={urllib.parse.quote('tracked.txt')}&kind=unstaged"
)
assert code == 200
assert "+two" in diff["diff"]["diff"]
staged, code = _post("/api/git/stage", {"session_id": sid, "paths": ["tracked.txt"]})
assert code == 200 and staged["git"]["totals"]["staged"] == 1
unstaged, code = _post("/api/git/unstage", {"session_id": sid, "paths": ["tracked.txt"]})
assert code == 200 and unstaged["git"]["totals"]["unstaged"] == 1
discarded, code = _post("/api/git/discard", {"session_id": sid, "paths": ["tracked.txt"]})
assert code == 200 and discarded["git"]["totals"]["changed"] == 0
(repo / "tracked.txt").write_text("one\nthree\n", encoding="utf-8")
_post("/api/git/stage", {"session_id": sid, "paths": ["tracked.txt"]})
committed, code = _post("/api/git/commit", {"session_id": sid, "message": "Route commit"})
assert code == 200
assert committed["ok"] is True
assert committed["status"]["totals"]["changed"] == 0
def test_git_routes_branches_and_checkout(cleanup_test_sessions):
sid, base_ws = _make_session(cleanup_test_sessions)
repo = base_ws / f"git-branch-route-{uuid.uuid4().hex[:8]}"
_init_repo(repo)
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
_git(repo, "branch", "-M", "main")
_git(repo, "checkout", "-b", "feature")
_git(repo, "checkout", "main")
_post("/api/session/update", {"session_id": sid, "workspace": str(repo), "model": "openai/gpt-5.4-mini"})
branches, code = _get(f"/api/git/branches?session_id={sid}")
assert code == 200
assert branches["branches"]["current"] == "main"
assert any(item["name"] == "feature" for item in branches["branches"]["local"])
checked, code = _post(
"/api/git/checkout",
{"session_id": sid, "ref": "feature", "mode": "local", "dirty_mode": "block"},
)
assert code == 200
assert checked["ok"] is True
assert checked["current_branch"] == "feature"
assert checked["git"]["branch"] == "feature"
def test_git_routes_selected_commit_and_structured_error(cleanup_test_sessions):
sid, base_ws = _make_session(cleanup_test_sessions)
repo = base_ws / f"git-selected-route-{uuid.uuid4().hex[:8]}"
_init_repo(repo)
(repo / "selected.txt").write_text("one\n", encoding="utf-8")
(repo / "other.txt").write_text("alpha\n", encoding="utf-8")
_commit_all(repo)
_post("/api/session/update", {"session_id": sid, "workspace": str(repo), "model": "openai/gpt-5.4-mini"})
(repo / "selected.txt").write_text("one\ntwo\n", encoding="utf-8")
(repo / "other.txt").write_text("alpha\nbeta\n", encoding="utf-8")
_git(repo, "add", "other.txt")
bad, code = _post("/api/git/commit-selected", {"session_id": sid, "message": "Bad", "paths": ["../x"]})
assert code == 400
assert bad["code"] == "path_outside_workspace"
committed, code = _post(
"/api/git/commit-selected",
{"session_id": sid, "message": "Selected route commit", "paths": ["selected.txt"]},
)
assert code == 200
assert committed["ok"] is True
assert committed["paths"] == ["selected.txt"]
assert _git(repo, "show", "--name-only", "--format=", "HEAD").splitlines() == ["selected.txt"]
def test_git_env_scrub_removes_redirecting_vars_and_preserves_temp_index(monkeypatch):
from api.workspace_git import _clean_git_env
monkeypatch.setenv("GIT_DIR", "/tmp/evil-git-dir")
monkeypatch.setenv("GIT_WORK_TREE", "/tmp/evil-work-tree")
monkeypatch.setenv("GIT_CONFIG_GLOBAL", "/tmp/evil-config")
monkeypatch.setenv("GIT_CONFIG_SYSTEM", "/tmp/evil-system-config")
monkeypatch.setenv("GIT_CONFIG_COUNT", "1")
monkeypatch.setenv("GIT_CONFIG_KEY_0", "core.sshCommand")
monkeypatch.setenv("GIT_CONFIG_VALUE_0", "ssh -i /tmp/evil-key")
monkeypatch.setenv("GIT_CONFIG_PARAMETERS", "'core.sshCommand=ssh -i /tmp/evil-key'")
env = _clean_git_env({"GIT_INDEX_FILE": "/tmp/hermes-index"})
assert "GIT_DIR" not in env
assert "GIT_WORK_TREE" not in env
assert "GIT_CONFIG_GLOBAL" not in env
assert "GIT_CONFIG_SYSTEM" not in env
assert "GIT_CONFIG_COUNT" not in env
assert "GIT_CONFIG_KEY_0" not in env
assert "GIT_CONFIG_VALUE_0" not in env
assert "GIT_CONFIG_PARAMETERS" not in env
assert env["GIT_INDEX_FILE"] == "/tmp/hermes-index"
def test_git_error_classifier_identifies_non_fast_forward_push():
from api.workspace_git import _classify_git_error
assert _classify_git_error("Updates were rejected", ["push"]) == "non_fast_forward"
assert _classify_git_error("non-fast-forward", ["push"]) == "non_fast_forward"
assert _classify_git_error("fetch first", ["push"]) == "non_fast_forward"
def test_git_commit_hook_failure_returns_hook_failed_code(tmp_path):
from api.workspace_git import GitWorkspaceError, git_commit, git_stage
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
hook = repo / ".git" / "hooks" / "pre-commit"
hook.write_text("#!/bin/sh\necho hook blocked >&2\nexit 1\n", encoding="utf-8")
hook.chmod(0o755)
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
git_stage(repo, ["tracked.txt"])
with pytest.raises(GitWorkspaceError) as exc:
git_commit(repo, "Hook should fail")
assert exc.value.code == "hook_failed"
def test_destructive_workspace_git_flag_defaults_off_and_accepts_truthy(monkeypatch):
from api.workspace_git import WORKSPACE_GIT_DESTRUCTIVE_ENV, workspace_git_destructive_enabled
monkeypatch.delenv(WORKSPACE_GIT_DESTRUCTIVE_ENV, raising=False)
assert workspace_git_destructive_enabled() is False
monkeypatch.setenv(WORKSPACE_GIT_DESTRUCTIVE_ENV, "1")
assert workspace_git_destructive_enabled() is True
monkeypatch.setenv(WORKSPACE_GIT_DESTRUCTIVE_ENV, "true")
assert workspace_git_destructive_enabled() is True
def test_git_active_stream_lock_detection(monkeypatch):
from api import routes
from api.config import STREAMS, STREAMS_LOCK
session = types.SimpleNamespace(active_stream_id="stream-git-lock-test")
with STREAMS_LOCK:
STREAMS[session.active_stream_id] = object()
try:
assert routes._git_locked_by_active_stream(session) is True
finally:
with STREAMS_LOCK:
STREAMS.pop(session.active_stream_id, None)
assert routes._git_locked_by_active_stream(session) is False
def test_git_commit_route_rejects_active_stream(monkeypatch, tmp_path):
from api import routes
from api.config import STREAMS, STREAMS_LOCK
from api.workspace_git import WORKSPACE_GIT_DESTRUCTIVE_ENV
# Enable destructive ops for this in-process test — conftest.py sets the env
# var on the test_server subprocess env block, but this test calls
# _handle_git_commit() directly in the pytest process, which inherits
# the default-OFF setting. Without this monkeypatch, the destructive-mode
# gate fires first (403) before the active-stream check (409) can run.
monkeypatch.setenv(WORKSPACE_GIT_DESTRUCTIVE_ENV, "1")
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
_git(repo, "add", "tracked.txt")
session = types.SimpleNamespace(
session_id="sid-active-git",
workspace=str(repo),
active_stream_id="stream-active-git",
)
monkeypatch.setattr(routes, "get_session", lambda sid: session)
handler = _CaptureHandler()
with STREAMS_LOCK:
STREAMS[session.active_stream_id] = object()
try:
assert routes._handle_git_commit(
handler,
{"session_id": session.session_id, "message": "Should be blocked"},
) is True
finally:
with STREAMS_LOCK:
STREAMS.pop(session.active_stream_id, None)
assert handler.status == 409
payload = handler.payload()
assert payload["code"] == "active_stream"
assert "active" in payload["error"].lower()