Prevent stale escape errors from collapsing live browse grants

This commit is contained in:
Rod Boev 2026-06-22 00:07:40 -04:00
parent 58b361b811
commit d20e9dd852
4 changed files with 35 additions and 25 deletions

View file

@ -6614,6 +6614,7 @@ from api.workspace import (
set_last_workspace,
git_info_for_workspace,
authorize_escape_target,
EscapeAuthorizationExpiredError,
list_dir,
list_authorized_escape_dir,
dir_signature,
@ -9825,9 +9826,6 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/list":
return _handle_list_dir(handler, parsed)
if parsed.path == "/api/escape/authorize":
return _handle_escape_authorize(handler, parsed)
if parsed.path == "/api/escape/list":
return _handle_escape_list_dir(handler, parsed)
@ -12995,10 +12993,10 @@ def _handle_escape_list_dir(handler, parsed):
return j(handler, payload)
except FileNotFoundError as exc:
return bad(handler, _sanitize_error(exc), 404)
except EscapeAuthorizationExpiredError as exc:
return bad(handler, _sanitize_error(exc), 403)
except ValueError as exc:
msg = _sanitize_error(exc)
status = 403 if "expired" in msg.lower() else 404
return bad(handler, msg, status)
return bad(handler, _sanitize_error(exc), 404)
def _handle_escape_file_read(handler, parsed):
@ -13018,10 +13016,10 @@ def _handle_escape_file_read(handler, parsed):
return j(handler, read_authorized_escape_file_content(Path(s.workspace), sid, token, rel))
except FileNotFoundError as exc:
return bad(handler, _sanitize_error(exc), 404)
except EscapeAuthorizationExpiredError as exc:
return bad(handler, _sanitize_error(exc), 403)
except ValueError as exc:
msg = _sanitize_error(exc)
status = 403 if "expired" in msg.lower() else 404
return bad(handler, msg, status)
return bad(handler, _sanitize_error(exc), 404)
def _handle_escape_file_raw(handler, parsed):
@ -13042,10 +13040,10 @@ def _handle_escape_file_raw(handler, parsed):
anchor_root, target = raw_authorized_escape_target(Path(s.workspace), sid, token, rel)
except FileNotFoundError:
return j(handler, {"error": "not found"}, status=404)
except EscapeAuthorizationExpiredError as exc:
return bad(handler, _sanitize_error(exc), 403)
except ValueError as exc:
msg = _sanitize_error(exc)
status = 403 if "expired" in msg.lower() else 404
return bad(handler, msg, status)
return bad(handler, _sanitize_error(exc), 404)
if not target.exists() or not target.is_file():
return j(handler, {"error": "not found"}, status=404)
ext = target.suffix.lower()

View file

@ -1461,7 +1461,13 @@ def _escape_virtual_path(root: str, rel: str) -> str:
def _escape_surface_target(workspace: Path, rel: str) -> tuple[Path, Path]:
workspace_root = workspace.resolve()
surface_rel = _normalize_workspace_rel_path(rel)
surface_path = workspace / surface_rel
surface_posix = PurePosixPath(surface_rel)
parent_rel = str(surface_posix.parent)
if parent_rel in ("", "."):
parent_path = workspace_root
else:
parent_path = safe_resolve_ws(workspace_root, parent_rel)
surface_path = parent_path / surface_posix.name
if not surface_path.is_symlink():
raise ValueError(f"Path is not an escape-target symlink: {rel}")
target = surface_path.resolve()
@ -1485,6 +1491,10 @@ def _escape_authorized_root(target: Path) -> tuple[Path, str]:
return resolved_target.parent, resolved_target.name
class EscapeAuthorizationExpiredError(ValueError):
pass
def _escape_prune_tokens(now: float | None = None) -> None:
cutoff = time.time() if now is None else now
expired = [token for token, record in _ESCAPE_AUTH_TOKENS.items() if float(record.get("expires_at") or 0.0) <= cutoff]
@ -1531,25 +1541,21 @@ def _escape_authorization_record(workspace: Path, session_id: str, token: str) -
_escape_prune_tokens(now)
record = dict(_ESCAPE_AUTH_TOKENS.get(token) or {})
if not record:
raise ValueError("Escape authorization expired")
raise EscapeAuthorizationExpiredError("Escape authorization expired")
if str(record.get("session_id") or "") != str(session_id or ""):
raise ValueError("Escape authorization expired")
raise EscapeAuthorizationExpiredError("Escape authorization expired")
if str(record.get("workspace_root") or "") != workspace_root:
raise ValueError("Escape authorization expired")
if now >= float(record.get("expires_at") or 0.0):
with _ESCAPE_AUTH_LOCK:
_ESCAPE_AUTH_TOKENS.pop(token, None)
raise ValueError("Escape authorization expired")
raise EscapeAuthorizationExpiredError("Escape authorization expired")
surface_path = _normalize_workspace_rel_path(record.get("surface_path") or ".")
surface_target = str(record.get("surface_target") or "")
try:
_surface, current_target = _escape_surface_target(Path(workspace_root), surface_path)
except ValueError:
raise ValueError("Escape authorization expired") from None
raise EscapeAuthorizationExpiredError("Escape authorization expired") from None
if str(current_target.resolve()) != surface_target:
raise ValueError("Escape authorization expired")
raise EscapeAuthorizationExpiredError("Escape authorization expired")
if not current_target.exists() or _is_blocked_system_path(current_target):
raise ValueError("Escape authorization expired")
raise EscapeAuthorizationExpiredError("Escape authorization expired")
return record

View file

@ -697,7 +697,7 @@ async function loadDir(path, opts={}){
if(!path||path==='.') _refreshGitBadge();
}catch(e){
const grant = _workspaceEscapeGrantForPath(path);
if(grant && (e && (e.status === 403 || e.status === 404))){
if(grant && e && e.status===403){
_clearWorkspaceEscapeGrant(grant.path);
showToast(t('external_link_grant_expired') || t('file_open_failed'), 5000, 'error');
return;
@ -1095,7 +1095,7 @@ async function openFile(path, opts={}){
renderCodePreviewContent(path, data.content);
}catch(e){
const grant = _workspaceEscapeGrantForPath(path);
if(grant && (e && (e.status === 403 || e.status === 404))){
if(grant && e && e.status===403){
_clearWorkspaceEscapeGrant(grant.path);
showToast(t('external_link_grant_expired') || t('file_open_failed'), 5000, 'error');
return;

View file

@ -176,6 +176,12 @@ class TestIssue4582EscapeNavigationLive:
auth, status = _post_json("/api/escape/authorize", {"session_id": sid, "path": "escape"})
assert status == 200, auth
nested_auth, nested_status = _post_json(
"/api/escape/authorize",
{"session_id": sid, "path": "escape/nested-escape"},
)
assert nested_status in (403, 404), nested_auth
listed = _get_json(
f"/api/escape/list?session_id={sid}&token={auth['token']}&path=escape"
)