diff --git a/gently/harness/conversation.py b/gently/harness/conversation.py index e5de53d9..bfd09684 100644 --- a/gently/harness/conversation.py +++ b/gently/harness/conversation.py @@ -17,6 +17,47 @@ logger = logging.getLogger(__name__) +_TEXT_TOOL_CALL_RE = re.compile(r"\s*(.*?)\s*", re.DOTALL | re.IGNORECASE) + + +def _extract_text_tool_calls(text: str) -> tuple[str, List[Dict[str, Any]]]: + """Extract JSON tool calls embedded in text fallback tags. + + Some model/test harness paths may emit a tool request as text instead of + structured ``tool_use`` blocks. Keep parsing permissive, but only return + well-formed objects that name a tool. + """ + if not text: + return text, [] + + calls: List[Dict[str, Any]] = [] + + def _remove_or_collect(match: re.Match) -> str: + try: + payload = json.loads(match.group(1).strip()) + except (TypeError, json.JSONDecodeError): + return "" + if not isinstance(payload, dict): + return "" + name = payload.get("name") + if not name: + return "" + tool_input = payload.get("input") + if tool_input is None: + tool_input = payload.get("arguments", {}) + if tool_input is None: + tool_input = {} + calls.append({ + "name": name, + "input": tool_input, + "id": payload.get("id"), + }) + return "" + + cleaned = _TEXT_TOOL_CALL_RE.sub(_remove_or_collect, text) + return cleaned, calls + + def _extend_tool_calls(out: List[Dict[str, Any]], content_blocks) -> None: """Append every tool_use block in content_blocks to out. diff --git a/gently/mesh/mesh_service.py b/gently/mesh/mesh_service.py index edac242c..c5cbe5b1 100644 --- a/gently/mesh/mesh_service.py +++ b/gently/mesh/mesh_service.py @@ -212,7 +212,7 @@ def _on_peer_discovered(self, data: dict, sender_ip: str, verified: bool = False # Only fetch status from trusted peers if trusted: - asyncio.ensure_future(self._fetch_and_update_peer(peer)) + self._schedule_status_fetch(peer) def _on_peer_heartbeat(self, instance_id: str, sender_ip: str, verified: bool = False): """Called on subsequent heartbeats from a known peer.""" @@ -228,7 +228,7 @@ def _on_nudge_received(self, peer_id: str, sender_ip: str): if peer: peer.last_seen = time.time() peer.ip_address = sender_ip - asyncio.ensure_future(self._fetch_and_update_peer(peer)) + self._schedule_status_fetch(peer) logger.debug(f"Mesh: nudge from {peer.hostname} ({peer_id[:8]}), refetching") def _on_local_status_changed(self, event): @@ -311,6 +311,20 @@ async def _fetch_and_update_peer(self, peer: PeerInfo): "hostname": peer.hostname, }) + def _schedule_status_fetch(self, peer: PeerInfo) -> None: + """Schedule a best-effort peer status fetch when the service is running.""" + if not self._peer_client: + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + logger.debug( + "Mesh: skipping status fetch for %s because no event loop is running", + peer.instance_id[:8], + ) + return + loop.create_task(self._fetch_and_update_peer(peer)) + # ------------------------------------------------------------------ # Pairing integration # ------------------------------------------------------------------ @@ -336,7 +350,7 @@ def mark_peer_trusted(self, instance_id: str): if cert_fp: peer.tls_enabled = True # Kick off an immediate status fetch now that we trust them - asyncio.ensure_future(self._fetch_and_update_peer(peer)) + self._schedule_status_fetch(peer) logger.info(f"Mesh: peer {peer.hostname} ({instance_id[:8]}) now trusted") # ------------------------------------------------------------------ diff --git a/gently/ui/web/accounts.py b/gently/ui/web/accounts.py index 2a534c44..07db175c 100644 --- a/gently/ui/web/accounts.py +++ b/gently/ui/web/accounts.py @@ -106,6 +106,8 @@ def create_user(self, username: str, password: str, role: str = "viewer") -> Non username = (username or "").strip() if not username: raise ValueError("username required") + if username in self._users: + raise ValueError(f"user already exists: {username}") if role not in ROLES: raise ValueError(f"role must be one of {ROLES}") salt = secrets.token_bytes(16) @@ -118,6 +120,43 @@ def create_user(self, username: str, password: str, role: str = "viewer") -> Non } self._save_users() + def set_role(self, username: str, role: str) -> None: + username = (username or "").strip() + if username not in self._users: + raise ValueError(f"user not found: {username}") + if role not in ROLES: + raise ValueError(f"role must be one of {ROLES}") + if self._users[username].get("role") == "admin" and role != "admin": + if self._admin_count() <= 1: + raise ValueError("cannot demote the last admin user") + self._users[username]["role"] = role + self._save_users() + + def reset_password(self, username: str, password: str) -> None: + username = (username or "").strip() + if username not in self._users: + raise ValueError(f"user not found: {username}") + salt = secrets.token_bytes(16) + self._users[username].update({ + "salt": salt.hex(), + "hash": self._hash(password, salt, _PBKDF2_ITERATIONS).hex(), + "iterations": _PBKDF2_ITERATIONS, + "password_updated_at": datetime.now().isoformat(timespec="seconds"), + }) + self._save_users() + + def delete_user(self, username: str) -> None: + username = (username or "").strip() + if username not in self._users: + raise ValueError(f"user not found: {username}") + if self._users[username].get("role") == "admin" and self._admin_count() <= 1: + raise ValueError("cannot delete the last admin user") + del self._users[username] + self._save_users() + + def _admin_count(self) -> int: + return sum(1 for rec in self._users.values() if rec.get("role") == "admin") + def verify_password(self, username: str, password: str) -> Optional[str]: """Return the user's role if the password matches, else None.""" rec = self._users.get((username or "").strip()) diff --git a/gently/ui/web/auth.py b/gently/ui/web/auth.py index 2f2d9927..34486e72 100644 --- a/gently/ui/web/auth.py +++ b/gently/ui/web/auth.py @@ -26,7 +26,7 @@ async def foo(_=Depends(require_control)): import os from enum import Enum -from fastapi import HTTPException, Request +from fastapi import HTTPException, Request, WebSocket logger = logging.getLogger(__name__) @@ -47,7 +47,7 @@ class Role(str, Enum): CONTROL = "control" -def current_username(request: Request) -> str | None: +def current_username(request: Request | WebSocket) -> str | None: """Return the authenticated username from the session cookie, or None. None when no account store is configured or the cookie is missing/invalid. @@ -70,7 +70,7 @@ def _configured_token() -> str | None: return tok or None -def resolve_role(request: Request) -> Role: +def resolve_role(request: Request | WebSocket) -> Role: """Determine the effective role for a request. Account mode (preferred): if user accounts are configured, identity comes diff --git a/gently/ui/web/routes/agent_ws.py b/gently/ui/web/routes/agent_ws.py index 8f3a3262..37e1b643 100644 --- a/gently/ui/web/routes/agent_ws.py +++ b/gently/ui/web/routes/agent_ws.py @@ -292,23 +292,13 @@ async def agent_websocket(websocket: WebSocket): bridge.register_display_broadcaster(_broadcast) # ── Authenticate the connection (account mode) ──────────── - # When user accounts are configured, identity comes from the signed - # session cookie (set at login). Viewers may watch but not drive; - # operators/admins may take the control lock. With no accounts - # configured we fall back to the legacy "anyone connected can drive". - from gently.ui.web.accounts import get_account_store, CONTROL_ROLES - from gently.ui.web.auth import SESSION_COOKIE - _acct = get_account_store() - username = None - can_control = True # legacy default when no accounts are configured - if _acct is not None and _acct.has_users(): - # Viewing is open: anonymous clients may connect and *watch* the - # conversation. Only authenticated operators/admins can hold or - # take the control lock (enforced on the drive actions below). - _token = websocket.cookies.get(SESSION_COOKIE) - username = _acct.verify_session(_token) if _token else None - role = _acct.get_role(username) if username else None - can_control = role in CONTROL_ROLES + # Viewing is open: anonymous clients may connect and watch the + # conversation. Driving uses the same role resolver as REST: accounts + # rely on the signed session cookie; legacy no-account mode grants + # localhost control and requires X-Gently-Token for remote clients. + from gently.ui.web.auth import Role, current_username, resolve_role + username = current_username(websocket) + can_control = resolve_role(websocket) is Role.CONTROL # Assign a stable id for control arbitration. The label shown to other # clients is the username when authenticated, else a generic window id. diff --git a/gently/ui/web/routes/auth_routes.py b/gently/ui/web/routes/auth_routes.py index e7db5500..3706844b 100644 --- a/gently/ui/web/routes/auth_routes.py +++ b/gently/ui/web/routes/auth_routes.py @@ -24,6 +24,15 @@ def _secure(request: Request) -> bool: # plain-HTTP LAN deployments. return request.url.scheme == "https" + def _require_admin(request: Request): + store = get_account_store() + if store is None: + return None, JSONResponse({"error": "accounts not configured"}, status_code=400) + requester = current_username(request) + if not requester or store.get_role(requester) != "admin": + return None, JSONResponse({"error": "admin role required"}, status_code=403) + return store, None + @router.get("/login", response_class=HTMLResponse) async def login_page(request: Request): store = get_account_store() @@ -31,7 +40,23 @@ async def login_page(request: Request): return RedirectResponse("/", status_code=302) if current_username(request): return RedirectResponse("/", status_code=302) - return server.templates.TemplateResponse("login.html", {"request": request}) + return server.templates.TemplateResponse(request, "login.html") + + @router.get("/admin/users", response_class=HTMLResponse) + async def admin_users_page(request: Request): + store = get_account_store() + if store is None or not store.has_users(): + return RedirectResponse("/", status_code=302) + requester = current_username(request) + if not requester: + return RedirectResponse("/login", status_code=302) + if store.get_role(requester) != "admin": + return HTMLResponse("Forbidden", status_code=403) + return server.templates.TemplateResponse( + request, + "admin_users.html", + {"username": requester}, + ) @router.post("/api/auth/login") async def login(request: Request): @@ -83,12 +108,10 @@ async def me(request: Request): @router.post("/api/auth/users") async def create_user(request: Request): """Admin-only: provision a new account.""" - store = get_account_store() - if store is None: - return JSONResponse({"error": "accounts not configured"}, status_code=400) + store, error = _require_admin(request) + if error: + return error requester = current_username(request) - if not requester or store.get_role(requester) != "admin": - return JSONResponse({"error": "admin role required"}, status_code=403) try: body = await request.json() except Exception: @@ -107,4 +130,51 @@ async def create_user(request: Request): logger.info("admin %s created user %s (%s)", requester, new_user, role) return JSONResponse({"ok": True, "username": new_user, "role": role}) + @router.get("/api/auth/users") + async def list_users(request: Request): + """Admin-only: list configured accounts.""" + store, error = _require_admin(request) + if error: + return error + return JSONResponse({"ok": True, "users": store.list_users()}) + + @router.patch("/api/auth/users/{username}") + async def update_user(username: str, request: Request): + """Admin-only: change a role and/or reset a password.""" + store, error = _require_admin(request) + if error: + return error + requester = current_username(request) + try: + body = await request.json() + except Exception: + body = {} + + changed = [] + try: + if "role" in body and body["role"]: + store.set_role(username, body["role"]) + changed.append("role") + if "password" in body and body["password"]: + store.reset_password(username, body["password"]) + changed.append("password") + except ValueError as e: + return JSONResponse({"error": str(e)}, status_code=400) + logger.info("admin %s updated user %s (%s)", requester, username, ",".join(changed)) + return JSONResponse({"ok": True, "username": username, "changed": changed}) + + @router.delete("/api/auth/users/{username}") + async def delete_user(username: str, request: Request): + """Admin-only: remove an account.""" + store, error = _require_admin(request) + if error: + return error + requester = current_username(request) + try: + store.delete_user(username) + except ValueError as e: + return JSONResponse({"error": str(e)}, status_code=400) + logger.info("admin %s deleted user %s", requester, username) + return JSONResponse({"ok": True, "username": username}) + return router diff --git a/gently/ui/web/routes/images.py b/gently/ui/web/routes/images.py index 5d505b6a..dafa96d7 100644 --- a/gently/ui/web/routes/images.py +++ b/gently/ui/web/routes/images.py @@ -5,13 +5,17 @@ from typing import Optional import numpy as np -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import Response, FileResponse +from gently.ui.web.auth import require_control from ..volume_helpers import parse_volume_uid +from ..upload_validation import decode_array_payload logger = logging.getLogger(__name__) +MAX_IMAGE_UPLOAD_BYTES = 64 * 1024 * 1024 + def create_router(server) -> APIRouter: router = APIRouter() @@ -124,7 +128,7 @@ async def get_image_png(uid: str): raise HTTPException(status_code=404, detail=f"Image {uid} not found") - @router.post("/api/images") + @router.post("/api/images", dependencies=[Depends(require_control)]) async def push_image_http(request: Request): """Push a 2D image via HTTP (for CV subagent visualizations)""" try: @@ -141,17 +145,21 @@ async def push_image_http(request: Request): if not all([image_b64, uid, shape]): raise HTTPException(status_code=400, detail="Missing required fields") - # Decode array - array = np.frombuffer( - base64.b64decode(image_b64), - dtype=np.dtype(dtype) - ).reshape(shape) + array = decode_array_payload( + image_b64, + shape, + dtype, + max_nbytes=MAX_IMAGE_UPLOAD_BYTES, + label="image", + ) # Push using the existing method await server.push_image(array, uid, data_type, metadata) return {"status": "ok", "uid": uid, "data_type": data_type} + except HTTPException: + raise except Exception as e: logger.error(f"Failed to push image via HTTP: {e}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/gently/ui/web/routes/volumes.py b/gently/ui/web/routes/volumes.py index e48475c3..cb39b109 100644 --- a/gently/ui/web/routes/volumes.py +++ b/gently/ui/web/routes/volumes.py @@ -6,13 +6,17 @@ from typing import Optional import numpy as np -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import Response +from gently.ui.web.auth import require_control from ..volume_helpers import load_volume_from_disk, image_to_base64_png +from ..upload_validation import decode_array_payload logger = logging.getLogger(__name__) +MAX_VOLUME_UPLOAD_BYTES = 512 * 1024 * 1024 + try: from PIL import Image PIL_AVAILABLE = True @@ -220,7 +224,7 @@ async def get_volume_data_for_3d_viewer(uid: str): raise HTTPException(status_code=404, detail=f"Volume {uid} not found") - @router.post("/api/volumes3d") + @router.post("/api/volumes3d", dependencies=[Depends(require_control)]) async def push_volume_3d_http(request: Request): """Push a 3D volume with segmentation via HTTP (for CV subagent)""" try: @@ -238,22 +242,29 @@ async def push_volume_3d_http(request: Request): if not all([volume_b64, masks_b64, uid, shape]): raise HTTPException(status_code=400, detail="Missing required fields") - # Decode arrays - volume = np.frombuffer( - base64.b64decode(volume_b64), - dtype=np.dtype(dtype_vol) - ).reshape(shape) + volume = decode_array_payload( + volume_b64, + shape, + dtype_vol, + max_nbytes=MAX_VOLUME_UPLOAD_BYTES, + label="volume", + ) - masks = np.frombuffer( - base64.b64decode(masks_b64), - dtype=np.dtype(dtype_mask) - ).reshape(shape) + masks = decode_array_payload( + masks_b64, + shape, + dtype_mask, + max_nbytes=MAX_VOLUME_UPLOAD_BYTES, + label="masks", + ) # Push using the existing method await server.push_volume_3d(volume, masks, uid, metadata) return {"status": "ok", "uid": uid, "shape": shape} + except HTTPException: + raise except Exception as e: logger.error(f"Failed to push 3D volume via HTTP: {e}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/gently/ui/web/routes/websocket.py b/gently/ui/web/routes/websocket.py index 3b35f3e1..35b7f9de 100644 --- a/gently/ui/web/routes/websocket.py +++ b/gently/ui/web/routes/websocket.py @@ -22,18 +22,12 @@ def _ws_can_control(websocket: WebSocket) -> bool: """Whether this /ws client may perform control actions (marking). - Account mode: operators/admins (by session cookie) only. Legacy mode - (no accounts configured): open, preserving prior behavior. + Mirrors the REST role resolver: accounts use the session cookie; legacy + no-account mode grants localhost control and requires X-Gently-Token for + remote control. """ - from gently.ui.web.accounts import get_account_store, CONTROL_ROLES - from gently.ui.web.auth import SESSION_COOKIE - store = get_account_store() - if store is None or not store.has_users(): - return True - token = websocket.cookies.get(SESSION_COOKIE) - user = store.verify_session(token) if token else None - role = store.get_role(user) if user else None - return role in CONTROL_ROLES + from gently.ui.web.auth import Role, resolve_role + return resolve_role(websocket) is Role.CONTROL def create_router(server) -> APIRouter: diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index fdafcc1d..df41bb17 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -1525,6 +1525,205 @@ a.tab-link.active { font-family: monospace; } +/* Admin users */ +.admin-users-page { + flex: 1; + min-height: 0; + overflow: auto; + padding: 1.5rem; + background: var(--bg-dark); +} + +.admin-users-shell { + max-width: 1120px; + margin: 0 auto; +} + +.admin-users-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.admin-users-head h1 { + margin: 0; + color: var(--accent); + font-size: 1.25rem; +} + +.admin-users-subtitle { + margin-top: 0.25rem; + color: var(--text-muted); + font-size: 0.85rem; +} + +.admin-users-status { + min-height: 1.2rem; + color: var(--text-muted); + font-size: 0.8rem; + text-align: right; +} + +.admin-users-status[data-tone="ok"] { + color: var(--accent-green); +} + +.admin-users-status[data-tone="error"] { + color: var(--accent-orange); +} + +.admin-user-create { + display: grid; + grid-template-columns: minmax(160px, 1fr) minmax(180px, 1fr) minmax(130px, 160px) auto; + gap: 0.75rem; + align-items: end; + padding: 1rem; + margin-bottom: 1rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); +} + +.admin-user-create label, +.admin-password-reset { + display: flex; + gap: 0.45rem; +} + +.admin-user-create label { + flex-direction: column; +} + +.admin-user-create span { + color: var(--text-muted); + font-size: 0.76rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.admin-primary-btn, +.admin-secondary-btn, +.admin-danger-btn { + border: 1px solid var(--border); + border-radius: 5px; + padding: 0.45rem 0.75rem; + color: var(--text); + background: var(--bg-hover); + cursor: pointer; + font-size: 0.82rem; + transition: border-color 0.15s, background 0.15s, color 0.15s; +} + +.admin-primary-btn { + color: #fff; + border-color: var(--accent); + background: var(--accent); +} + +.admin-secondary-btn:hover, +.admin-danger-btn:hover:not(:disabled) { + border-color: var(--accent); +} + +.admin-danger-btn { + color: var(--accent-orange); +} + +.admin-danger-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.admin-users-table-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); +} + +.admin-users-table { + width: 100%; + border-collapse: collapse; +} + +.admin-users-table th, +.admin-users-table td { + padding: 0.75rem; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: middle; +} + +.admin-users-table th { + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + background: var(--bg-dark); +} + +.admin-users-table tr:last-child td { + border-bottom: none; +} + +.admin-users-name { + font-weight: 600; +} + +.admin-current-user { + display: inline-flex; + margin-left: 0.5rem; + padding: 0.12rem 0.35rem; + border-radius: 4px; + color: var(--accent-green); + background: rgba(74, 222, 128, 0.12); + font-size: 0.68rem; + font-weight: 600; +} + +.admin-password-reset { + align-items: center; +} + +.admin-password-reset .settings-input { + min-width: 180px; +} + +.admin-role-select { + min-width: 130px; +} + +.admin-users-actions { + width: 1%; + white-space: nowrap; +} + +.admin-users-empty { + color: var(--text-muted); + text-align: center; +} + +@media (max-width: 800px) { + .admin-users-page { + padding: 1rem; + } + .admin-users-head { + align-items: flex-start; + flex-direction: column; + } + .admin-user-create { + grid-template-columns: 1fr; + } + .admin-password-reset { + flex-direction: column; + align-items: stretch; + } +} + /* =================================================================== * Calibration → Profile v2 — metrics-first 2x2 layout * =================================================================== */ diff --git a/gently/ui/web/static/js/admin-users.js b/gently/ui/web/static/js/admin-users.js new file mode 100644 index 00000000..4355d4b0 --- /dev/null +++ b/gently/ui/web/static/js/admin-users.js @@ -0,0 +1,181 @@ +(function() { + const app = document.getElementById('admin-users-app'); + const tbody = document.getElementById('admin-users-tbody'); + const form = document.getElementById('admin-user-create'); + const statusEl = document.getElementById('admin-users-status'); + const currentUser = app ? app.dataset.currentUser : ''; + + function setStatus(text, tone) { + if (!statusEl) return; + statusEl.textContent = text || ''; + statusEl.dataset.tone = tone || ''; + } + + async function requestJson(url, options) { + const response = await fetch(url, Object.assign({ + headers: { 'Content-Type': 'application/json' }, + }, options || {})); + const data = await response.json().catch(() => ({})); + if (!response.ok || data.error) { + throw new Error(data.error || `Request failed: ${response.status}`); + } + return data; + } + + function roleSelect(user) { + const select = document.createElement('select'); + select.className = 'settings-input admin-role-select'; + ['viewer', 'operator', 'admin'].forEach(role => { + const option = document.createElement('option'); + option.value = role; + option.textContent = role; + option.selected = role === user.role; + select.appendChild(option); + }); + select.addEventListener('change', async () => { + setStatus('Saving...', ''); + try { + await requestJson(`/api/auth/users/${encodeURIComponent(user.username)}`, { + method: 'PATCH', + body: JSON.stringify({ role: select.value }), + }); + setStatus('Role saved', 'ok'); + await loadUsers(); + } catch (error) { + select.value = user.role; + setStatus(error.message, 'error'); + } + }); + return select; + } + + function passwordReset(user) { + const wrap = document.createElement('div'); + wrap.className = 'admin-password-reset'; + + const input = document.createElement('input'); + input.className = 'settings-input'; + input.type = 'password'; + input.placeholder = 'New password'; + input.autocomplete = 'new-password'; + + const button = document.createElement('button'); + button.className = 'admin-secondary-btn'; + button.type = 'button'; + button.textContent = 'Reset'; + button.addEventListener('click', async () => { + if (!input.value) { + setStatus('Password required', 'error'); + return; + } + setStatus('Saving...', ''); + try { + await requestJson(`/api/auth/users/${encodeURIComponent(user.username)}`, { + method: 'PATCH', + body: JSON.stringify({ password: input.value }), + }); + input.value = ''; + setStatus('Password reset', 'ok'); + } catch (error) { + setStatus(error.message, 'error'); + } + }); + + wrap.append(input, button); + return wrap; + } + + function deleteButton(user) { + const button = document.createElement('button'); + button.className = 'admin-danger-btn'; + button.type = 'button'; + button.textContent = 'Delete'; + button.disabled = user.username === currentUser; + button.title = button.disabled ? 'Cannot delete the current signed-in user' : 'Delete user'; + button.addEventListener('click', async () => { + if (!window.confirm(`Delete user "${user.username}"?`)) return; + setStatus('Deleting...', ''); + try { + await requestJson(`/api/auth/users/${encodeURIComponent(user.username)}`, { + method: 'DELETE', + }); + setStatus('User deleted', 'ok'); + await loadUsers(); + } catch (error) { + setStatus(error.message, 'error'); + } + }); + return button; + } + + function renderUsers(users) { + tbody.innerHTML = ''; + if (!users.length) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = 4; + cell.className = 'admin-users-empty'; + cell.textContent = 'No users'; + row.appendChild(cell); + tbody.appendChild(row); + return; + } + + users.forEach(user => { + const row = document.createElement('tr'); + + const nameCell = document.createElement('td'); + nameCell.className = 'admin-users-name'; + nameCell.textContent = user.username; + if (user.username === currentUser) { + const badge = document.createElement('span'); + badge.className = 'admin-current-user'; + badge.textContent = 'current'; + nameCell.appendChild(badge); + } + + const roleCell = document.createElement('td'); + roleCell.appendChild(roleSelect(user)); + + const passwordCell = document.createElement('td'); + passwordCell.appendChild(passwordReset(user)); + + const actionsCell = document.createElement('td'); + actionsCell.className = 'admin-users-actions'; + actionsCell.appendChild(deleteButton(user)); + + row.append(nameCell, roleCell, passwordCell, actionsCell); + tbody.appendChild(row); + }); + } + + async function loadUsers() { + try { + const data = await requestJson('/api/auth/users'); + renderUsers(data.users || []); + } catch (error) { + setStatus(error.message, 'error'); + } + } + + if (form) { + form.addEventListener('submit', async event => { + event.preventDefault(); + const payload = Object.fromEntries(new FormData(form).entries()); + setStatus('Creating...', ''); + try { + await requestJson('/api/auth/users', { + method: 'POST', + body: JSON.stringify(payload), + }); + form.reset(); + setStatus('User created', 'ok'); + await loadUsers(); + } catch (error) { + setStatus(error.message, 'error'); + } + }); + } + + document.addEventListener('DOMContentLoaded', loadUsers); +})(); diff --git a/gently/ui/web/templates/_header.html b/gently/ui/web/templates/_header.html index 3f113590..ccd8ea21 100644 --- a/gently/ui/web/templates/_header.html +++ b/gently/ui/web/templates/_header.html @@ -37,6 +37,15 @@ {% endif %} + + @@ -97,4 +106,13 @@ localStorage.setItem('gently-theme', next); }); })(); + (function() { + const link = document.getElementById('admin-users-link'); + if (!link) return; + fetch('/api/auth/me').then(r => r.json()).then(me => { + if (me && me.authenticated && me.role === 'admin') { + link.hidden = false; + } + }).catch(() => {}); + })(); diff --git a/gently/ui/web/templates/admin_users.html b/gently/ui/web/templates/admin_users.html new file mode 100644 index 00000000..8d4994ff --- /dev/null +++ b/gently/ui/web/templates/admin_users.html @@ -0,0 +1,70 @@ + + + + + + Gently - Users + + + + + + + {% include '_header.html' %} + +
+
+
+
+

User accounts

+

Viewer, operator, and admin access for this Gently instance.

+
+ +
+ +
+ + + + +
+ +
+ + + + + + + + + + + + + + +
UserRolePassword reset
Loading...
+
+
+
+ + + + diff --git a/gently/ui/web/upload_validation.py b/gently/ui/web/upload_validation.py new file mode 100644 index 00000000..57f37a52 --- /dev/null +++ b/gently/ui/web/upload_validation.py @@ -0,0 +1,59 @@ +"""Validation helpers for HTTP array upload routes.""" + +from __future__ import annotations + +import base64 +import binascii +import math +from typing import Iterable + +import numpy as np +from fastapi import HTTPException + + +def decode_array_payload( + encoded: str, + shape: Iterable[int], + dtype_name: str, + *, + max_nbytes: int, + label: str, +) -> np.ndarray: + """Decode a base64 array after validating shape, dtype, and byte count.""" + if not isinstance(encoded, str) or not encoded: + raise HTTPException(status_code=400, detail=f"{label} payload must be base64 text") + if isinstance(shape, (str, bytes)) or not isinstance(shape, Iterable): + raise HTTPException(status_code=400, detail=f"{label} shape must be a list of dimensions") + + try: + shape_tuple = tuple(int(dim) for dim in shape) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail=f"{label} shape must contain integers") + if not shape_tuple or len(shape_tuple) > 4 or any(dim <= 0 for dim in shape_tuple): + raise HTTPException(status_code=400, detail=f"{label} shape must have 1-4 positive dimensions") + + try: + dtype = np.dtype(dtype_name) + except TypeError: + raise HTTPException(status_code=400, detail=f"{label} dtype is not supported") + if dtype.hasobject: + raise HTTPException(status_code=400, detail=f"{label} dtype may not contain Python objects") + + expected_nbytes = math.prod(shape_tuple) * dtype.itemsize + if expected_nbytes > max_nbytes: + raise HTTPException( + status_code=413, + detail=f"{label} payload is too large ({expected_nbytes} bytes > {max_nbytes} bytes)", + ) + + try: + raw = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError): + raise HTTPException(status_code=400, detail=f"{label} payload is not valid base64") + if len(raw) != expected_nbytes: + raise HTTPException( + status_code=400, + detail=f"{label} byte length {len(raw)} does not match shape/dtype {expected_nbytes}", + ) + + return np.frombuffer(raw, dtype=dtype).reshape(shape_tuple) diff --git a/launch_gently.py b/launch_gently.py index a2e590fa..df500443 100644 --- a/launch_gently.py +++ b/launch_gently.py @@ -21,6 +21,7 @@ """ import asyncio +import getpass import json import logging import os @@ -35,7 +36,6 @@ from gently.log_config import configure_logging from gently.core.log_bridge import configure_log_bridge -from gently.app.agent import MicroscopyAgent from gently.organisms import load_organism from gently.hardware import load_hardware, get_hardware from gently.settings import settings @@ -95,6 +95,62 @@ def list_sessions(store: FileStore): print("Use: python launch_gently.py --resume ") +def _manage_users(args) -> int: + """Run account-management commands without starting the web server.""" + from gently.ui.web.accounts import AccountStore, ROLES + + store = AccountStore(settings.storage.base_path / "auth") + + if args.users: + users = store.list_users() + if not users: + print("No web UI users configured.") + return 0 + print("Web UI users") + print("-" * 30) + for user in users: + print(f" {user['username']}\t{user['role']}") + return 0 + + if args.add_user: + password = args.password or getpass.getpass(f"Password for {args.add_user}: ") + store.create_user(args.add_user, password, role=args.role) + print(f"Created user {args.add_user} ({args.role}).") + return 0 + + if args.reset_password: + password = args.password or getpass.getpass(f"New password for {args.reset_password}: ") + store.reset_password(args.reset_password, password) + print(f"Reset password for {args.reset_password}.") + return 0 + + if args.set_role: + username, role = args.set_role + if role not in ROLES: + print(f"Error: role must be one of {', '.join(ROLES)}") + return 2 + store.set_role(username, role) + print(f"Set {username} role to {role}.") + return 0 + + if args.delete_user: + store.delete_user(args.delete_user) + print(f"Deleted user {args.delete_user}.") + return 0 + + return 1 + + +def _has_user_management_command(args) -> bool: + return any([ + args.users, + args.add_user, + args.reset_password, + args.set_role, + args.delete_user, + ]) + + def _print_banner(viz_url, device_connected, offline, storage_dir, log_file, resumed): """Print a human-readable launch banner to the terminal. @@ -235,6 +291,14 @@ async def main(offline: bool = False, resume_session: str = None, show_sessions: store = FileStore(storage_dir) # ── Accounts / auth ─────────────────────────────────────────── + # Handle --sessions (just list and exit) + if show_sessions: + list_sessions(store) + store.close() + return + + from gently.app.agent import MicroscopyAgent + # Self-managed user accounts gate microscope control on the LAN. On first # run we bootstrap an admin and print its one-time password in the banner. # Set GENTLY_NO_AUTH=1 to disable accounts (legacy localhost-control mode). @@ -248,12 +312,6 @@ async def main(offline: bool = False, resume_session: str = None, show_sessions: except Exception as e: logger.error("Account store init failed (continuing without auth): %s", e) - # Handle --sessions (just list and exit) - if show_sessions: - list_sessions(store) - store.close() - return - # Web-only: the TUI is retired. The browser is the control surface and # the launcher just starts the server — no Node/dist requirement. @@ -509,7 +567,7 @@ def _status_provider(): print(" First-run admin account created — sign in at the URL above:") print(f" username: {_u}") print(f" password: {_p}") - print(" (Save this now. Add users via the admin API; GENTLY_NO_AUTH=1 disables auth.)\n") + print(" (Save this now. Add users with --add-user or the admin API; GENTLY_NO_AUTH=1 disables auth.)\n") if viz_url and not no_browser: _open_browser(viz_url) @@ -562,11 +620,6 @@ def _sig(*_a): def cli_main(): """Sync entry point for ``gently`` console script (pyproject.toml).""" - if not os.getenv("ANTHROPIC_API_KEY"): - print("Error: ANTHROPIC_API_KEY not set") - print("Set with: set ANTHROPIC_API_KEY=your-key") - exit(1) - parser = argparse.ArgumentParser(description="Launch Microscopy Agent") parser.add_argument("--offline", action="store_true", help="Run without server connections") parser.add_argument("--sessions", action="store_true", help="List available sessions and exit") @@ -575,8 +628,29 @@ def cli_main(): parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose (INFO) logging") parser.add_argument("--debug", action="store_true", help="Enable debug logging (most verbose)") parser.add_argument("--no-browser", action="store_true", help="Do not auto-open the web UI in a browser") + parser.add_argument("--users", action="store_true", help="List web UI users and exit") + parser.add_argument("--add-user", metavar="USERNAME", help="Create a web UI user and exit") + parser.add_argument("--reset-password", metavar="USERNAME", help="Reset a web UI user's password and exit") + parser.add_argument("--set-role", nargs=2, metavar=("USERNAME", "ROLE"), + help="Set a web UI user's role: viewer, operator, or admin") + parser.add_argument("--delete-user", metavar="USERNAME", help="Delete a web UI user and exit") + parser.add_argument("--role", default="viewer", choices=("viewer", "operator", "admin"), + help="Role for --add-user (default: viewer)") + parser.add_argument("--password", help="Password for --add-user or --reset-password") args = parser.parse_args() + if _has_user_management_command(args): + try: + sys.exit(_manage_users(args)) + except ValueError as e: + print(f"Error: {e}") + sys.exit(2) + + if not args.sessions and not os.getenv("ANTHROPIC_API_KEY"): + print("Error: ANTHROPIC_API_KEY not set") + print("Set with: set ANTHROPIC_API_KEY=your-key") + exit(1) + log_level = "WARNING" if args.verbose: log_level = "INFO" diff --git a/tests/test_campaign_coordination.py b/tests/test_campaign_coordination.py index 18ee49e2..49737fc4 100644 --- a/tests/test_campaign_coordination.py +++ b/tests/test_campaign_coordination.py @@ -15,6 +15,14 @@ import urllib.request import urllib.error +if "pytest" in sys.modules and os.environ.get("GENTLY_RUN_LIVE_CAMPAIGN_TESTS") != "1": + import pytest + + pytest.skip( + "live campaign coordination script requires a running Gently server", + allow_module_level=True, + ) + BASE = os.environ.get("GENTLY_URL", "http://localhost:8080") FAKE_PEER = "test-peer-001" FAKE_HOST = "test-machine" diff --git a/tests/test_dispim_device_safety.py b/tests/test_dispim_device_safety.py index 40fab85a..9ca67e86 100644 --- a/tests/test_dispim_device_safety.py +++ b/tests/test_dispim_device_safety.py @@ -27,7 +27,7 @@ def __init__(self): self._configs = {} # group -> current_config self._available_configs = {} # group -> [configs] self._exposure = 10.0 - self._camera_device = None + self._camera_device = "" self._focus_device = None self._circular_buffer = [] self._sequence_running = False @@ -83,6 +83,9 @@ def waitForConfig(self, group, config): def setCameraDevice(self, name): self._camera_device = name + def getCameraDevice(self): + return self._camera_device + def setFocusDevice(self, name): self._focus_device = name @@ -226,9 +229,9 @@ def make_z_stage(core=None, limits=(50.0, 250.0)): return DiSPIMZstage("ZStage", core or make_core(), limits=limits) -def make_xy_stage(core=None, x_limits=(2000.0, 4000.0), y_limits=(-1000.0, 1000.0)): +def make_xy_stage(core=None): from gently.hardware.dispim.devices.stage import DiSPIMXYStage - return DiSPIMXYStage("XYStage", core or make_core(), x_limits=x_limits, y_limits=y_limits) + return DiSPIMXYStage("XYStage", core or make_core()) def make_piezo(core=None, limits=(-200.0, 200.0)): @@ -318,38 +321,40 @@ class TestXYStageBounds: def test_valid_xy_position(self): stage = make_xy_stage() - status = stage.set([3000.0, 0.0]) + x = (stage.x_limits[0] + stage.x_limits[1]) / 2.0 + y = (stage.y_limits[0] + stage.y_limits[1]) / 2.0 + status = stage.set([x, y]) status.wait(timeout=2) - assert stage.core._xy_position == (3000.0, 0.0) + assert stage.core._xy_position == (x, y) def test_x_below_lower_limit(self): stage = make_xy_stage() - status = stage.set([1999.0, 0.0]) - with pytest.raises(ValueError, match="outside limits"): + status = stage.set([stage.x_limits[0] - 1.0, 0.0]) + with pytest.raises(ValueError, match="outside hardware limits"): status.wait(timeout=2) def test_x_above_upper_limit(self): stage = make_xy_stage() - status = stage.set([4001.0, 0.0]) - with pytest.raises(ValueError, match="outside limits"): + status = stage.set([stage.x_limits[1] + 1.0, 0.0]) + with pytest.raises(ValueError, match="outside hardware limits"): status.wait(timeout=2) def test_y_below_lower_limit(self): stage = make_xy_stage() - status = stage.set([3000.0, -1001.0]) - with pytest.raises(ValueError, match="outside limits"): + status = stage.set([0.0, stage.y_limits[0] - 1.0]) + with pytest.raises(ValueError, match="outside hardware limits"): status.wait(timeout=2) def test_y_above_upper_limit(self): stage = make_xy_stage() - status = stage.set([3000.0, 1001.0]) - with pytest.raises(ValueError, match="outside limits"): + status = stage.set([0.0, stage.y_limits[1] + 1.0]) + with pytest.raises(ValueError, match="outside hardware limits"): status.wait(timeout=2) def test_core_not_called_on_invalid_x(self): core = make_core() stage = make_xy_stage(core=core) - stage.set([0.0, 0.0]) # x=0 is below x_limits[0]=2000 + stage.set([stage.x_limits[0] - 1.0, 0.0]) time.sleep(0.1) assert not any(c[0] == 'setXYPosition' for c in core.call_log) diff --git a/tests/test_web_auth_hardening.py b/tests/test_web_auth_hardening.py new file mode 100644 index 00000000..7ea4e675 --- /dev/null +++ b/tests/test_web_auth_hardening.py @@ -0,0 +1,200 @@ +import base64 +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from fastapi.templating import Jinja2Templates + +from gently.ui.web.accounts import AccountStore, set_account_store +from gently.ui.web.auth import Role, SESSION_COOKIE, resolve_role +from gently.ui.web.routes.images import create_router as create_images_router +from gently.ui.web.routes.auth_routes import create_router as create_auth_router +from gently.ui.web.routes.volumes import create_router as create_volumes_router +from gently.ui.web.routes.websocket import _ws_can_control +from gently.ui.web.upload_validation import decode_array_payload + + +TEMPLATES = Jinja2Templates( + directory=str(Path(__file__).parents[1] / "gently" / "ui" / "web" / "templates") +) + + +class _Client: + def __init__(self, host): + self.host = host + + +class _RequestLike: + def __init__(self, host="192.0.2.10", headers=None, cookies=None): + self.client = _Client(host) + self.headers = headers or {} + self.cookies = cookies or {} + + +@pytest.fixture(autouse=True) +def _reset_auth(monkeypatch): + set_account_store(None) + monkeypatch.delenv("GENTLY_CONTROL_TOKEN", raising=False) + yield + set_account_store(None) + + +def test_legacy_websocket_control_requires_token_for_remote(monkeypatch): + remote = _RequestLike() + assert _ws_can_control(remote) is False + + monkeypatch.setenv("GENTLY_CONTROL_TOKEN", "secret") + with_token = _RequestLike(headers={"X-Gently-Token": "secret"}) + assert _ws_can_control(with_token) is True + + +def test_legacy_loopback_keeps_control_without_token(): + assert resolve_role(_RequestLike(host="127.0.0.1")) is Role.CONTROL + + +def test_account_roles_drive_websocket_control(tmp_path): + store = AccountStore(tmp_path / "auth") + store.create_user("viewer", "pw", role="viewer") + store.create_user("operator", "pw", role="operator") + set_account_store(store) + + viewer = _RequestLike(cookies={SESSION_COOKIE: store.issue_session("viewer")}) + operator = _RequestLike(cookies={SESSION_COOKIE: store.issue_session("operator")}) + + assert _ws_can_control(viewer) is False + assert _ws_can_control(operator) is True + + +def test_account_store_manages_roles_passwords_and_deletes(tmp_path): + store = AccountStore(tmp_path / "auth") + store.create_user("admin", "pw", role="admin") + store.create_user("viewer", "pw", role="viewer") + + store.set_role("viewer", "operator") + store.reset_password("viewer", "new-pw") + assert store.get_role("viewer") == "operator" + assert store.verify_password("viewer", "new-pw") == "operator" + + store.delete_user("viewer") + assert store.get_role("viewer") is None + + with pytest.raises(ValueError, match="last admin"): + store.delete_user("admin") + + +def test_account_store_rejects_duplicate_users(tmp_path): + store = AccountStore(tmp_path / "auth") + store.create_user("admin", "pw", role="admin") + + with pytest.raises(ValueError, match="already exists"): + store.create_user("admin", "new-pw", role="viewer") + + assert store.verify_password("admin", "pw") == "admin" + assert store.verify_password("admin", "new-pw") is None + + +def test_admin_api_lists_updates_and_deletes_users(tmp_path): + store = AccountStore(tmp_path / "auth") + store.create_user("admin", "pw", role="admin") + store.create_user("viewer", "pw", role="viewer") + set_account_store(store) + + app = FastAPI() + app.include_router(create_auth_router(SimpleNamespace(templates=None))) + client = TestClient(app) + client.cookies.set(SESSION_COOKIE, store.issue_session("admin")) + + resp = client.get("/api/auth/users") + assert resp.status_code == 200 + assert {u["username"] for u in resp.json()["users"]} == {"admin", "viewer"} + + resp = client.patch( + "/api/auth/users/viewer", + json={"role": "operator", "password": "new-pw"}, + ) + assert resp.status_code == 200 + assert store.get_role("viewer") == "operator" + assert store.verify_password("viewer", "new-pw") == "operator" + + resp = client.delete("/api/auth/users/viewer") + assert resp.status_code == 200 + assert store.get_role("viewer") is None + + +def test_admin_users_page_requires_admin_account(tmp_path): + store = AccountStore(tmp_path / "auth") + store.create_user("admin", "pw", role="admin") + store.create_user("viewer", "pw", role="viewer") + set_account_store(store) + + app = FastAPI() + app.include_router(create_auth_router(SimpleNamespace(templates=TEMPLATES))) + + anonymous = TestClient(app) + resp = anonymous.get("/admin/users", follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["location"] == "/login" + + viewer = TestClient(app) + viewer.cookies.set(SESSION_COOKIE, store.issue_session("viewer")) + assert viewer.get("/admin/users").status_code == 403 + + admin = TestClient(app) + admin.cookies.set(SESSION_COOKIE, store.issue_session("admin")) + resp = admin.get("/admin/users") + assert resp.status_code == 200 + assert "User accounts" in resp.text + assert "admin-users-app" in resp.text + + +def test_image_push_requires_control(monkeypatch): + app = FastAPI() + pushed = {} + + async def push_image(array, uid, data_type, metadata): + pushed["shape"] = array.shape + pushed["uid"] = uid + + server = SimpleNamespace(push_image=push_image) + app.include_router(create_images_router(server)) + client = TestClient(app) + + arr = np.array([[1, 2], [3, 4]], dtype=np.uint8) + payload = { + "uid": "img-1", + "shape": list(arr.shape), + "dtype": str(arr.dtype), + "image_b64": base64.b64encode(arr.tobytes()).decode("ascii"), + } + + assert client.post("/api/images", json=payload).status_code == 403 + + monkeypatch.setenv("GENTLY_CONTROL_TOKEN", "secret") + resp = client.post("/api/images", json=payload, headers={"X-Gently-Token": "secret"}) + assert resp.status_code == 200 + assert pushed == {"shape": (2, 2), "uid": "img-1"} + + +def test_volume_push_requires_control(): + app = FastAPI() + app.include_router(create_volumes_router(SimpleNamespace())) + client = TestClient(app) + + assert client.post("/api/volumes3d", json={}).status_code == 403 + + +def test_decode_array_payload_rejects_oversize_shape(): + raw = base64.b64encode(b"\x00").decode("ascii") + with pytest.raises(HTTPException) as exc: + decode_array_payload(raw, [8], "uint8", max_nbytes=4, label="image") + assert exc.value.status_code == 413 + + +def test_decode_array_payload_rejects_shape_mismatch(): + raw = base64.b64encode(b"\x00").decode("ascii") + with pytest.raises(HTTPException) as exc: + decode_array_payload(raw, [2], "uint8", max_nbytes=4, label="image") + assert exc.value.status_code == 400