Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@

## [Unreleased]

## [v0.51.123] — 2026-05-24 — Release CU (stage-batch5 — 2-PR low-risk batch — gzip+ETag static caching / Open in VS Code)

### Performance

- **PR #2779** by @v2psv — Static asset serving negotiates gzip, emits ETags, and uses `immutable` cache headers for fingerprinted URLs. `_serve_static()` in `api/routes.py` previously sent every `/static/*` response with `Cache-Control: no-store` and no `Content-Encoding`, so a page reload over a slow link re-downloaded the full ~2.4 MB JS+CSS shell on every visit. The fix layers three changes inside the same function: (1) gzip the body when the client opts in via `Accept-Encoding`, gated to compressible MIME types and files >1 KB; (2) emit a weak ETag derived from `(size, mtime_ns)` and short-circuit conditional GETs to `304 Not Modified`; (3) send `Cache-Control: public, max-age=31536000, immutable` when the URL carries a non-empty `?v=…` fingerprint (the `__WEBUI_VERSION__` token already substituted by the index template and referenced from `static/sw.js`'s `SHELL_ASSETS`), falling back to `public, max-age=300` otherwise. Raw bytes, compressed bytes, and ETags are cached in-process keyed by `(size, mtime_ns)` so a redeploy is picked up without a restart, while missing/random paths never enter the cache and image/font types skip gzip to avoid wasted CPU on already-compressed payloads. Measured against an asyncio TCP proxy that injects RTT + bandwidth caps for representative VPN scenarios: cold loads improve 2.7-3.1× (e.g. 80 ms RTT / 10 Mbps WireGuard goes from 4.0 s to 1.3 s), warm reloads improve 3.3-4.0× via 304 responses, and bytes-on-the-wire drop 74% on cold loads. Loopback (already fast) still benefits 2.4×. Scope is strictly `/static/*`: `/api/*`, `/stream`, `/`, `/index.html`, `/session/*`, and login/auth routes are served by independent handlers and continue to send `no-store` exactly as before — no change to CSRF, session payloads, SSE buffering, or login flows. 11 regression tests pin gzip negotiation, ETag/304 round-trip including `Vary: Accept-Encoding`, fingerprint-driven cache policy including empty `?v=`, image/tiny-file skip rules, redeploy invalidation, and the existing path-traversal sandbox.

### Added

- **PR #2787** by @munim — "Open in VS Code" action in workspace file browser (resolves #2735). Right-clicking any file, folder, or the workspace root now shows an **Open in VS Code** menu item alongside the existing Reveal in File Manager action. The action calls a new `POST /api/file/open-vscode` endpoint which resolves the workspace-relative path via the existing `safe_resolve` traversal guard, then launches VS Code via `subprocess.Popen` (fire-and-forget, consistent with `_handle_file_reveal`). The endpoint resolves the executable via `shutil.which()` first, then falls back to a hardcoded list of common install locations (macOS: `/usr/local/bin/code` and the app-bundle CLI; Linux: `/usr/bin/code`, `/snap/bin/code`; Windows: `%LOCALAPPDATA%\Programs\Microsoft VS Code\bin\code.cmd` and the `%PROGRAMFILES%` variants) so the action works even when the server process inherits a minimal PATH. Configurable via a new optional `vscode` block in `config.yaml`: `command` overrides the default `code` executable; `host_path_prefix` + `container_path_prefix` enable Docker/container host-path translation. If the command cannot be found anywhere, a descriptive error is returned instead of a bare OS error. i18n keys `open_in_vscode` and `open_in_vscode_failed` added with full translations in all 10 locales. 26 new tests in `tests/test_2735_open_in_vscode.py` pin source wiring, command-resolution logic, i18n completeness, translated strings, and live endpoint error paths.

## [v0.51.122] — 2026-05-24 — Release CT (stage-batch4 — 4-PR low-risk batch — stale cache tail / inflight UI / segment flush / reasoning accumulator)

### Fixed
Expand Down
160 changes: 156 additions & 4 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import html as _html
import copy
import io
import gzip
import json
import logging
import os
Expand Down Expand Up @@ -5455,6 +5456,9 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/file/path":
return _handle_file_path(handler, body)

if parsed.path == "/api/file/open-vscode":
return _handle_file_open_vscode(handler, body)

# ── Workspace management (POST) ──
if parsed.path == "/api/workspaces/add":
return _handle_workspace_add(handler, body)
Expand Down Expand Up @@ -6237,6 +6241,20 @@ def handle_delete(handler, parsed) -> bool:
# MIME types that are text-based and should carry charset=utf-8
_TEXT_MIME_TYPES = {"text/css", "application/javascript", "text/html", "image/svg+xml", "text/plain"}

# MIME types worth gzipping. Image and font formats (png/jpg/webp/woff2) are
# already compressed; gzip would only add CPU and a few bytes of framing.
_COMPRESSIBLE_MIME = {
"text/css", "application/javascript", "text/html", "image/svg+xml",
"application/json", "text/plain",
}

# In-process cache for raw bytes, compressed bytes, and ETag. The cache is keyed
# by absolute path and invalidated on (size, high-precision mtime) change, so a
# redeploy is picked up without a process restart. Missing/random paths never
# enter the cache; memory cost is bounded by the static/ tree's served files.
_STATIC_CACHE: dict = {}
_STATIC_CACHE_LOCK = threading.Lock()


def _serve_static(handler, parsed):
static_root = (Path(__file__).parent.parent / "static").resolve()
Expand All @@ -6252,13 +6270,63 @@ def _serve_static(handler, parsed):
ext = static_file.suffix.lower()
ct = _STATIC_MIME.get(ext.lstrip("."), "text/plain")
ct_header = f"{ct}; charset=utf-8" if ct in _TEXT_MIME_TYPES else ct

# Look up or populate the per-file cache (raw, optional gzip, ETag).
# Keyed by absolute path; invalidated by (size, nanosecond mtime).
st = static_file.stat()
sig = (st.st_size, st.st_mtime_ns)
cache_key = str(static_file)
raw = gz = etag = None
with _STATIC_CACHE_LOCK:
cached = _STATIC_CACHE.get(cache_key)
if cached and cached[0] == sig:
_, raw, gz, etag = cached
if raw is None:
raw = static_file.read_bytes()
# Weak ETag: equality semantics, derived from filesystem identity.
etag = f'W/"{sig[0]:x}-{sig[1]:x}"'
gz = (gzip.compress(raw, compresslevel=6)
if ct in _COMPRESSIBLE_MIME and len(raw) > 1024
else None)
with _STATIC_CACHE_LOCK:
_STATIC_CACHE[cache_key] = (sig, raw, gz, etag)

# The page template substitutes __WEBUI_VERSION__ at request time (see the
# `/`/`/index.html`/`/session/` branch above), and static/sw.js's
# SHELL_ASSETS list relies on the same convention. So a fingerprinted URL
# is safe to cache aggressively: any redeploy changes the URL.
version_values = parse_qs(parsed.query, keep_blank_values=True).get("v", [""])
has_fingerprint = bool(version_values[0])
cache_control = (
"public, max-age=31536000, immutable" if has_fingerprint
else "public, max-age=300"
)

# 304 short-circuit on conditional GET.
if handler.headers.get("If-None-Match") == etag:
handler.send_response(304)
handler.send_header("ETag", etag)
handler.send_header("Cache-Control", cache_control)
if gz is not None:
handler.send_header("Vary", "Accept-Encoding")
handler.end_headers()
return True

accept_enc = (handler.headers.get("Accept-Encoding") or "").lower()
use_gzip = gz is not None and "gzip" in accept_enc
body = gz if use_gzip else raw

handler.send_response(200)
handler.send_header("Content-Type", ct_header)
handler.send_header("Cache-Control", "no-store")
raw = static_file.read_bytes()
handler.send_header("Content-Length", str(len(raw)))
handler.send_header("Content-Length", str(len(body)))
handler.send_header("ETag", etag)
handler.send_header("Cache-Control", cache_control)
if gz is not None:
handler.send_header("Vary", "Accept-Encoding")
if use_gzip:
handler.send_header("Content-Encoding", "gzip")
handler.end_headers()
handler.wfile.write(raw)
handler.wfile.write(body)
return True


Expand Down Expand Up @@ -9526,6 +9594,90 @@ def _handle_file_path(handler, body):
return bad(handler, _sanitize_error(e))


def _handle_file_open_vscode(handler, body):
"""Open a workspace file or folder in VS Code (#2735).

Reads optional ``vscode`` config block from config.yaml:

vscode:
command: code # executable on PATH; defaults to "code"
host_path_prefix: /home/user/projects # Docker host path
container_path_prefix: /app/workspace # matching container path

If ``host_path_prefix`` and ``container_path_prefix`` are both set,
paths that begin with ``container_path_prefix`` are translated to the
host prefix before being handed to VS Code. This lets users running
Hermes WebUI inside Docker still open files in their local editor.
"""
try:
require(body, "session_id", "path")
except ValueError as e:
return bad(handler, str(e))
try:
s = get_session(body["session_id"])
except KeyError:
return bad(handler, "Session not found", 404)
try:
target = safe_resolve(Path(s.workspace), body["path"])
if not target.exists():
return bad(handler, f"File not found: {target}", 404)

target_str = str(target)

# Optional Docker host/container path translation
from api.config import get_config as _get_cfg # noqa: PLC0415
vscode_cfg = _get_cfg().get("vscode", {})
if not isinstance(vscode_cfg, dict):
vscode_cfg = {}
container_prefix = vscode_cfg.get("container_path_prefix", "")
host_prefix = vscode_cfg.get("host_path_prefix", "")
if container_prefix and host_prefix and target_str.startswith(container_prefix):
target_str = host_prefix + target_str[len(container_prefix):]

cmd = vscode_cfg.get("command", "code")
# Resolve the command to an absolute path so subprocess.Popen finds it
# even when the server process inherits a minimal PATH (e.g. when
# launched via start.sh on macOS where /usr/local/bin may be absent).
resolved_cmd = shutil.which(cmd)
if resolved_cmd is None:
# Try common VS Code installation paths as fallback.
# macOS: /usr/local/bin/code (symlink) or app bundle CLI
# Linux: /usr/bin/code or snap
# Windows: user-install under %LOCALAPPDATA%, system-install under %PROGRAMFILES%
_local_app_data = os.environ.get("LOCALAPPDATA", "")
_prog_files = os.environ.get("PROGRAMFILES", "C:\\Program Files")
_prog_files_x86 = os.environ.get("PROGRAMFILES(X86)", "C:\\Program Files (x86)")
_vscode_fallbacks = [
# macOS
"/usr/local/bin/code",
"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
# Linux
"/usr/bin/code",
"/snap/bin/code",
# Windows (user install)
os.path.join(_local_app_data, "Programs", "Microsoft VS Code", "bin", "code.cmd"),
# Windows (system install)
os.path.join(_prog_files, "Microsoft VS Code", "bin", "code.cmd"),
os.path.join(_prog_files_x86, "Microsoft VS Code", "bin", "code.cmd"),
]
for fb in _vscode_fallbacks:
if fb and Path(fb).exists():
resolved_cmd = fb
break
if resolved_cmd is None:
return bad(
handler,
f"VS Code command not found: {cmd!r}. "
"Install VS Code and ensure the 'code' CLI is on PATH, "
"or set vscode.command in config.yaml to the full path.",
)
subprocess.Popen([resolved_cmd, target_str])

return j(handler, {"ok": True, "path": body["path"]})
except (ValueError, PermissionError, OSError) as e:
return bad(handler, _sanitize_error(e))


def _handle_workspace_add(handler, body):
# Strip surrounding paired quotes BEFORE any further processing — macOS
# Finder's "Copy as Pathname" wraps paths in single quotes, and users
Expand Down
Loading
Loading