diff --git a/CHANGELOG.md b/CHANGELOG.md index cde2a35e..3455e962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ ### Fixed +- **Workspace image previews and file downloads now work when the WebUI is served under a subpath.** Behind a reverse proxy that mounts the app at a subpath (e.g. `/hermes/`), workspace image previews and downloads 404'd: those consumers built root-relative `/api/...` URLs that resolved to the server root instead of the mount, while text/code previews (which route through `api()`) kept working — a confusing partial failure. The workspace route helper now resolves against `document.baseURI`, so every consumer (image preview, media/pdf/html frames, download link, open-in-browser) gets a correctly-mounted URL. The normal root-mount deployment is unaffected (identical URLs). Thanks @tomtong2015. (#5830) + - **Imported external-agent sessions (Claude Code, Codex) no longer show a non-zero "WebUI sessions" count with an empty list.** The server session-count classifier treated a read-only external-agent import (which carries a real title) as non-CLI — counting it under `webui_session_count` — while the client renderer filed the same row in the CLI bucket. The result was a confusing "WebUI sessions (N)" chip over an empty list, with those sessions only reachable under the CLI tab. The server now classifies `external_agent`/`external-agent` sources as CLI, matching the client, so the tab counts agree with where rows render. WebUI, messaging, and delegated-subagent sessions are unaffected. Thanks @nesquena-hermes. (#5846, #5831) - **Long tool outputs can no longer blow up the model context after a compression event.** After the conversation is compressed, the protected-tail tool-result payloads sent to the model are now hard-capped to a token budget (applied once, only after a confirmed compression). The cap operates on the model context only (a deep copy of `context_messages`) — it never removes or reorders messages (so no tool_call/tool_result pairing is broken) and never touches the visible/persisted transcript, which keeps the full tool output. Thanks @franksong2702. (#5667, #4685) diff --git a/static/workspace.js b/static/workspace.js index fab0c3d0..88fd6d59 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -242,6 +242,23 @@ function _workspacePathIsReadOnly(path){ } function _workspaceRouteForPath(path, kind, opts={}){ + // Resolve the app-relative "/api/…" route against document.baseURI so the + // URLs that are consumed OUTSIDE api() — previewImg.src, the media/pdf/html + // frame src, the download anchor, window.open — keep working under a subpath + // mount like /hermes/. A bare "/api/…" string resolves to the server root + // there and 404s. (api() strips the leading slash and re-resolves against + // baseURI itself, so routes passed through it are unaffected by already + // being absolute.) + const route=_workspaceRouteForPathRel(path, kind, opts); + if(!route) return route; + // Non-browser test harnesses have no document/location: keep the app-relative form. + const base=(typeof document!=='undefined'&&document.baseURI)||(typeof location!=='undefined'&&location.href)||''; + if(!base||!/^https?:\/\//i.test(base)) return route; + const rel=route.startsWith('/') ? route.slice(1) : route; + return new URL(rel, base).href; +} + +function _workspaceRouteForPathRel(path, kind, opts={}){ if(!S.session) return ''; const normalizedPath = _normalizeWorkspaceRelPath(path); const grant = _workspaceEscapeGrantForPath(normalizedPath); diff --git a/tests/test_workspace_subpath_raw_urls.py b/tests/test_workspace_subpath_raw_urls.py new file mode 100644 index 00000000..cec4c7ed --- /dev/null +++ b/tests/test_workspace_subpath_raw_urls.py @@ -0,0 +1,129 @@ +""" +Tests for the workspace panel's raw file URLs under a subpath mount. + +`_workspaceRouteForPath` builds app-relative "/api/…" strings. Most callers +pass them through `api()`, which strips the leading slash and re-resolves the +URL against `document.baseURI`, so they work under a subpath mount like +`/hermes/`. But several callers use the route DIRECTLY, bypassing `api()`: + + * `previewImg.src` (image preview) + * media / pdf / html frame `.src` + * the download `` anchor + * `window.open(...)` (open-in-browser) + +For those, a bare "/api/file/raw?…" resolves to the SERVER ROOT under a subpath +mount and 404s — image previews break and every download fails, while text +previews (which go through api()) keep working. The fix resolves the route +against `document.baseURI` inside `_workspaceRouteForPath` itself. + +The first two tests are static source regressions; the third runs the builder +in Node with a subpath `document.baseURI` and asserts the resolved URL keeps +the mount prefix. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).parent.parent +WORKSPACE_JS = ROOT / "static" / "workspace.js" +NODE = shutil.which("node") + + +def _workspace_js() -> str: + return WORKSPACE_JS.read_text(encoding="utf-8") + + +def _route_helper_block() -> str: + """Slice the workspace-route builders out of workspace.js for a Node harness.""" + src = _workspace_js() + start = src.find("function _escapeGrantStore(){") + assert start >= 0, "route helper block start not found in static/workspace.js" + end = src.find("async function authorizeWorkspaceEscapeNavigation(", start) + assert end >= 0, "route helper block end not found in static/workspace.js" + return src[start:end] + + +class TestWorkspaceSubpathRawUrls: + def test_route_builder_resolves_against_base_uri(self): + """_workspaceRouteForPath must resolve routes against document.baseURI.""" + src = _workspace_js() + assert "document.baseURI" in src, ( + "workspace.js must resolve raw workspace routes against document.baseURI " + "so previewImg.src / downloads / window.open work under a subpath mount" + ) + + def test_route_builder_keeps_app_relative_strings_internally(self): + """The literal '/api/...' route strings must remain in _workspaceRouteForPathRel.""" + src = _workspace_js() + assert "/api/file/raw?session_id=" in src, ( + "the app-relative /api/file/raw route string must remain as the internal " + "building block inside _workspaceRouteForPathRel (used when document is absent)" + ) + + @pytest.mark.skipif(NODE is None, reason="node not on PATH") + def test_raw_url_keeps_subpath_prefix(self): + helper_block = _route_helper_block() + js = ( + "const helperBlock = " + + json.dumps(helper_block) + + ";\n" + + r""" +global.document = { baseURI: 'https://host.example/pod-123/hermes/' }; +const S = { session: { session_id: 'sess-1' }, currentDir: '.', _escapeGrants: Object.create(null) }; +const runner = new Function( + 'S', 'URLSearchParams', 'document', + helperBlock + '; return { _workspaceRouteForPath };' +); +const fns = runner(S, URLSearchParams, global.document); +console.log(JSON.stringify({ + raw: fns._workspaceRouteForPath('sub/dir/plot.png', 'raw', { download: true }), + img: fns._workspaceRouteForPath('sub/dir/plot.png', 'raw'), + read: fns._workspaceRouteForPath('sub/dir/plot.png', 'read'), +})); +""" + ) + out = subprocess.run( + [NODE, "-e", js], cwd=ROOT, capture_output=True, text=True, check=True + ) + result = json.loads(out.stdout) + prefix = "https://host.example/pod-123/hermes/api/" + assert result["raw"].startswith(prefix), result["raw"] + assert "download=1" in result["raw"], result["raw"] + assert result["img"].startswith(prefix), result["img"] + # Routes normally consumed via api() are still absolute now, which api() + # tolerates (it strips the leading slash of relative inputs only). + assert result["read"].startswith(prefix), result["read"] + # The mount prefix must not be duplicated or dropped. + assert result["raw"].count("/hermes/") == 1, result["raw"] + + @pytest.mark.skipif(NODE is None, reason="node not on PATH") + def test_non_http_base_falls_back_to_app_relative(self): + """about:blank (jsdom's default baseURI) must not crash the builder.""" + helper_block = _route_helper_block() + js = ( + "const helperBlock = " + + json.dumps(helper_block) + + ";\n" + + r""" +global.document = { baseURI: 'about:blank' }; +const S = { session: { session_id: 'sess-1' }, currentDir: '.', _escapeGrants: Object.create(null) }; +const runner = new Function( + 'S', 'URLSearchParams', 'document', + helperBlock + '; return { _workspaceRouteForPath };' +); +const fns = runner(S, URLSearchParams, global.document); +console.log(JSON.stringify({ + raw: fns._workspaceRouteForPath('sub/dir/plot.png', 'raw', { download: true }), +})); +""" + ) + out = subprocess.run( + [NODE, "-e", js], cwd=ROOT, capture_output=True, text=True, check=True + ) + result = json.loads(out.stdout) + # Non-http(s) base: keep the app-relative form rather than throwing. + assert result["raw"].startswith("/api/file/raw?session_id="), result["raw"]