_workspaceRouteForPath now resolves app-relative /api/... routes against document.baseURI so the direct consumers (previewImg.src, media/pdf/html frame src, download href, window.open) work under a subpath mount instead of 404ing at the server root. Root-mount deployment unaffected (identical final URLs); api() callers not double-prefixed. Gate-pass: Codex SAFE (no root-mount regression, all 6 consumers verified), 825 workspace regression tests + browser-smoke green. Nathan-approved. Co-authored-by: t <a@b> Co-authored-by: tomtong2015 <tomtong2015@users.noreply.github.com>
This commit is contained in:
3 files changed
+148
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 `<a href>` 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"]
|
||||
Reference in new issue
Block a user