feat: register local agent project workspaces
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user