feat(workspaces): autocomplete trusted workspace paths — v0.50.162 (PR #880 by @franksong2702, closes #616)

Adds GET /api/workspaces/suggest endpoint and autocomplete dropdown in the Spaces panel. Suggestions limited to trusted roots (home, saved workspaces, boot default). Keyboard nav, Tab completion, hidden dir support. Symlink-escape and dotdot-escape invariants locked by regression tests.
This commit is contained in:
Frank Song
2026-04-23 10:35:58 +08:00
committed by GitHub
parent 0f1b232c12
commit 62c56175b7
7 changed files with 365 additions and 10 deletions

View File

@@ -1,5 +1,16 @@
# Hermes Web UI -- Changelog
## [Unreleased]
### Added
- **Workspace path autocomplete in Spaces** — the "Add workspace path" field in
the Spaces panel now suggests trusted directories as you type, supports
keyboard navigation plus `Tab` completion, and keeps hidden directories out of
the list unless the current path segment starts with `.`. Suggestions are
limited to trusted roots (home, saved workspaces, and the boot default
workspace subtree) and never enumerate blocked system roots. (`api/routes.py`,
`api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616)
## [v0.50.161] — 2026-04-23
### Fixed

View File

@@ -300,6 +300,7 @@ from api.workspace import (
get_last_workspace,
set_last_workspace,
list_dir,
list_workspace_suggestions,
read_file_content,
safe_resolve_ws,
resolve_trusted_workspace,
@@ -711,6 +712,17 @@ def handle_get(handler, parsed) -> bool:
handler, {"workspaces": load_workspaces(), "last": get_last_workspace()}
)
if parsed.path == "/api/workspaces/suggest":
qs = parse_qs(parsed.query)
prefix = qs.get("prefix", [""])[0]
return j(
handler,
{
"suggestions": list_workspace_suggestions(prefix),
"prefix": prefix,
},
)
if parsed.path == "/api/sessions/search":
return _handle_sessions_search(handler, parsed)

View File

@@ -219,6 +219,141 @@ def set_last_workspace(path: str) -> None:
logger.debug("Failed to set last workspace")
def _workspace_blocked_roots() -> tuple[Path, ...]:
return (
# Linux / macOS
Path('/etc'),
Path('/usr'),
Path('/var'),
Path('/bin'),
Path('/sbin'),
Path('/boot'),
Path('/proc'),
Path('/sys'),
Path('/dev'),
Path('/lib'),
Path('/lib64'),
Path('/opt/homebrew'),
)
def _is_within(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _trusted_workspace_roots() -> list[Path]:
roots: list[Path] = []
def add(candidate: str | Path | None) -> None:
if candidate in (None, ""):
return
try:
p = Path(candidate).expanduser().resolve()
except Exception:
return
if not p.exists() or not p.is_dir():
return
if any(_is_within(p, blocked) for blocked in _workspace_blocked_roots()):
return
if p not in roots:
roots.append(p)
add(Path.home())
add(_BOOT_DEFAULT_WORKSPACE)
for w in load_workspaces():
add(w.get("path"))
roots.sort(key=lambda p: len(str(p)))
return roots
def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]:
"""Return workspace path suggestions under trusted roots only.
Suggestions are limited to directories under one of:
- Path.home()
- the boot default workspace
- already-saved workspace roots
Arbitrary system prefixes return an empty list rather than an error so the
UI can safely autocomplete while the user types.
"""
roots = _trusted_workspace_roots()
if not roots:
return []
raw = (prefix or "").strip()
if not raw:
return [str(p) for p in roots[:limit]]
if raw.startswith("~"):
target = Path(raw).expanduser()
elif Path(raw).is_absolute():
target = Path(raw)
else:
target = Path.home() / raw
normalized = str(target)
normalized_lower = normalized.lower()
suggestions: list[str] = []
def add(path: Path) -> None:
value = str(path)
if value not in suggestions:
suggestions.append(value)
# If the user is typing a partial trusted root like /Users/xuef..., suggest
# the matching trusted roots without scanning arbitrary system parents.
for root in roots:
if str(root).lower().startswith(normalized_lower):
add(root)
in_root = [
root
for root in roots
if normalized == str(root) or normalized.startswith(str(root) + os.sep)
]
if not in_root:
return suggestions[:limit]
anchor_root = max(in_root, key=lambda p: len(str(p)))
ends_with_sep = raw.endswith(os.sep) or raw.endswith('/')
parent = target if ends_with_sep else target.parent
leaf = '' if ends_with_sep else target.name
show_hidden = leaf.startswith('.')
try:
parent_resolved = parent.expanduser().resolve()
except Exception:
return suggestions[:limit]
if not parent_resolved.exists() or not parent_resolved.is_dir():
return suggestions[:limit]
if not _is_within(parent_resolved, anchor_root):
return suggestions[:limit]
leaf_lower = leaf.lower()
try:
children = sorted(parent_resolved.iterdir(), key=lambda p: p.name.lower())
except OSError:
return suggestions[:limit]
for child in children:
if not child.is_dir():
continue
if child.name.startswith('.') and not show_hidden:
continue
if leaf_lower and not child.name.lower().startswith(leaf_lower):
continue
add(child.resolve())
if len(suggestions) >= limit:
break
return suggestions[:limit]
def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
"""Resolve and validate a workspace path.
@@ -240,13 +375,6 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
None/empty path falls back to the boot-time DEFAULT_WORKSPACE, which is always
trusted (it was validated at server startup).
"""
_BLOCKED_SYSTEM_ROOTS = {
# Linux / macOS
Path('/etc'), Path('/usr'), Path('/var'), Path('/bin'), Path('/sbin'),
Path('/boot'), Path('/proc'), Path('/sys'), Path('/dev'),
Path('/lib'), Path('/lib64'), Path('/opt/homebrew'),
}
if path in (None, ""):
return Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve()
@@ -258,7 +386,7 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
raise ValueError(f"Path is not a directory: {candidate}")
# Block known system roots and their children
for blocked in _BLOCKED_SYSTEM_ROOTS:
for blocked in _workspace_blocked_roots():
try:
candidate.relative_to(blocked)
raise ValueError(f"Path points to a system directory: {candidate}")

View File

@@ -536,6 +536,94 @@ async function submitMemorySave() {
// ── Workspace management ──
let _workspaceList = []; // cached from /api/workspaces
let _wsSuggestTimer = null;
let _wsSuggestReq = 0;
let _wsSuggestIndex = -1;
function closeWorkspacePathSuggestions(){
const box=$('wsAddSuggestions');
if(box){
box.innerHTML='';
box.style.display='none';
}
_wsSuggestIndex=-1;
}
function _applyWorkspaceSuggestion(path){
const input=$('wsAddInput');
const next=(path||'').endsWith('/')?(path||''):`${path||''}/`;
if(input){
input.value=next;
input.focus();
input.setSelectionRange(next.length, next.length);
}
scheduleWorkspacePathSuggestions();
}
function _highlightWorkspaceSuggestion(idx){
const box=$('wsAddSuggestions');
if(!box)return;
const items=[...box.querySelectorAll('.ws-suggest-item')];
items.forEach((el,i)=>{
const active=i===idx;
el.classList.toggle('active', active);
if(active) el.scrollIntoView({block:'nearest'});
});
}
function _renderWorkspacePathSuggestions(paths){
const box=$('wsAddSuggestions');
if(!box)return;
box.innerHTML='';
if(!paths || !paths.length){
box.style.display='none';
_wsSuggestIndex=-1;
return;
}
paths.forEach((path, idx)=>{
const pathParts=(path||'').split('/').filter(Boolean);
const leaf=pathParts[pathParts.length-1]||path;
const parent=pathParts.length>1?`/${pathParts.slice(0,-1).join('/')}`:'/';
const item=document.createElement('button');
item.type='button';
item.className='ws-suggest-item';
item.innerHTML=`<span class="ws-suggest-leaf">${esc(leaf)}</span><span class="ws-suggest-parent">${esc(parent)}</span>`;
item.dataset.path=path;
item.onmouseenter=()=>{_wsSuggestIndex=idx;_highlightWorkspaceSuggestion(idx);};
item.onmousedown=(e)=>{e.preventDefault();_applyWorkspaceSuggestion(path);};
box.appendChild(item);
});
box.style.display='block';
_wsSuggestIndex=0;
_highlightWorkspaceSuggestion(_wsSuggestIndex);
}
async function _loadWorkspacePathSuggestions(prefix){
const reqId=++_wsSuggestReq;
try{
const qs=new URLSearchParams({prefix:prefix||''}).toString();
const data=await api(`/api/workspaces/suggest?${qs}`);
if(reqId!==_wsSuggestReq)return;
_renderWorkspacePathSuggestions(data.suggestions||[]);
}catch(_){
if(reqId!==_wsSuggestReq)return;
closeWorkspacePathSuggestions();
}
}
function scheduleWorkspacePathSuggestions(){
const input=$('wsAddInput');
if(!input)return;
const prefix=input.value.trim();
if(!prefix){
closeWorkspacePathSuggestions();
return;
}
if(_wsSuggestTimer) clearTimeout(_wsSuggestTimer);
_wsSuggestTimer=setTimeout(()=>{
_loadWorkspacePathSuggestions(prefix);
}, 120);
}
function getWorkspaceFriendlyName(path){
// Look up the friendly name from the workspace list cache, fallback to last path segment
@@ -719,13 +807,70 @@ function renderWorkspacesPanel(workspaces){
}
const addRow=document.createElement('div');addRow.className='ws-add-row';
addRow.innerHTML=`
<input id="wsAddInput" placeholder="${esc(t('workspace_add_path_placeholder'))}" style="flex:1;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:7px 10px;font-size:12px;outline:none;">
<div class="ws-add-input-wrap">
<input id="wsAddInput" placeholder="${esc(t('workspace_add_path_placeholder'))}" autocomplete="off" style="width:100%;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:7px 10px;font-size:12px;outline:none;">
</div>
<button class="ws-action-btn" onclick="addWorkspace()">${li('plus',12)} ${esc(t('add'))}</button>`;
panel.appendChild(addRow);
const suggestBox=document.createElement('div');
suggestBox.id='wsAddSuggestions';
suggestBox.className='ws-suggestions';
suggestBox.style.display='none';
panel.appendChild(suggestBox);
const hint=document.createElement('div');
hint.style.cssText='font-size:11px;color:var(--muted);padding:4px 0 8px';
hint.textContent=t('workspace_paths_validated_hint');
panel.appendChild(hint);
const input=$('wsAddInput');
if(input){
input.oninput=()=>scheduleWorkspacePathSuggestions();
input.onfocus=()=>{
if(input.value.trim()) scheduleWorkspacePathSuggestions();
else closeWorkspacePathSuggestions();
};
input.onkeydown=(e)=>{
const box=$('wsAddSuggestions');
const items=box?[...box.querySelectorAll('.ws-suggest-item')]:[];
if(!items.length){
if(e.key==='Enter'){
e.preventDefault();
addWorkspace();
}
return;
}
if(e.key==='ArrowDown'){
e.preventDefault();
_wsSuggestIndex=Math.min(items.length-1,Math.max(-1,_wsSuggestIndex)+1);
_highlightWorkspaceSuggestion(_wsSuggestIndex);
return;
}
if(e.key==='ArrowUp'){
e.preventDefault();
_wsSuggestIndex=_wsSuggestIndex<=0?0:_wsSuggestIndex-1;
_highlightWorkspaceSuggestion(_wsSuggestIndex);
return;
}
if(e.key==='Escape'){
e.preventDefault();
closeWorkspacePathSuggestions();
return;
}
if(e.key==='Enter'){
e.preventDefault();
if(_wsSuggestIndex>=0 && items[_wsSuggestIndex]){
_applyWorkspaceSuggestion(items[_wsSuggestIndex].dataset.path||'');
}else{
addWorkspace();
}
return;
}
if(e.key==='Tab' && _wsSuggestIndex>=0 && items[_wsSuggestIndex]){
e.preventDefault();
_applyWorkspaceSuggestion(items[_wsSuggestIndex].dataset.path||'');
return;
}
};
}
}
async function addWorkspace(){
@@ -737,10 +882,15 @@ async function addWorkspace(){
_workspaceList=data.workspaces;
renderWorkspacesPanel(data.workspaces);
if(input)input.value='';
closeWorkspacePathSuggestions();
showToast(t('workspace_added'));
}catch(e){setStatus(t('add_failed')+e.message);}
}
document.addEventListener('click',e=>{
if(!e.target.closest('.ws-add-input-wrap')) closeWorkspacePathSuggestions();
});
async function removeWorkspace(path){
const _rmWs=await showConfirmDialog({title:t('workspace_remove_confirm_title'),message:t('workspace_remove_confirm_message',path),confirmLabel:t('remove'),danger:true,focusCancel:true});
if(!_rmWs) return;

View File

@@ -874,6 +874,15 @@
.cmd-item-badge-skill{color:var(--accent-text);background:var(--accent-bg);border-color:var(--accent-bg-strong);}
.ws-action-btn.danger:hover{background:rgba(239,83,80,.1);color:var(--error);border-color:var(--error);}
.ws-add-row{display:flex;gap:8px;align-items:center;padding:10px 0 4px;}
.ws-add-input-wrap{flex:1;min-width:0;}
.ws-suggestions{margin:0 0 6px;background:var(--bg2);border:1px solid var(--border2);border-radius:8px;box-shadow:0 10px 24px rgba(0,0,0,.22);max-height:220px;overflow:auto;}
.ws-suggest-item{display:flex;flex-direction:column;gap:2px;width:100%;padding:8px 10px;border:0;background:transparent;color:var(--text);text-align:left;font-size:12px;cursor:pointer;}
.ws-suggest-item:hover{background:rgba(255,255,255,.08);}
.ws-suggest-item.active{background:var(--accent-bg);outline:1px solid var(--accent-bg-strong);outline-offset:-1px;box-shadow:inset 0 0 0 1px rgba(255,255,255,.04);}
.ws-suggest-leaf{font-size:13px;font-weight:600;color:var(--text);}
.ws-suggest-parent{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.ws-suggest-item.active .ws-suggest-leaf{color:var(--accent-text);}
.ws-suggest-item.active .ws-suggest-parent{color:var(--text);}
/* ── Message action buttons (copy, edit, retry) ── */
.msg-actions{display:flex;align-items:center;gap:2px;opacity:0;transition:opacity .15s;margin-left:auto;}
.msg-row:hover .msg-actions{opacity:1;}

17
tests/test_issue616.py Normal file
View File

@@ -0,0 +1,17 @@
import pathlib
def test_workspace_suggest_endpoint_is_wired():
src = pathlib.Path("api/routes.py").read_text(encoding="utf-8")
assert '"/api/workspaces/suggest"' in src
def test_spaces_panel_uses_workspace_suggest_autocomplete():
src = pathlib.Path("static/panels.js").read_text(encoding="utf-8")
assert "/api/workspaces/suggest" in src
assert "wsAddSuggestions" in src
assert "scheduleWorkspacePathSuggestions" in src
assert "if(!prefix)" in src
assert "dataset.path" in src
assert "scrollIntoView" in src
assert "_wsSuggestIndex=0" in src

View File

@@ -1,5 +1,5 @@
"""Sprint 5 tests: workspace CRUD, file save, session index, JS serving."""
import json, pathlib, uuid, urllib.request, urllib.error
import json, pathlib, uuid, urllib.request, urllib.error, urllib.parse
import os
from tests._pytest_port import BASE
@@ -80,6 +80,34 @@ def test_workspace_add_requires_path():
result, status = post("/api/workspaces/add", {})
assert status == 400
def test_workspace_suggest_returns_trusted_directories(cleanup_test_sessions):
_, ws = make_session_tracked(cleanup_test_sessions)
child = make_workspace_child(ws, f"workspace-suggest-{uuid.uuid4().hex[:6]}")
nested = make_workspace_child(child, "nested")
prefix = str(child.parent / child.name[:12])
data, status = get(f"/api/workspaces/suggest?prefix={urllib.parse.quote(prefix)}")
assert status == 200
assert str(child) in data["suggestions"]
assert all(not pathlib.Path(p).name.startswith('.') for p in data["suggestions"])
def test_workspace_suggest_hides_untrusted_system_prefix():
data, status = get("/api/workspaces/suggest?prefix=/etc")
assert status == 200
assert data["suggestions"] == []
def test_workspace_suggest_hidden_dirs_only_when_requested(cleanup_test_sessions):
_, ws = make_session_tracked(cleanup_test_sessions)
hidden = make_workspace_child(ws, ".workspace-hidden")
visible = make_workspace_child(ws, "workspace-visible")
base = str(ws) + "/"
data, status = get(f"/api/workspaces/suggest?prefix={urllib.parse.quote(base)}")
assert status == 200
assert str(visible) in data["suggestions"]
assert str(hidden) not in data["suggestions"]
data2, status2 = get(f"/api/workspaces/suggest?prefix={urllib.parse.quote(base + '.w')}")
assert status2 == 200
assert str(hidden) in data2["suggestions"]
def test_workspace_remove(cleanup_test_sessions):
_, ws = make_session_tracked(cleanup_test_sessions)
child = make_workspace_child(ws, f"workspace-remove-{uuid.uuid4().hex[:6]}")