Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions gently/harness/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,47 @@
logger = logging.getLogger(__name__)


_TEXT_TOOL_CALL_RE = re.compile(r"<tool_call>\s*(.*?)\s*</tool_call>", 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.

Expand Down
20 changes: 17 additions & 3 deletions gently/mesh/mesh_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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):
Expand Down Expand Up @@ -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
# ------------------------------------------------------------------
Expand All @@ -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")

# ------------------------------------------------------------------
Expand Down
39 changes: 39 additions & 0 deletions gently/ui/web/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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())
Expand Down
6 changes: 3 additions & 3 deletions gently/ui/web/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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.
Expand All @@ -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
Expand Down
24 changes: 7 additions & 17 deletions gently/ui/web/routes/agent_ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
82 changes: 76 additions & 6 deletions gently/ui/web/routes/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,39 @@ 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()
if store is None or not store.has_users():
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):
Expand Down Expand Up @@ -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:
Expand All @@ -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
22 changes: 15 additions & 7 deletions gently/ui/web/routes/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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))
Expand Down
Loading