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:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
144
api/workspace.py
144
api/workspace.py
@@ -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}")
|
||||
|
||||
152
static/panels.js
152
static/panels.js
@@ -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;
|
||||
|
||||
@@ -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
17
tests/test_issue616.py
Normal 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
|
||||
@@ -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]}")
|
||||
|
||||
Reference in New Issue
Block a user