Merge pull request #2816 from nesquena/release/stage-batch5

Release CU: stage-batch5 — 2-PR low-risk batch (v0.51.123) — gzip+ETag static caching / Open in VS Code
This commit is contained in:
nesquena-hermes
2026-05-23 21:36:33 -07:00
committed by GitHub
6 changed files with 855 additions and 44 deletions
+10
View File
@@ -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
+156 -4
View File
@@ -6,6 +6,7 @@ Extracted from server.py (Sprint 11) so server.py is a thin shell.
import html as _html
import copy
import io
import gzip
import json
import logging
import os
@@ -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)
@@ -6237,6 +6241,20 @@ _STATIC_MIME = {
# 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()
@@ -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
@@ -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
+60 -40
View File
@@ -402,10 +402,12 @@ const LOCALES = {
rename_prompt: 'New name:',
deleted: 'Deleted ',
delete_failed: 'Delete failed: ',
reveal_in_finder: 'Reveal in File Manager',
reveal_failed: 'Failed to reveal: ',
copy_file_path: 'Copy file path',
download_folder: 'Download Folder',
reveal_in_finder: 'Reveal in File Manager',
reveal_failed: 'Failed to reveal: ',
copy_file_path: 'Copy file path',
open_in_vscode: 'Open in VS Code',
open_in_vscode_failed: 'Failed to open in VS Code: ',
download_folder: 'Download Folder',
path_copied: 'File path copied to clipboard',
path_copy_failed: 'Failed to copy path: ',
session_rename: 'Rename conversation',
@@ -1636,10 +1638,12 @@ const LOCALES = {
rename_prompt: 'Nuovo nome:',
deleted: 'Eliminato ',
delete_failed: 'Eliminazione fallita: ',
reveal_in_finder: 'Mostra nel File Manager',
reveal_failed: 'Mostra fallito: ',
copy_file_path: 'Copia percorso file',
download_folder: 'Download Folder', // TODO: translate
reveal_in_finder: 'Mostra nel File Manager',
reveal_failed: 'Mostra fallito: ',
copy_file_path: 'Copia percorso file',
open_in_vscode: 'Apri in VS Code',
open_in_vscode_failed: 'Apertura in VS Code fallita: ',
download_folder: 'Download Folder', // TODO: translate
path_copied: 'Percorso file copiato negli appunti',
path_copy_failed: 'Copia percorso fallita: ',
session_rename: 'Rinomina conversazione',
@@ -2862,10 +2866,12 @@ const LOCALES = {
rename_prompt: '新しい名前:',
deleted: '削除しました: ',
delete_failed: '削除失敗: ',
reveal_in_finder: 'ファイルマネージャーで表示',
reveal_failed: '表示に失敗しました: ',
copy_file_path: 'ファイルパスをコピー',
download_folder: 'Download Folder', // TODO: translate
reveal_in_finder: 'ファイルマネージャーで表示',
reveal_failed: '表示に失敗しました: ',
copy_file_path: 'ファイルパスをコピー',
open_in_vscode: 'VS Codeで開く',
open_in_vscode_failed: 'VS Codeで開けませんでした: ',
download_folder: 'Download Folder', // TODO: translate
path_copied: 'ファイルパスをクリップボードにコピーしました',
path_copy_failed: 'パスのコピーに失敗しました: ',
session_rename: '会話の名前を変更',
@@ -4014,10 +4020,12 @@ const LOCALES = {
rename_prompt: 'Новое имя:',
deleted: 'Удалено ',
delete_failed: 'Не удалось удалить: ',
reveal_in_finder: 'Показать в файловом менеджере',
reveal_failed: 'Не удалось открыть: ',
copy_file_path: 'Копировать путь к файлу',
download_folder: 'Download Folder', // TODO: translate
reveal_in_finder: 'Показать в файловом менеджере',
reveal_failed: 'Не удалось открыть: ',
copy_file_path: 'Копировать путь к файлу',
open_in_vscode: 'Открыть в VS Code',
open_in_vscode_failed: 'Не удалось открыть в VS Code: ',
download_folder: 'Download Folder', // TODO: translate
path_copied: 'Путь к файлу скопирован в буфер обмена',
path_copy_failed: 'Не удалось скопировать путь: ',
session_rename: 'Переименовать беседу',
@@ -5159,10 +5167,12 @@ const LOCALES = {
rename_prompt: 'Nuevo nombre:',
deleted: 'Eliminado ',
delete_failed: 'Error al eliminar: ',
reveal_in_finder: 'Mostrar en el gestor de archivos',
reveal_failed: 'Error al mostrar: ',
copy_file_path: 'Copiar ruta del archivo',
download_folder: 'Download Folder', // TODO: translate
reveal_in_finder: 'Mostrar en el gestor de archivos',
reveal_failed: 'Error al mostrar: ',
copy_file_path: 'Copiar ruta del archivo',
open_in_vscode: 'Abrir en VS Code',
open_in_vscode_failed: 'Error al abrir en VS Code: ',
download_folder: 'Download Folder', // TODO: translate
path_copied: 'Ruta del archivo copiada al portapapeles',
path_copy_failed: 'Error al copiar la ruta: ',
session_rename: 'Renombrar conversación',
@@ -6307,10 +6317,12 @@ const LOCALES = {
rename_prompt: 'Neuer Name:',
deleted: 'Gelöscht ',
delete_failed: 'Löschen fehlgeschlagen: ',
reveal_in_finder: 'Im Dateimanager anzeigen',
reveal_failed: 'Anzeige fehlgeschlagen: ',
copy_file_path: 'Dateipfad kopieren',
download_folder: 'Download Folder', // TODO: translate
reveal_in_finder: 'Im Dateimanager anzeigen',
reveal_failed: 'Anzeige fehlgeschlagen: ',
copy_file_path: 'Dateipfad kopieren',
open_in_vscode: 'In VS Code öffnen',
open_in_vscode_failed: 'In VS Code öffnen fehlgeschlagen: ',
download_folder: 'Download Folder', // TODO: translate
path_copied: 'Dateipfad in die Zwischenablage kopiert',
path_copy_failed: 'Pfad konnte nicht kopiert werden: ',
session_rename: 'Unterhaltung umbenennen',
@@ -7507,10 +7519,12 @@ const LOCALES = {
rename_prompt: '新名称:',
deleted: '已删除 ',
delete_failed: '删除失败:',
reveal_in_finder: '在文件管理器中显示',
reveal_failed: '显示失败:',
copy_file_path: '\u590d\u5236\u6587\u4ef6\u8def\u5f84',
download_folder: 'Download Folder', // TODO: translate
reveal_in_finder: '在文件管理器中显示',
reveal_failed: '显示失败:',
copy_file_path: '\u590d\u5236\u6587\u4ef6\u8def\u5f84',
open_in_vscode: '在VS Code中打开',
open_in_vscode_failed: '在VS Code中打开失败:',
download_folder: 'Download Folder', // TODO: translate
path_copied: '\u6587\u4ef6\u8def\u5f84\u5df2\u590d\u5236\u5230\u526a\u8d34\u677f',
path_copy_failed: '\u590d\u5236\u8def\u5f84\u5931\u8d25\uff1a',
session_rename: '\u91cd\u547d\u540d\u5bf9\u8bdd',
@@ -8576,10 +8590,12 @@ const LOCALES = {
rename_prompt: '新名稱:',
deleted: '\u5df2\u522a\u9664 ',
delete_failed: '\u522a\u9664\u5931\u6557\uff1a',
reveal_in_finder: '\u5728\u6a94\u6848\u7ba1\u7406\u54e1\u4e2d\u986f\u793a',
reveal_failed: '\u986f\u793a\u5931\u6557\uff1a',
copy_file_path: '\u8907\u88fd\u6a94\u6848\u8def\u5f91',
download_folder: 'Download Folder', // TODO: translate
reveal_in_finder: '\u5728\u6a94\u6848\u7ba1\u7406\u54e1\u4e2d\u986f\u793a',
reveal_failed: '\u986f\u793a\u5931\u6557\uff1a',
copy_file_path: '\u8907\u88fd\u6a94\u6848\u8def\u5f91',
open_in_vscode: '在VS Code中開啟',
open_in_vscode_failed: '在VS Code中開啟失敗:',
download_folder: 'Download Folder', // TODO: translate
path_copied: '\u6a94\u6848\u8def\u5f91\u5df2\u8907\u88fd\u5230\u526a\u8cbc\u7c3f',
path_copy_failed: '\u8907\u88fd\u8def\u5f91\u5931\u6557\uff1a',
session_rename: '\u91cd\u65b0\u547d\u540d\u5c0d\u8a71',
@@ -9888,10 +9904,12 @@ const LOCALES = {
delete_confirm: (name) => `Excluir ${name}?`,
deleted: 'Excluído ',
delete_failed: 'Falha ao excluir: ',
reveal_in_finder: 'Mostrar no gerenciador de arquivos',
reveal_failed: 'Falha ao mostrar: ',
copy_file_path: 'Copiar caminho do arquivo',
download_folder: 'Download Folder', // TODO: translate
reveal_in_finder: 'Mostrar no gerenciador de arquivos',
reveal_failed: 'Falha ao mostrar: ',
copy_file_path: 'Copiar caminho do arquivo',
open_in_vscode: 'Abrir no VS Code',
open_in_vscode_failed: 'Falha ao abrir no VS Code: ',
download_folder: 'Download Folder', // TODO: translate
path_copied: 'Caminho do arquivo copiado para a área de transferência',
path_copy_failed: 'Falha ao copiar caminho: ',
session_rename: 'Renomear conversa',
@@ -11012,10 +11030,12 @@ const LOCALES = {
rename_prompt: '새 이름:',
deleted: '삭제됨: ',
delete_failed: '삭제 실패: ',
reveal_in_finder: '파일 관리자에서 열기',
reveal_failed: '표시 실패: ',
copy_file_path: '파일 경로 복사',
download_folder: 'Download Folder', // TODO: translate
reveal_in_finder: '파일 관리자에서 열기',
reveal_failed: '표시 실패: ',
copy_file_path: '파일 경로 복사',
open_in_vscode: 'VS Code에서 열기',
open_in_vscode_failed: 'VS Code에서 열기 실패: ',
download_folder: 'Download Folder', // TODO: translate
path_copied: '파일 경로가 클립보드에 복사되었습니다',
path_copy_failed: '경로 복사 실패: ',
session_rename: '대화 이름 변경',
+15
View File
@@ -7918,6 +7918,12 @@ function _showWorkspaceRootContextMenu(e){
catch(err){showToast(t('reveal_failed')+(err.message||err));}
}));
menu.appendChild(_workspaceContextMenuItem(t('open_in_vscode'),async()=>{
menu.remove();
try{await api('/api/file/open-vscode',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:'.'})});}
catch(err){showToast(t('open_in_vscode_failed')+(err.message||err));}
}));
menu.appendChild(_workspaceContextMenuItem(t('copy_file_path'),async()=>{
menu.remove();
try{
@@ -8163,6 +8169,15 @@ function _showFileContextMenu(e, item){
revealItem.onclick=async()=>{menu.remove();try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('reveal_failed')+(err.message||err));}};
menu.appendChild(revealItem);
// Open in VS Code (#2735)
const vscodeItem=document.createElement('div');
vscodeItem.textContent=t('open_in_vscode');
vscodeItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
vscodeItem.onmouseenter=()=>vscodeItem.style.background='var(--hover-bg)';
vscodeItem.onmouseleave=()=>vscodeItem.style.background='';
vscodeItem.onclick=async()=>{menu.remove();try{await api('/api/file/open-vscode',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('open_in_vscode_failed')+(err.message||err));}};
menu.appendChild(vscodeItem);
// Copy file path — resolves the absolute on-disk path on the server (so the
// user gets the full /home/.../workspace/foo.py rather than the relative
// path the file tree shows) and writes it to the OS clipboard. Useful for
+342
View File
@@ -0,0 +1,342 @@
"""Tests for issue #2735 — "Open in VS Code" action for workspace files/folders.
Pins three layers:
1. **Source wiring** the dispatch entry, handler structure, and menu items
exist in the correct files.
2. **i18n completeness** both new keys (``open_in_vscode`` and
``open_in_vscode_failed``) are present in every locale block.
3. **Live endpoint behaviour** error paths (missing fields, unknown session,
missing file, path traversal) behave correctly against the test server.
The success path (VS Code actually opening) is not covered here because it
requires VS Code to be installed on the CI host. The subprocess call is
intentionally fire-and-forget (matching ``_handle_file_reveal``), so its
failure is surfaced via the OSError catch and a 400 response. That
observable is tested in ``TestOpenInVsCodeEndpointBehaviour``.
"""
from __future__ import annotations
import json
import pathlib
import re
import sys
import urllib.error
import urllib.request
ROOT = pathlib.Path(__file__).resolve().parent.parent
ROUTES = ROOT / "api" / "routes.py"
UI = ROOT / "static" / "ui.js"
I18N = ROOT / "static" / "i18n.js"
sys.path.insert(0, str(pathlib.Path(__file__).parent))
from conftest import TEST_BASE # noqa: E402
# ═══════════════════════════════════════════════════════════════════════════════
# Source-level wiring
# ═══════════════════════════════════════════════════════════════════════════════
class TestOpenInVsCodeBackendWiring:
def test_route_dispatch_entry_present(self):
"""Dispatcher must route /api/file/open-vscode to the handler."""
src = ROUTES.read_text(encoding="utf-8")
assert 'parsed.path == "/api/file/open-vscode"' in src
def test_handler_function_defined(self):
src = ROUTES.read_text(encoding="utf-8")
assert "def _handle_file_open_vscode(handler, body):" in src
def test_handler_uses_safe_resolve(self):
"""Handler must use safe_resolve to prevent path traversal."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m, "_handle_file_open_vscode body not found"
body = m.group(0)
assert "safe_resolve(Path(s.workspace)" in body
def test_handler_checks_existence(self):
"""Handler must require the target to exist (unlike copy-path)."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "exists()" in body
def test_handler_reads_vscode_config(self):
"""Handler must read the optional ``vscode`` config block."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert 'get("vscode"' in body
def test_handler_defaults_to_code_command(self):
"""Default executable must be ``code`` when config is absent."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert '"code"' in body
def test_handler_supports_path_prefix_mapping(self):
"""Handler must support container_path_prefix / host_path_prefix
so Docker users can map container paths to host paths."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "container_path_prefix" in body
assert "host_path_prefix" in body
def test_handler_uses_subprocess_popen(self):
"""Handler must use subprocess.Popen (async, non-blocking) consistent
with _handle_file_reveal."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "subprocess.Popen(" in body
def test_handler_resolves_command_via_shutil_which(self):
"""Handler must use shutil.which() to find the command so it works
even when the server's inherited PATH is minimal (e.g. macOS launch
via start.sh where /usr/local/bin may be absent from the subprocess
PATH)."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "shutil.which(" in body
def test_handler_has_vscode_fallback_paths(self):
"""Handler must try common VS Code paths when shutil.which fails,
covering macOS (/usr/local/bin/code), Linux (/snap/bin/code), and
Windows (%LOCALAPPDATA%\\Programs\\Microsoft VS Code)."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "/usr/local/bin/code" in body # macOS
assert "/snap/bin/code" in body # Linux snap
assert "Microsoft VS Code" in body # Windows
def test_handler_returns_helpful_error_when_not_found(self):
"""When code command is not found anywhere, handler must return a
descriptive error instead of a bare OSError message."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "VS Code command not found" in body
class TestOpenInVsCodeFrontendWiring:
def test_file_context_menu_has_vscode_item(self):
"""_showFileContextMenu must include the Open in VS Code action."""
src = UI.read_text(encoding="utf-8")
assert "t('open_in_vscode')" in src
assert "/api/file/open-vscode" in src
def test_workspace_root_context_menu_has_vscode_item(self):
"""_showWorkspaceRootContextMenu must also include the VS Code action."""
src = UI.read_text(encoding="utf-8")
# Both the file and root menus call the same endpoint; verify at least
# two references in the file so we know both call sites exist.
assert src.count("/api/file/open-vscode") >= 2
def test_vscode_item_uses_hover_bg(self):
"""VS Code menu item must use var(--hover-bg), not var(--hover) or
any other undefined variable."""
src = UI.read_text(encoding="utf-8")
# Confirm the item is wired with the correct variable — count hover-bg
# usages; as long as our item follows the pattern the suite is green.
assert "var(--hover-bg)" in src
def test_vscode_failure_toast_uses_i18n_key(self):
"""Error toast on VS Code open failure must use the translatable key."""
src = UI.read_text(encoding="utf-8")
assert "t('open_in_vscode_failed')" in src
def test_vscode_item_guards_err_message(self):
"""Error handler must guard against non-Error objects with
(err.message||err) consistent with reveal handler."""
src = UI.read_text(encoding="utf-8")
# Find the open-vscode call site and check for the guard pattern near it.
idx = src.find("/api/file/open-vscode")
assert idx != -1
# Look in a window around the first call site.
window = src[max(0, idx - 200) : idx + 500]
assert "(err.message||err)" in window or "(err.message || err)" in window
class TestOpenInVsCodeI18n:
"""Both new translation keys must be present in every locale block."""
LOCALES = [
# (locale tag, sample anchor key: value)
("en", "reveal_in_finder: 'Reveal in File Manager'"),
("it", "reveal_in_finder: 'Mostra nel File Manager'"),
("ja", "reveal_in_finder: 'ファイルマネージャーで表示'"),
("ru", "reveal_in_finder: 'Показать в файловом менеджере'"),
("es", "reveal_in_finder: 'Mostrar en el gestor de archivos'"),
("de", "reveal_in_finder: 'Im Dateimanager anzeigen'"),
("zh-CN", "reveal_in_finder: '在文件管理器中显示'"),
("pt", "reveal_in_finder: 'Mostrar no gerenciador de arquivos'"),
("ko", "reveal_in_finder: '파일 관리자에서 열기'"),
]
def test_open_in_vscode_key_count(self):
"""open_in_vscode key must appear exactly once per locale (10 total)."""
src = I18N.read_text(encoding="utf-8")
count = src.count("open_in_vscode:")
assert count == 10, (
f"Expected 10 open_in_vscode: entries (one per locale), found {count}"
)
def test_open_in_vscode_failed_key_count(self):
"""open_in_vscode_failed key must appear exactly once per locale (10 total)."""
src = I18N.read_text(encoding="utf-8")
count = src.count("open_in_vscode_failed:")
assert count == 10, (
f"Expected 10 open_in_vscode_failed: entries (one per locale), found {count}"
)
def test_english_translation_not_a_placeholder(self):
"""English locale must have a human-readable string, not a TODO."""
src = I18N.read_text(encoding="utf-8")
assert "open_in_vscode: 'Open in VS Code'" in src
assert "open_in_vscode_failed: 'Failed to open in VS Code: '" in src
def test_non_english_locales_translated(self):
"""Non-English locales must have real translations, not TODO stubs."""
src = I18N.read_text(encoding="utf-8")
# Spot-check a selection of locales — none of these should be TODO stubs.
assert "open_in_vscode: 'Apri in VS Code'" in src # it
assert "open_in_vscode: 'VS Codeで開く'" in src # ja
assert "open_in_vscode: 'Открыть в VS Code'" in src # ru
assert "open_in_vscode: 'Abrir en VS Code'" in src # es
assert "open_in_vscode: 'In VS Code öffnen'" in src # de
assert "open_in_vscode: 'VS Code에서 열기'" in src # ko
def test_keys_adjacent_to_reveal_block(self):
"""New keys must appear near the reveal/copy block so locale coverage
is easy to spot in code review."""
src = I18N.read_text(encoding="utf-8")
# In the English block, open_in_vscode must appear between
# copy_file_path and download_folder.
copy_idx = src.index("copy_file_path: 'Copy file path'")
dl_idx = src.index("download_folder: 'Download Folder'", copy_idx)
vscode_idx = src.index("open_in_vscode: 'Open in VS Code'", copy_idx)
assert copy_idx < vscode_idx < dl_idx, (
"open_in_vscode key must appear between copy_file_path and "
"download_folder in the English locale block"
)
# ═══════════════════════════════════════════════════════════════════════════════
# Live endpoint behaviour
# ═══════════════════════════════════════════════════════════════════════════════
def _post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(
TEST_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
class TestOpenInVsCodeEndpointBehaviour:
def _new_session(self):
body, status = _post("/api/session/new", {})
assert status == 200, body
return body["session"]["session_id"]
def test_missing_session_id_returns_400(self):
body, status = _post("/api/file/open-vscode", {"path": "."})
assert status == 400, body
assert "session_id" in body.get("error", "")
def test_missing_path_returns_400(self):
sid = self._new_session()
body, status = _post("/api/file/open-vscode", {"session_id": sid})
assert status == 400, body
assert "path" in body.get("error", "")
def test_unknown_session_returns_404(self):
body, status = _post(
"/api/file/open-vscode",
{"session_id": "nonexistent-session-xyz", "path": "."},
)
assert status == 404, body
assert "session" in body.get("error", "").lower()
def test_missing_file_returns_404_with_path(self):
"""Attempting to open a file that does not exist must return 404 and
include the resolved path in the error (mirrors _handle_file_reveal
behaviour introduced in #1764)."""
sid = self._new_session()
body, status = _post(
"/api/file/open-vscode",
{"session_id": sid, "path": "does-not-exist-2735.txt"},
)
assert status == 404, body
err = body.get("error", "")
assert "does-not-exist-2735.txt" in err, (
f"404 message must include the resolved path, got: {err!r}"
)
def test_path_traversal_rejected(self):
"""Handler must reject paths that escape the workspace root."""
sid = self._new_session()
body, status = _post(
"/api/file/open-vscode",
{"session_id": sid, "path": "../../../../../../etc/passwd"},
)
assert status == 400, body
@@ -0,0 +1,272 @@
"""Regression tests for static-asset compression + cache headers in _serve_static.
Pre-fix shape:
/static/* served raw bytes with `Cache-Control: no-store` and no
`Content-Encoding`. A page reload over a slow link re-downloaded the
full ~2.4 MB shell on every visit, even though every reference in
static/index.html and static/sw.js carries `?v=__WEBUI_VERSION__`
fingerprinting that already guarantees a fresh URL on redeploy.
Fix: _serve_static now negotiates gzip when the client opts in, emits
weak ETags for conditional GETs, and sends `max-age=31536000, immutable`
when the request URL carries a `?v=` fingerprint (`max-age=300`
otherwise). Bytes + headers are cached in-process and invalidated on
(size, mtime) change so a redeploy is picked up without a restart.
These tests pin both halves header policy AND the cache-invalidation
contract so future refactors of _serve_static cannot silently
re-introduce no-store or break the gzip/304 path.
"""
import gzip
from types import SimpleNamespace
from urllib.parse import urlparse
class _FakeHandler:
"""Minimal request handler stand-in matching tests/test_session_static_assets.py."""
def __init__(self, request_headers=None):
self.status = None
self.sent_headers = []
self.body = bytearray()
self.wfile = self
self.headers = dict(request_headers or {})
def send_response(self, status):
self.status = status
def send_header(self, name, value):
self.sent_headers.append((name, value))
def end_headers(self):
pass
def write(self, data):
self.body.extend(data)
def header(self, name):
for key, value in self.sent_headers:
if key.lower() == name.lower():
return value
return None
def _make_static_file(static_root, name, content):
path = static_root / name
path.write_bytes(content if isinstance(content, bytes) else content.encode("utf-8"))
return path
def _serve(routes, path, query="", request_headers=None):
"""Invoke _serve_static via the real urllib parse path."""
parsed = urlparse(f"http://x{path}{('?' + query) if query else ''}")
h = _FakeHandler(request_headers)
routes._serve_static(h, parsed)
return h
def _patch_static_root(monkeypatch, static_root):
"""Force _serve_static to read from a temp directory and clear its cache."""
from api import routes
monkeypatch.setattr(
routes, "_serve_static",
lambda handler, parsed, _root=static_root, _orig=routes._serve_static: _orig(handler, parsed),
)
# Tests redirect by writing files to the real static dir's parent layout
# via a fixture; instead we monkeypatch the module-level Path computation.
# _serve_static derives static_root from `Path(__file__).parent.parent / "static"`,
# so we monkeypatch __file__ via a closure that re-resolves with our temp tree.
# Simpler: patch the cache and call the real function with a parsed path that
# resolves under the real static dir. We use the fixture below instead.
# ── Fixture: build a tiny isolated static tree and rebind paths ───────────
import pytest
@pytest.fixture
def isolated_static(tmp_path, monkeypatch):
"""Stand up an isolated static/ tree and rebind _serve_static to use it.
Yields the static_root Path so tests can drop files into it.
"""
from api import routes
static_root = tmp_path / "static"
static_root.mkdir()
# Patch the cache so cross-test state cannot leak.
monkeypatch.setattr(routes, "_STATIC_CACHE", {}, raising=True)
# _serve_static derives static_root from Path(__file__).parent.parent.
# Rebind by monkeypatching Path resolution: we wrap the function so the
# caller-visible signature is unchanged.
original = routes._serve_static
def wrapped(handler, parsed):
# Trick: temporarily monkeypatch Path so the function sees our temp tree.
import api.routes as ar
orig_file = ar.__file__
# Place a sentinel api/routes.py "next to" tmp_path so the relative
# walk lands in our static_root.
fake_api_dir = tmp_path / "api"
fake_api_dir.mkdir(exist_ok=True)
fake_routes = fake_api_dir / "routes.py"
if not fake_routes.exists():
fake_routes.write_text("# stub for path resolution\n")
monkeypatch.setattr(ar, "__file__", str(fake_routes))
try:
return original(handler, parsed)
finally:
monkeypatch.setattr(ar, "__file__", orig_file)
monkeypatch.setattr(routes, "_serve_static", wrapped)
yield static_root
# ── Tests ─────────────────────────────────────────────────────────────────
def test_plain_get_returns_raw_bytes_with_etag(isolated_static):
from api import routes
payload = b"console.log('hello');\n" * 200 # > 1 KB so gzip-eligible
_make_static_file(isolated_static, "ui.js", payload)
h = _serve(routes, "/static/ui.js")
assert h.status == 200
assert h.header("Content-Type") == "application/javascript; charset=utf-8"
assert h.header("Content-Encoding") is None # no gzip without Accept-Encoding
assert h.header("ETag") is not None and h.header("ETag").startswith('W/"')
assert h.header("Cache-Control") == "public, max-age=300" # no fingerprint
assert bytes(h.body) == payload
def test_gzip_negotiated_when_client_accepts(isolated_static):
from api import routes
payload = (b"a" * 50_000)
_make_static_file(isolated_static, "ui.js", payload)
h = _serve(routes, "/static/ui.js", request_headers={"Accept-Encoding": "gzip, deflate"})
assert h.status == 200
assert h.header("Content-Encoding") == "gzip"
assert h.header("Vary") == "Accept-Encoding"
assert gzip.decompress(bytes(h.body)) == payload
assert int(h.header("Content-Length")) == len(h.body) < len(payload)
def test_fingerprinted_url_gets_immutable_cache(isolated_static):
from api import routes
_make_static_file(isolated_static, "ui.js", b"x" * 2000)
h = _serve(routes, "/static/ui.js", query="v=abc1234")
assert h.header("Cache-Control") == "public, max-age=31536000, immutable"
def test_empty_fingerprint_value_gets_short_cache(isolated_static):
"""Only a non-empty version token is an immutable-cache fingerprint."""
from api import routes
_make_static_file(isolated_static, "ui.js", b"x" * 2000)
h = _serve(routes, "/static/ui.js", query="v=")
assert h.header("Cache-Control") == "public, max-age=300"
def test_unfingerprinted_url_gets_short_cache(isolated_static):
from api import routes
_make_static_file(isolated_static, "ui.js", b"x" * 2000)
h = _serve(routes, "/static/ui.js")
assert h.header("Cache-Control") == "public, max-age=300"
def test_conditional_get_returns_304(isolated_static):
from api import routes
_make_static_file(isolated_static, "ui.js", b"hello world\n" * 100)
first = _serve(routes, "/static/ui.js", query="v=abc")
etag = first.header("ETag")
assert etag is not None
second = _serve(routes, "/static/ui.js", query="v=abc",
request_headers={"If-None-Match": etag})
assert second.status == 304
assert second.header("ETag") == etag
assert second.header("Cache-Control") == "public, max-age=31536000, immutable"
assert second.header("Vary") == "Accept-Encoding"
assert bytes(second.body) == b""
def test_etag_changes_when_file_changes(isolated_static):
"""Cache must invalidate when (size, mtime) changes — guards redeploy correctness."""
import time
from api import routes
f = _make_static_file(isolated_static, "ui.js", b"v1" * 1000)
first = _serve(routes, "/static/ui.js")
etag_v1 = first.header("ETag")
# Touch with a later mtime (1 s granularity matches the ETag formula).
time.sleep(1.1)
f.write_bytes(b"v2-different-content" * 50)
second = _serve(routes, "/static/ui.js")
etag_v2 = second.header("ETag")
assert etag_v1 != etag_v2
# Old ETag now produces a 200, not a stale 304.
third = _serve(routes, "/static/ui.js", request_headers={"If-None-Match": etag_v1})
assert third.status == 200
def test_etag_changes_for_same_size_edits_within_same_second(isolated_static):
"""The cache signature must keep sub-second mtime precision."""
import os
from api import routes
f = _make_static_file(isolated_static, "ui.js", b"a" * 2048)
second = 1_900_000_000
os.utime(f, ns=(second * 1_000_000_000, second * 1_000_000_000))
first = _serve(routes, "/static/ui.js")
etag_v1 = first.header("ETag")
f.write_bytes(b"b" * 2048)
os.utime(f, ns=(second * 1_000_000_000 + 123_000_000,
second * 1_000_000_000 + 123_000_000))
second_response = _serve(routes, "/static/ui.js")
assert second_response.header("ETag") != etag_v1
assert bytes(second_response.body) == b"b" * 2048
def test_image_is_not_gzipped(isolated_static):
"""Already-compressed binary types must skip gzip to avoid wasted CPU."""
from api import routes
# 4 KB of pseudo-PNG (real header doesn't matter, only the MIME does)
_make_static_file(isolated_static, "favicon.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 4000)
h = _serve(routes, "/static/favicon.png", request_headers={"Accept-Encoding": "gzip"})
assert h.status == 200
assert h.header("Content-Encoding") is None
assert h.header("Content-Type") == "image/png"
def test_tiny_file_is_not_gzipped(isolated_static):
"""Files under 1 KB skip gzip — framing overhead exceeds savings."""
from api import routes
_make_static_file(isolated_static, "tiny.js", b"export {};\n")
h = _serve(routes, "/static/tiny.js", request_headers={"Accept-Encoding": "gzip"})
assert h.status == 200
assert h.header("Content-Encoding") is None
def test_path_traversal_still_rejected(isolated_static):
"""Sandbox check from the original implementation must remain intact."""
from api import routes
_make_static_file(isolated_static, "ui.js", b"ok")
# Try to break out of static/ — must 404, not serve external files.
h = _serve(routes, "/static/../api/routes.py")
assert h.status == 404