feat: register local agent project workspaces

This commit is contained in:
Hamza Ayed
2026-10-04 01:57:44 +03:00
parent d29275043a
commit 1feeb372a4
12 changed files with 806 additions and 45 deletions
+51
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import os
import hashlib
import sqlite3
import os
from uuid import UUID
from pathlib import Path
from contextlib import contextmanager
@@ -142,6 +143,15 @@ def initialize_database() -> None:
);
CREATE INDEX IF NOT EXISTS idx_agent_audit_created
ON agent_audit_events(created_at DESC);
CREATE TABLE IF NOT EXISTS user_workspace_roots (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(user_id, path)
);
CREATE INDEX IF NOT EXISTS idx_workspace_roots_user
ON user_workspace_roots(user_id);
"""
)
message_columns = {
@@ -171,6 +181,47 @@ def ensure_user(user_id: str) -> None:
connection.execute("INSERT OR IGNORE INTO users(id) VALUES (?)", (user_id,))
def register_workspace_root(user_id: str, path: str) -> None:
with _connect() as connection:
connection.execute("INSERT OR IGNORE INTO users(id) VALUES (?)", (user_id,))
candidate = os.path.normcase(os.path.abspath(path))
existing_roots = connection.execute(
"SELECT path FROM user_workspace_roots WHERE user_id <> ?", (user_id,)
).fetchall()
for row in existing_roots:
existing = os.path.normcase(os.path.abspath(str(row["path"])))
try:
common = os.path.commonpath((candidate, existing))
except ValueError:
continue
if common in (candidate, existing):
raise ValueError(
"مجلد المشروع يتداخل مع مجلد مسجل لحساب آخر؛ اختر مجلدًا منفصلًا."
)
connection.execute(
"INSERT OR IGNORE INTO user_workspace_roots(user_id, path) VALUES (?, ?)",
(user_id, path),
)
def list_workspace_roots(user_id: str) -> list[str]:
with _connect() as connection:
rows = connection.execute(
"SELECT path FROM user_workspace_roots WHERE user_id = ? ORDER BY created_at, path",
(user_id,),
).fetchall()
return [str(row["path"]) for row in rows]
def unregister_workspace_root(user_id: str, path: str) -> bool:
with _connect() as connection:
cursor = connection.execute(
"DELETE FROM user_workspace_roots WHERE user_id = ? AND path = ?",
(user_id, path),
)
return cursor.rowcount > 0
def list_conversations(user_id: str) -> list[dict[str, Any]]:
with _connect() as connection:
rows = connection.execute(
+48
View File
@@ -244,6 +244,54 @@ class WorkspaceFilesRequest(BaseModel):
workspace_path: str = Field(min_length=1, max_length=2048)
def _is_loopback_request(request: Request) -> bool:
client_host = request.client.host if request.client is not None else ""
try:
return ipaddress.ip_address(client_host).is_loopback
except ValueError:
return False
@app.post("/v1/agent/projects", status_code=201)
async def register_agent_project(
request: WorkspaceFilesRequest,
http_request: Request,
user_id: str = Depends(get_authenticated_user_id),
) -> dict[str, Any]:
"""Register a project folder explicitly selected by this local desktop user."""
if not _is_loopback_request(http_request):
raise HTTPException(status_code=403, detail="تسجيل مجلدات المشاريع متاح من هذا الجهاز فقط.")
try:
root = workspace.validate_workspace_registration(request.workspace_path)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
try:
database.register_workspace_root(user_id, str(root))
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
files = [path.relative_to(root).as_posix() for path in workspace.list_knowledge_files(root)]
return {"path": str(root), "name": root.name, "files": files, "limit": workspace.MAX_SCAN_FILES}
@app.delete("/v1/agent/projects")
async def unregister_agent_project(
request: WorkspaceFilesRequest,
http_request: Request,
user_id: str = Depends(get_authenticated_user_id),
) -> dict[str, Any]:
"""Revoke this user's explicit local project-folder registration."""
if not _is_loopback_request(http_request):
raise HTTPException(status_code=403, detail="إدارة تسجيل المشاريع متاحة من هذا الجهاز فقط.")
try:
root = workspace.validate_workspace_registration(
request.workspace_path, must_exist=False
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
removed = database.unregister_workspace_root(user_id, str(root))
return {"path": str(root), "removed": removed}
class KnowledgeIndexRequest(BaseModel):
workspace_path: str = Field(min_length=1, max_length=2048)
files: list[str] = Field(min_length=1, max_length=20)
+41 -3
View File
@@ -64,8 +64,26 @@ def _roots_for_user(user_id: str | None) -> tuple[Path, ...]:
roots = configured_roots()
from app import auth, database
registered_roots: list[Path] = []
if user_id is not None:
for value in database.list_workspace_roots(user_id):
try:
root = Path(value).expanduser().resolve(strict=True)
except (OSError, RuntimeError):
continue
if root.is_dir() and root not in registered_roots:
registered_roots.append(root)
def combine(*groups: tuple[Path, ...] | list[Path]) -> tuple[Path, ...]:
combined: list[Path] = []
for group in groups:
for root in group:
if root not in combined:
combined.append(root)
return tuple(combined)
if user_id is None or user_id == database.LOCAL_USER_ID:
return roots
return combine(roots, registered_roots)
email = auth.account_email(user_id)
if email is None:
@@ -110,9 +128,29 @@ def _roots_for_user(user_id: str | None) -> tuple[Path, ...]:
if first_root == second_root or first_root in second_root.parents or second_root in first_root.parents:
raise ValueError("جذور مساحة العمل لحسابين مختلفين متداخلة في إعداد الخادم.")
assigned = resolved_by_email.get(email.casefold())
if not assigned:
if not assigned and not registered_roots:
raise WorkspaceAccessDenied("لم يخصص مسؤول الخادم مساحة عمل لهذا الحساب.")
return assigned
# For authenticated accounts, the global roots are validation boundaries,
# not grants. Keep account access limited to its explicit assignment plus
# folders that account registered from its local desktop.
return combine(assigned or (), registered_roots)
def validate_workspace_registration(value: str, *, must_exist: bool = True) -> Path:
"""Resolve a directory explicitly selected in the local desktop app."""
if not value.strip():
raise ValueError("اختر مجلد مشروع صالحًا.")
try:
root = Path(value).expanduser().resolve(strict=must_exist)
except (OSError, RuntimeError) as exc:
raise ValueError("مجلد المشروع غير موجود أو غير متاح.") from exc
if must_exist and not root.is_dir():
raise ValueError("يجب اختيار مجلد صالح للمشروع.")
if root == Path(root.anchor):
raise ValueError("اختر مجلد مشروع محددًا، وليس جذر القرص.")
if root.name.startswith(".") or root.name in IGNORED_PARTS:
raise ValueError("لا يمكن تسجيل مجلد مخفي أو مستثنى كمشروع.")
return root
def selected_root(value: str | None, *, user_id: str | None = None) -> Path | None: