diff --git a/SovereignAI-Starter/ROADMAP.md b/SovereignAI-Starter/ROADMAP.md index 2f0b429..9fe6d2d 100644 --- a/SovereignAI-Starter/ROADMAP.md +++ b/SovereignAI-Starter/ROADMAP.md @@ -4,7 +4,9 @@ ## الحالة الحالية — 2026-10-04 -- تحقق واجهة تسجيل المشاريع على Windows: كانت العملية المفتوحة تستخدم ملف Debug أقدم من مصدر Flutter، لذلك لم يظهر زر «تسجيل مشروع جديد». اختبارات الواجهة 12/12 واختبارات حالة حفظ/فتح/إزالة المشاريع 7/7، و`flutter analyze` بلا ملاحظات. أُعيد بناء Windows Debug من المصدر الحالي وشُغّل التطبيق من الملف الناتج؛ بقي FastAPI على `127.0.0.1:8000` بحالة `ok` وGemma 4 E2B. سجل «مشاريعي» يحفظ مسارات المجلدات محليًا؛ لا يعني ذلك بعدُ تنفيذ أوامر المشروع، وهو ما يظل مشروطًا بعزل نظام التشغيل. +- 2026-10-04 — تسجيل المشاريع في الواجهة والـAPI: عند اختيار مجلد من Flutter يُسجّل API المحلي المسار في SQLite تحت هوية الحساب (`user_workspace_roots`) ثم يسرد الملفات المدعومة؛ عند إزالة المشروع يُلغى التسجيل ولا تُحذف الملفات. أضيف POST/DELETE لـ`/v1/agent/projects` ويُسمح بهما عبر loopback فقط. اختبارات API تثبت التسجيل والقراءة لصاحب المشروع، رفض الحساب الآخر، منع تسجيل مجلد متداخل بين حسابين (409)، وإلغاء الوصول بعد الحذف أو بعد حذف المجلد الأصلي. اختبار حي على مجلد خارج جذر المنتج قرأ `README.md`، وسأل Gemma عبر الوكيل فأجاب من المحتوى باسم `Cedar`، ثم تحقق رفض الوصول بعد الإلغاء. اختبارات Python ذات الصلة 9/9 وFlutter 17/17، و`flutter analyze` بلا ملاحظات و`compileall` ناجح. أُعيد تشغيل API وفحص `/health` (`ok`, Gemma 4)، وتأكد وجود مساري POST/DELETE في OpenAPI، وبُني وشُغّل Windows Debug الحالي (PID 1328). تشغيل أوامر البناء/الاختبار من الوكيل لم يُدمج بعد؛ عزل نظام التشغيل لهذا التشغيل ما زال مفتوحًا. + +- اختبار سقف التخزين: نجح `scripts/verify_vhd_disk_quota.ps1` بقرص VHDX مؤقت 64MiB؛ كتب 53,477,376 بايت حتى `ERROR_DISK_FULL` (112)، بقي 151,552 بايت حرًا، وحُذف قرص الاختبار بعد التحقق. سكربت القرص الدائم 1GiB اجتاز تحليل PowerShell بعد إصلاحه، لكن `Status` أكد عدم وجود القرص؛ محاولة الإعداد عبر Windows PowerShell وPowerShell 7 انتهت بخطأ بدء `0xc0000142` قبل أي تعديل. لذلك لا يوجد حتى الآن VHDX دائم، ولا يُسمح للوكيل بتنفيذ أوامر. - متابعة التقييم على ملفات المشروع الفعلية: أضيفت مرادفات `permission/permissions/صلاحية/صلاحيات` لرموز صلاحيات الأدوات، فأصبح المقطع العميق `tool_name not in selected_skill.allowed_tools` يظهر ضمن نتائج السؤال الذي كان يفشل سابقًا؛ تقييم الاسترجاع 9/9 أدلة (`evals/results/retrieval_project_permission_synonym_2026-10-03.json`). هذا يقيس العثور على المصدر لا جودة صياغة Gemma. أضيف حدّ سياق لأربع مقاطع وإزالة التكرار وتوزيع المقاطع على ملفات مختلفة. في 2026-10-03 أُعيد تشغيل FastAPI من الشفرة الحالية بعد اكتشاف أن الخدمة الحية كانت أقدم من تعديل السياق. على الخدمة الجديدة، أعاد التقييم الكامل 11/11 ملفًا مستهدفًا في المرتبة الأولى ووجد الدليل 11/11 (`evals/results/retrieval_project_agent_fresh_api_2026-10-03.json`). مراجعة يدوية أولية لإجابات الجولة وجدت 9/11 إجابات مباشرة؛ سؤال ترحيل الإجابات وسؤال توجيه الصور امتنعا رغم وجود الدليل، لكن إعادة كل حالة منفردة أجابت عنها مع اقتباس المصدر (`evals/results/retrieval_project_agent_migration_retry_2026-10-03.json` و`evals/results/retrieval_project_agent_routing_retry_2026-10-03.json`). هذا يكشف تذبذب جودة التوليد، ولا يعادل درجات مستقلة؛ مراجعة بشرية أوسع ما زالت مفتوحة. اختبارات Flutter الكاملة الآن 16/16، واختبارات Python الكاملة عبر `unittest` الآن 89/89 (2026-10-03). فحص `compileall` و`git diff --check` ناجحان. @@ -77,7 +79,7 @@ - [x] ربط CRUD المحادثات والتقييم بهوية الجلسة، والتحقق من أن حسابًا ثانيًا لا يقرأ محادثة الحساب الأول. - [x] فرض Bearer على جميع عمليات `/v1` الخاصة وإرسال الجلسة من Flutter للمحادثة/الوكيل/الملفات/المعرفة/البحث والصوت؛ فحص OpenAPI يضمن ألا توجد عملية خاصة بلا HTTP Bearer. - [x] عزل فهرس المعرفة وسجل التدقيق بمعرّف الحساب، وربط رمز معاينة تعديل الملفات بصاحبها؛ اختبار API أثبت عدم استرجاع حساب لمحتوى فهرسه حساب آخر. -- [x] ربط جذور مساحة العمل بالحساب عبر إعداد مسؤول الخدمة `SOVEREIGNAI_USER_WORKSPACES` (بريد الحساب ← مسارات مخصصة تحت القائمة العامة)؛ يرفض API الحساب الذي لا يملك تخصيصًا، ويرفض المسارات المتداخلة بين الحسابات. اختبار API أكد أن لكل حساب جذره فقط (2026-10-03). +- [x] ربط جذور مساحة العمل بالحساب عبر إعداد مسؤول الخدمة `SOVEREIGNAI_USER_WORKSPACES` (بريد الحساب ← مسارات مخصصة تحت القائمة العامة)، وإتاحة تسجيل مجلدات اختارها المستخدم صراحةً من Flutter في SQLite المحلي. جذور الخدمة العامة حدود تحقق وليست سماحًا تلقائيًا لكل حساب؛ التسجيل المحلي يقبل loopback فقط، ويمنع تداخل مجلدين مسجلين لحسابين مختلفين. اختبار API أكد تسجيل/قراءة المجلد لصاحبه، رفض حساب آخر، رفض الجذر المتداخل، وإلغاء الوصول بعد إزالة المشروع (2026-10-04). - [x] حاجز loopback داخل FastAPI: يرفض كل طلب peer ليس `127.0.0.1` أو `::1` بحالة 403 ومعرّف طلب، حتى لو رُبط Uvicorn خطأً على عنوان عام. اختبارات العميل المحلي/البعيد والمصادقة والمعرفة نجحت (23 اختبارًا). هذا يحد الوصول الشبكي على مستوى API لكنه لا يعزل ملفات العملية. - [ ] عزل عملية FastAPI عن ملفات المضيف غير المصرح بها على مستوى نظام التشغيل قبل السماح بعميل شبكي أو خدمة مستضافة؛ القائمة البرمجية وحدها لا تحد صلاحيات العملية نفسها. فحص 2026-10-03: Docker وWindows Sandbox غير متاحين، ولم يمكن تأكيد وجود توزيعة WSL (الأمر المتاح يعرض تعليمات فقط)؛ فحص ميزات Windows يتطلب صلاحية مسؤول. حاجز loopback في FastAPI يخفف الخطر محليًا لكنه لا يحقق عزل الملفات. تجربة AppContainer لم تثبت حد مساحة قرص موثوقًا، وطلب إنشاء VHDX أُلغي عند UAC دون تغيير أقراص الجهاز. إعادة فحص 2026-10-03 أكدت أن جلسة Windows الحالية ليست Administrator، وأن تشغيل `diskpart /?` فشل بـ`0xc0000142` قبل أي تعديل؛ أظهر فحص الأقراص C: وقسم NTFS للنظام فقط، ولم ينشأ VHDX. إعادة فحص قراءة فقط 2026-10-04: `wsl --status` و`wsl --list --verbose` أعادا شاشة الاستخدام بدل حالة توزيعة، و`fsutil quota query C:` أعاد `Access is denied`. أظهر `whoami /groups` أن مجموعة Administrators بحالة deny-only ومستوى الجلسة Medium؛ لا تملك جلسة التنفيذ الحالية رمز مسؤول فعليًا. نحتاج بيئة عزل نظامية قابلة للقياس أو جلسة إعداد مسؤول قبل تفعيل أوامر الوكيل. - [x] إضافة قائمة سماح على مستوى الخادم عبر `SOVEREIGNAI_ALLOWED_WORKSPACES`؛ يرفض API أي مجلد خارج الجذور المعتمدة. مع `SOVEREIGNAI_USER_WORKSPACES` يطبق الخادم كذلك حدود جذور كل حساب. لا يغني ذلك عن عزل صلاحيات عملية FastAPI على مستوى نظام التشغيل. diff --git a/SovereignAI-Starter/app/database.py b/SovereignAI-Starter/app/database.py index f4750ab..9d4e491 100644 --- a/SovereignAI-Starter/app/database.py +++ b/SovereignAI-Starter/app/database.py @@ -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( diff --git a/SovereignAI-Starter/app/main.py b/SovereignAI-Starter/app/main.py index 8b83b1f..abe393c 100644 --- a/SovereignAI-Starter/app/main.py +++ b/SovereignAI-Starter/app/main.py @@ -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) diff --git a/SovereignAI-Starter/app/workspace.py b/SovereignAI-Starter/app/workspace.py index 3fa6855..05377f8 100644 --- a/SovereignAI-Starter/app/workspace.py +++ b/SovereignAI-Starter/app/workspace.py @@ -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: diff --git a/SovereignAI-Starter/flutter_app/lib/core/network/api_repository.dart b/SovereignAI-Starter/flutter_app/lib/core/network/api_repository.dart index 5933a13..1dbb7dd 100644 --- a/SovereignAI-Starter/flutter_app/lib/core/network/api_repository.dart +++ b/SovereignAI-Starter/flutter_app/lib/core/network/api_repository.dart @@ -478,6 +478,26 @@ class ApiRepository { return (data['files'] as List).cast(); } + Future> registerWorkspaceProject(String workspacePath) async { + final response = await http.post( + Uri.parse('$_baseUrl/v1/agent/projects'), + headers: await _userHeaders(), + body: jsonEncode({'workspace_path': workspacePath}), + ); + _checkStatus(response); + final data = jsonDecode(response.body) as Map; + return (data['files'] as List).cast(); + } + + Future unregisterWorkspaceProject(String workspacePath) async { + final response = await http.delete( + Uri.parse('$_baseUrl/v1/agent/projects'), + headers: await _userHeaders(), + body: jsonEncode({'workspace_path': workspacePath}), + ); + _checkStatus(response); + } + Future> indexWorkspaceKnowledge( String workspacePath, List files, diff --git a/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_cubit.dart b/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_cubit.dart index 4b3958b..61015c0 100644 --- a/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_cubit.dart +++ b/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_cubit.dart @@ -271,7 +271,7 @@ class ChatCubit extends Cubit { ), ); try { - final files = await _api.listWorkspaceFiles(path); + final files = await _api.registerWorkspaceProject(path); if (isClosed || state.selectedWorkspacePath != path) return const []; final savedPaths = [ ...state.savedWorkspacePaths.where((savedPath) => savedPath != path), @@ -300,9 +300,26 @@ class ChatCubit extends Cubit { } Future removeSavedWorkspace(String path) async { + try { + await _api.unregisterWorkspaceProject(path); + } catch (error) { + if (!isClosed) emit(state.copyWith(error: _readableError(error))); + return false; + } final savedPaths = - state.savedWorkspacePaths.where((savedPath) => savedPath != path).toList(); - emit(state.copyWith(savedWorkspacePaths: List.unmodifiable(savedPaths))); + state.savedWorkspacePaths + .where((savedPath) => savedPath != path) + .toList(); + final isSelected = state.selectedWorkspacePath == path; + emit( + state.copyWith( + savedWorkspacePaths: List.unmodifiable(savedPaths), + isWorkspaceMode: isSelected ? false : null, + clearSelectedWorkspacePath: isSelected, + availableWorkspaceFiles: isSelected ? const [] : null, + selectedWorkspaceFiles: isSelected ? const [] : null, + ), + ); return _persistSettings({'saved_workspace_paths': savedPaths}); } diff --git a/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/pages/chat_page.dart b/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/pages/chat_page.dart index 1412eab..a6d490b 100644 --- a/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/pages/chat_page.dart +++ b/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/pages/chat_page.dart @@ -180,7 +180,7 @@ class _ChatPageState extends State { (dialogContext) => AlertDialog( title: const Text('إزالة المشروع من القائمة؟'), content: Text( - 'سيُزال ${_workspaceName(path)} من قائمة المشاريع المحفوظة فقط. لن تُحذف ملفاته.', + 'سيُزال ${_workspaceName(path)} من قائمة المشاريع، ويُلغى أي تسجيل وصول خاص به. لن تُحذف ملفاته من جهازك.', ), actions: [ TextButton( @@ -698,8 +698,8 @@ class _ChatPageState extends State { content: Text( api.accountSessionPersists ? credentials.$1 - ? 'تم إنشاء الحساب وتخزين الجلسة بأمان.' - : 'تم تسجيل الدخول وتخزين الجلسة بأمان.' + ? 'تم إنشاء الحساب وتخزين الجلسة بأمان.' + : 'تم تسجيل الدخول وتخزين الجلسة بأمان.' : credentials.$1 ? 'تم إنشاء الحساب. جلسة الويب مؤقتة وستنتهي عند إعادة تحميل الصفحة.' : 'تم تسجيل الدخول. جلسة الويب مؤقتة وستنتهي عند إعادة تحميل الصفحة.', @@ -743,10 +743,11 @@ class _ChatPageState extends State { child: const Text('إلغاء'), ), FilledButton( - onPressed: () => Navigator.pop( - dialogContext, - emailController.text.trim(), - ), + onPressed: + () => Navigator.pop( + dialogContext, + emailController.text.trim(), + ), child: const Text('إرسال الرمز'), ), ], @@ -818,7 +819,9 @@ class _ChatPageState extends State { passwordController.text.length < 12) { ScaffoldMessenger.of(dialogContext).showSnackBar( const SnackBar( - content: Text('أدخل رمزًا صالحًا وكلمة مرور من 12 محرفًا على الأقل.'), + content: Text( + 'أدخل رمزًا صالحًا وكلمة مرور من 12 محرفًا على الأقل.', + ), ), ); return; @@ -841,7 +844,9 @@ class _ChatPageState extends State { await api.completePasswordReset(token: reset.$1, newPassword: reset.$2); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('تم تغيير كلمة المرور. سجّل الدخول بها الآن.')), + const SnackBar( + content: Text('تم تغيير كلمة المرور. سجّل الدخول بها الآن.'), + ), ); } catch (error) { if (!mounted) return; @@ -1121,14 +1126,18 @@ class _ChatPageState extends State { return Padding( padding: const EdgeInsets.only(bottom: 3), child: Material( - color: selected - ? const Color(0xFFDCEAE3) - : Colors.transparent, + color: + selected + ? const Color(0xFFDCEAE3) + : Colors.transparent, borderRadius: BorderRadius.circular(10), child: ListTile( dense: true, visualDensity: VisualDensity.compact, - contentPadding: const EdgeInsets.only(left: 3, right: 8), + contentPadding: const EdgeInsets.only( + left: 3, + right: 8, + ), leading: Icon( Icons.folder_open_outlined, size: 18, @@ -1146,9 +1155,10 @@ class _ChatPageState extends State { icon: const Icon(Icons.close, size: 16), onPressed: () => _removeSavedWorkspace(path), ), - onTap: chat.isSending - ? null - : () => _openSavedWorkspace(path), + onTap: + chat.isSending + ? null + : () => _openSavedWorkspace(path), ), ), ); diff --git a/SovereignAI-Starter/flutter_app/test/chat_cubit_test.dart b/SovereignAI-Starter/flutter_app/test/chat_cubit_test.dart index 4bb4ad5..c500931 100644 --- a/SovereignAI-Starter/flutter_app/test/chat_cubit_test.dart +++ b/SovereignAI-Starter/flutter_app/test/chat_cubit_test.dart @@ -17,6 +17,8 @@ class _VersionTestApi extends ApiRepository { String? indexedWorkspace; List indexedFiles = const []; List deletedFiles = const []; + String? registeredProject; + String? unregisteredProject; @override Future getModelName() async => 'gemma4:e2b'; @@ -62,6 +64,17 @@ class _VersionTestApi extends ApiRepository { 'README.md', ]; + @override + Future> registerWorkspaceProject(String workspacePath) async { + registeredProject = workspacePath; + return listWorkspaceFiles(workspacePath); + } + + @override + Future unregisterWorkspaceProject(String workspacePath) async { + unregisteredProject = workspacePath; + } + @override Future> indexWorkspaceKnowledge( String workspacePath, @@ -180,30 +193,50 @@ void main() { await cubit.close(); }); - test('registered project folders persist and can be removed from the list', () async { - final settings = _MemorySettingsStore(); - final firstApi = _VersionTestApi(); - final first = ChatCubit(firstApi, settingsStore: settings); + test( + 'registered project folders persist and can be removed from the list', + () async { + final settings = _MemorySettingsStore(); + final firstApi = _VersionTestApi(); + final first = ChatCubit(firstApi, settingsStore: settings); + await Future.delayed(Duration.zero); + + await first.selectWorkspace(r'C:\Projects\sovereign-ai'); + expect(firstApi.registeredProject, r'C:\Projects\sovereign-ai'); + expect(first.state.savedWorkspacePaths, [r'C:\Projects\sovereign-ai']); + expect(settings.values['saved_workspace_paths'], [ + r'C:\Projects\sovereign-ai', + ]); + await first.close(); + + final restoredApi = _VersionTestApi(); + final restored = ChatCubit(restoredApi, settingsStore: settings); + await Future.delayed(Duration.zero); + expect(restored.state.savedWorkspacePaths, [r'C:\Projects\sovereign-ai']); + + await restored.removeSavedWorkspace(r'C:\Projects\sovereign-ai'); + expect(restoredApi.unregisteredProject, r'C:\Projects\sovereign-ai'); + expect(restored.state.savedWorkspacePaths, isEmpty); + expect(settings.values['saved_workspace_paths'], isEmpty); + await restored.close(); + }, + ); + + test('removing the active project revokes its chat context', () async { + final api = _VersionTestApi(); + final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory()); await Future.delayed(Duration.zero); + const project = r'C:\Projects\active-project'; - await first.selectWorkspace(r'C:\Projects\sovereign-ai'); - expect(first.state.savedWorkspacePaths, [r'C:\Projects\sovereign-ai']); - expect(settings.values['saved_workspace_paths'], [ - r'C:\Projects\sovereign-ai', - ]); - await first.close(); + await cubit.selectWorkspace(project); + expect(cubit.state.isWorkspaceMode, isTrue); + await cubit.removeSavedWorkspace(project); - final restored = ChatCubit( - _VersionTestApi(), - settingsStore: settings, - ); - await Future.delayed(Duration.zero); - expect(restored.state.savedWorkspacePaths, [r'C:\Projects\sovereign-ai']); - - await restored.removeSavedWorkspace(r'C:\Projects\sovereign-ai'); - expect(restored.state.savedWorkspacePaths, isEmpty); - expect(settings.values['saved_workspace_paths'], isEmpty); - await restored.close(); + expect(api.unregisteredProject, project); + expect(cubit.state.isWorkspaceMode, isFalse); + expect(cubit.state.selectedWorkspacePath, isNull); + expect(cubit.state.availableWorkspaceFiles, isEmpty); + await cubit.close(); }); test('regeneration keeps prior answers and selection is persisted', () async { diff --git a/SovereignAI-Starter/flutter_app/test/widget_test.dart b/SovereignAI-Starter/flutter_app/test/widget_test.dart index 5d1d390..7e9db24 100644 --- a/SovereignAI-Starter/flutter_app/test/widget_test.dart +++ b/SovereignAI-Starter/flutter_app/test/widget_test.dart @@ -50,6 +50,13 @@ class _TestApiRepository extends ApiRepository { 'lib/main.dart', ]; + @override + Future> registerWorkspaceProject(String workspacePath) async => + listWorkspaceFiles(workspacePath); + + @override + Future unregisterWorkspaceProject(String workspacePath) async {} + @override Future> listConversations() async => []; } @@ -97,6 +104,13 @@ class _ProposalWidgetApi extends _TestApiRepository { 'src/hello.py', ]; + @override + Future> registerWorkspaceProject(String workspacePath) async => + listWorkspaceFiles(workspacePath); + + @override + Future unregisterWorkspaceProject(String workspacePath) async {} + @override Future runAgent( String task, { diff --git a/SovereignAI-Starter/scripts/setup_execution_sandbox_disk.ps1 b/SovereignAI-Starter/scripts/setup_execution_sandbox_disk.ps1 new file mode 100644 index 0000000..93e59a3 --- /dev/null +++ b/SovereignAI-Starter/scripts/setup_execution_sandbox_disk.ps1 @@ -0,0 +1,235 @@ +param( + [ValidateSet('Setup', 'Mount', 'Status', 'Remove')] + [string]$Action = 'Setup', + [switch]$DirectElevated, + [switch]$ConfirmRemove +) + +$ErrorActionPreference = 'Stop' +$sandboxRoot = Join-Path $env:LOCALAPPDATA 'SovereignAI\agent-sandbox' +$vhdPath = Join-Path $sandboxRoot 'execution.vhdx' +$mountPath = Join-Path $sandboxRoot 'workspace' +$volumeLabel = 'SOVEREIGNAI_EXEC' +$virtualSizeBytes = 1GB +$volumeSizeToleranceBytes = 8MB + +function Test-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Invoke-DiskPartScript([string[]]$Commands) { + $scriptPath = Join-Path $env:TEMP ("SovereignAI-DiskPart-{0}.txt" -f [guid]::NewGuid().ToString('N')) + try { + Set-Content -LiteralPath $scriptPath -Value ($Commands + 'exit') -Encoding ascii + $output = & "$env:WINDIR\System32\diskpart.exe" /s $scriptPath 2>&1 | Out-String + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0 -or $output -match '(?im)^DiskPart has encountered an error') { + throw "DiskPart failed (exit $exitCode): $output" + } + return $output + } finally { + if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { + Remove-Item -LiteralPath $scriptPath -Force + } + } +} + +function Get-ExecutionVolume { + $volumes = @(Get-Volume -FileSystemLabel $volumeLabel -ErrorAction SilentlyContinue) + if ($volumes.Count -gt 1) { throw "More than one volume is labeled $volumeLabel; refusing to choose." } + if ($volumes.Count -eq 0) { return $null } + return $volumes[0] +} + +function Assert-ExecutionVolume($Volume) { + if (-not $Volume) { throw "The expected $volumeLabel volume is not attached." } + if ($Volume.FileSystem -ne 'NTFS') { throw "Expected NTFS but found '$($Volume.FileSystem)'." } + if ($Volume.Size -gt ($virtualSizeBytes + $volumeSizeToleranceBytes) -or + $Volume.Size -lt ($virtualSizeBytes - 32MB)) { + throw "Unexpected sandbox volume size: $($Volume.Size) bytes." + } + $mountedVolumePath = (& "$env:WINDIR\System32\mountvol.exe" $mountPath /L 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or + -not $mountedVolumePath.Equals($Volume.Path.Trim(), [StringComparison]::OrdinalIgnoreCase)) { + throw "The sandbox mount path does not resolve to the expected VHDX volume. mountvol='$mountedVolumePath', volume='$($Volume.Path)'." + } + $diskImage = Get-DiskImage -ImagePath $vhdPath -ErrorAction SilentlyContinue + if (-not $diskImage -or -not $diskImage.Attached) { + throw 'The VHDX backing file is not attached.' + } + $disk = $diskImage | Get-Disk + if ($disk.Size -gt $virtualSizeBytes -or $disk.Size -lt ($virtualSizeBytes - 1MB)) { + throw "The VHDX virtual capacity is unexpected: $($disk.Size) bytes." + } + return [pscustomobject]@{ + vhdx_path = $vhdPath + mount_path = $mountPath + virtual_size_bytes = $disk.Size + filesystem_size_bytes = $Volume.Size + filesystem = $Volume.FileSystem + filesystem_label = $Volume.FileSystemLabel + free_bytes = $Volume.SizeRemaining + attached = $diskImage.Attached + } +} + +function Mount-ExecutionDisk { + if (-not (Test-Path -LiteralPath $vhdPath -PathType Leaf)) { + throw "The sandbox VHDX does not exist: $vhdPath" + } + $volume = Get-ExecutionVolume + if (-not $volume) { + Ensure-MountDirectory + $commands = @( + "select vdisk file=`"$vhdPath`"", + 'attach vdisk', + 'select partition 1', + "assign mount=`"$mountPath`"" + ) + Invoke-DiskPartScript $commands | Out-Null + $volume = Get-ExecutionVolume + } + Assert-ExecutionVolume $volume | Out-Null + return $volume +} + +function Ensure-MountDirectory { + if (-not (Test-Path -LiteralPath $sandboxRoot -PathType Container)) { + New-Item -ItemType Directory -Path $sandboxRoot -Force | Out-Null + } + if (-not (Test-Path -LiteralPath $mountPath -PathType Container)) { + New-Item -ItemType Directory -Path $mountPath | Out-Null + } + $mountItem = Get-Item -LiteralPath $mountPath -Force + if (($mountItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "The sandbox mount directory is unexpectedly a reparse point: $mountPath" + } + if (Get-ChildItem -LiteralPath $mountPath -Force) { + throw "The intended sandbox mount directory is not empty: $mountPath" + } +} + +if ($Action -eq 'Status') { + $volume = Get-ExecutionVolume + if (-not $volume) { + [pscustomobject]@{ configured = (Test-Path -LiteralPath $vhdPath); attached = $false; vhdx_path = $vhdPath; mount_path = $mountPath } | ConvertTo-Json + exit 0 + } + Assert-ExecutionVolume $volume | ConvertTo-Json -Depth 4 + exit 0 +} + +if ($Action -eq 'Remove' -and -not $ConfirmRemove) { + throw 'Removal deletes the dedicated sandbox disk and all data stored on it. Re-run with -ConfirmRemove to proceed.' +} + +if (-not $DirectElevated -and -not (Test-Administrator)) { + $powershell = Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe' + $arguments = @( + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-File', ('"{0}"' -f $MyInvocation.MyCommand.Path), + '-Action', $Action, + '-DirectElevated' + ) + if ($ConfirmRemove) { $arguments += '-ConfirmRemove' } + try { + $child = Start-Process -FilePath $powershell -ArgumentList ($arguments -join ' ') ` + -Verb RunAs -WindowStyle Hidden -PassThru -Wait + } catch { + throw "Windows did not grant the required administrator token: $($_.Exception.Message)" + } + exit $child.ExitCode +} +if (-not (Test-Administrator)) { throw 'This action requires an elevated administrator token.' } + +switch ($Action) { + 'Setup' { + if (Test-Path -LiteralPath $vhdPath -PathType Leaf) { + $volume = Get-ExecutionVolume + if (-not $volume) { + $volume = Mount-ExecutionDisk + } + Assert-ExecutionVolume $volume | ConvertTo-Json -Depth 4 + break + } + + if (Test-Path -LiteralPath $vhdPath) { + throw "A non-file already occupies the expected VHDX path: $vhdPath" + } + if (Get-ExecutionVolume) { + throw "A volume labeled $volumeLabel exists but is not backed by the expected VHDX path." + } + $driveRoot = [System.IO.Path]::GetPathRoot($env:LOCALAPPDATA) + $availableBytes = [System.IO.DriveInfo]::new($driveRoot).AvailableFreeSpace + if ($availableBytes -lt (2 * $virtualSizeBytes)) { + throw "Less than 2 GiB is free on $driveRoot; refusing to create a 1 GiB sandbox disk." + } + Ensure-MountDirectory + + try { + $commands = @( + "create vdisk file=`"$vhdPath`" maximum=1024 type=expandable", + "select vdisk file=`"$vhdPath`"", + 'attach vdisk', + 'create partition primary', + "format fs=ntfs quick label=$volumeLabel", + "assign mount=`"$mountPath`"" + ) + $output = Invoke-DiskPartScript $commands + $volume = Get-ExecutionVolume + Assert-ExecutionVolume $volume | ConvertTo-Json -Depth 4 + Write-Host 'The VHDX has a 1 GiB virtual maximum. Its data volume is mounted only at the dedicated workspace path.' + Write-Host 'Agent command execution remains disabled until the broker and API enforce snapshots, cleanup, and per-run approval.' + } catch { + if (Test-Path -LiteralPath $vhdPath -PathType Leaf) { + try { + $mountItem = Get-Item -LiteralPath $mountPath -Force -ErrorAction SilentlyContinue + if ($mountItem -and ($mountItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + & "$env:WINDIR\System32\mountvol.exe" $mountPath /D | Out-Null + if ($LASTEXITCODE -ne 0) { throw "MountVol could not remove the partial mount ($LASTEXITCODE)." } + } + Invoke-DiskPartScript @("select vdisk file=`"$vhdPath`"", 'detach vdisk') | Out-Null + Remove-Item -LiteralPath $vhdPath -Force + } catch { + Write-Warning "Automatic cleanup could not remove the failed VHDX; inspect $vhdPath. $($_.Exception.Message)" + } + } + throw + } + } + 'Mount' { + $volume = Mount-ExecutionDisk + Assert-ExecutionVolume $volume | ConvertTo-Json -Depth 4 + } + 'Remove' { + if (-not (Test-Path -LiteralPath $vhdPath -PathType Leaf)) { + Write-Host 'No sandbox VHDX exists at the expected path.' + break + } + $resolvedRoot = [System.IO.Path]::GetFullPath($sandboxRoot).TrimEnd('\') + $resolvedVhd = [System.IO.Path]::GetFullPath($vhdPath) + if (-not $resolvedVhd.StartsWith($resolvedRoot + '\', [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to remove a VHDX outside the dedicated sandbox directory: $resolvedVhd" + } + $volume = Get-ExecutionVolume + if ($volume) { + Assert-ExecutionVolume $volume | Out-Null + & "$env:WINDIR\System32\mountvol.exe" $mountPath /D | Out-Null + if ($LASTEXITCODE -ne 0) { throw "MountVol could not remove the sandbox mount ($LASTEXITCODE)." } + Invoke-DiskPartScript @("select vdisk file=`"$vhdPath`"", 'detach vdisk') | Out-Null + } + Remove-Item -LiteralPath $vhdPath -Force + if ((Test-Path -LiteralPath $mountPath -PathType Container) -and + -not (Get-ChildItem -LiteralPath $mountPath -Force)) { + Remove-Item -LiteralPath $mountPath -Force + } + if ((Test-Path -LiteralPath $sandboxRoot -PathType Container) -and + -not (Get-ChildItem -LiteralPath $sandboxRoot -Force)) { + Remove-Item -LiteralPath $sandboxRoot -Force + } + Write-Host 'Removed the exact dedicated execution VHDX and its mount directory.' + } +} diff --git a/SovereignAI-Starter/scripts/verify_vhd_disk_quota.ps1 b/SovereignAI-Starter/scripts/verify_vhd_disk_quota.ps1 new file mode 100644 index 0000000..051115b --- /dev/null +++ b/SovereignAI-Starter/scripts/verify_vhd_disk_quota.ps1 @@ -0,0 +1,218 @@ +param( + [switch]$DirectElevated, + [string]$ReportPath +) + +$ErrorActionPreference = 'Stop' + +function Test-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +if (-not $DirectElevated) { + if (Test-Administrator) { + $DirectElevated = $true + } else { + $ReportPath = Join-Path $env:TEMP ("SovereignAI-VHDQuota-{0}.json" -f [guid]::NewGuid().ToString('N')) + $powershell = Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe' + $scriptPath = $MyInvocation.MyCommand.Path + $arguments = @( + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-File', ('"{0}"' -f $scriptPath), + '-DirectElevated', + '-ReportPath', ('"{0}"' -f $ReportPath) + ) -join ' ' + try { + $child = Start-Process -FilePath $powershell -ArgumentList $arguments ` + -Verb RunAs -WindowStyle Hidden -PassThru -Wait + } catch { + Write-Error "Windows did not grant the required administrator token: $($_.Exception.Message)" + exit 1 + } + if (Test-Path -LiteralPath $ReportPath -PathType Leaf) { + Get-Content -LiteralPath $ReportPath -Raw + Remove-Item -LiteralPath $ReportPath -Force + } else { + Write-Error "The elevated quota probe exited $($child.ExitCode) without producing its report." + } + exit $child.ExitCode + } +} + +if (-not (Test-Administrator)) { + throw 'The direct probe requires an elevated administrator token.' +} + +$probeParent = Join-Path $env:LOCALAPPDATA 'SovereignAI\execution-sandbox-probes' +$probeDirectory = Join-Path $probeParent ("vhd-quota-{0}" -f [guid]::NewGuid().ToString('N')) +$vhdPath = Join-Path $probeDirectory 'quota-probe.vhdx' +$mountPath = Join-Path $probeDirectory 'volume' +$diskpartScript = Join-Path $probeDirectory 'diskpart.txt' +$detachScript = Join-Path $probeDirectory 'detach.txt' +$testFile = Join-Path $mountPath 'capacity-probe.bin' +$maximumMegabytes = 64 +$report = $null +$resultCode = 0 +$cleanupError = $null +$mounted = $false + +try { + $systemDrive = [System.IO.Path]::GetPathRoot($env:LOCALAPPDATA) + $availableBytes = [System.IO.DriveInfo]::new($systemDrive).AvailableFreeSpace + if ($availableBytes -lt 256MB) { + throw 'Less than 256 MiB is free on the system drive; refusing to create the 64 MiB probe disk.' + } + + New-Item -ItemType Directory -Path $probeDirectory -Force | Out-Null + New-Item -ItemType Directory -Path $mountPath -Force | Out-Null + $diskpartCommands = @( + "create vdisk file=`"$vhdPath`" maximum=$maximumMegabytes type=expandable", + "select vdisk file=`"$vhdPath`"", + 'attach vdisk', + 'create partition primary', + 'format fs=ntfs quick label=SOVEREIGNAI_PROBE', + "assign mount=`"$mountPath`"", + 'exit' + ) + Set-Content -LiteralPath $diskpartScript -Value $diskpartCommands -Encoding ascii + $diskpartOutput = & "$env:WINDIR\System32\diskpart.exe" /s $diskpartScript 2>&1 | Out-String + $diskpartExitCode = $LASTEXITCODE + if ($diskpartExitCode -ne 0) { + throw "DiskPart failed with exit code $diskpartExitCode. $diskpartOutput" + } + + $deadline = (Get-Date).AddSeconds(15) + $volumes = @(Get-Volume -FileSystemLabel 'SOVEREIGNAI_PROBE' -ErrorAction SilentlyContinue) + while ($volumes.Count -eq 0 -and (Get-Date) -lt $deadline) { + Start-Sleep -Milliseconds 250 + $volumes = @(Get-Volume -FileSystemLabel 'SOVEREIGNAI_PROBE' -ErrorAction SilentlyContinue) + } + if ($volumes.Count -ne 1) { + $mountvolOutput = & "$env:WINDIR\System32\mountvol.exe" $mountPath /L 2>&1 | Out-String + throw "Expected exactly one formatted probe volume. mountvol: $mountvolOutput DiskPart: $diskpartOutput" + } + $volume = $volumes[0] + $mountvolOutput = (& "$env:WINDIR\System32\mountvol.exe" $mountPath /L 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or + -not $mountvolOutput.Trim().Equals($volume.Path.Trim(), [StringComparison]::OrdinalIgnoreCase)) { + throw "The test mount path does not resolve to the formatted probe VHDX. mountvol='$mountvolOutput'; volume='$($volume.Path)'. DiskPart: $diskpartOutput" + } + if ($volume.Size -gt (($maximumMegabytes + 2) * 1MB) -or $volume.Size -lt (48MB)) { + $mountvolOutput = & "$env:WINDIR\System32\mountvol.exe" $mountPath /L 2>&1 | Out-String + throw "Unexpected probe volume capacity: $($volume.Size) bytes. mountvol: $mountvolOutput DiskPart: $diskpartOutput" + } + $mounted = $true + + $buffer = [byte[]]::new(1MB) + for ($index = 0; $index -lt $buffer.Length; $index++) { + $buffer[$index] = [byte](($index * 31 + 97) % 251) + } + $writtenBytes = [long]0 + $diskFullException = $null + $stream = [System.IO.FileStream]::new( + $testFile, + [System.IO.FileMode]::CreateNew, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::None, + $buffer.Length, + [System.IO.FileOptions]::SequentialScan + ) + try { + while ($true) { + $stream.Write($buffer, 0, $buffer.Length) + $writtenBytes += $buffer.Length + } + } catch [System.IO.IOException] { + $diskFullException = $_.Exception + } finally { + $stream.Dispose() + } + + $errorCode = if ($diskFullException) { $diskFullException.HResult -band 0xffff } else { $null } + $volume = Get-Volume -UniqueId $volume.UniqueId + $freeAtLimit = $volume.SizeRemaining + if (-not $diskFullException -or $errorCode -ne 112) { + throw "The write did not stop with ERROR_DISK_FULL (112). HResult=$($diskFullException.HResult); message=$($diskFullException.Message)" + } + if ($freeAtLimit -gt 1MB) { + throw "The write stopped with $freeAtLimit bytes still free; the volume-capacity boundary was not confirmed." + } + + $vhdBytes = (Get-Item -LiteralPath $vhdPath).Length + $report = [pscustomobject]@{ + passed = $true + administrator_token = $true + diskpart_exit_code = $diskpartExitCode + volume_label = $volume.FileSystemLabel + virtual_volume_bytes = $volume.Size + bytes_written_before_error = $writtenBytes + error_code = $errorCode + error_message = $diskFullException.Message + free_bytes_at_limit = $freeAtLimit + dynamic_vhdx_bytes_at_limit = $vhdBytes + probe_vhdx_max_megabytes = $maximumMegabytes + isolation = 'temporary NTFS filesystem inside a dynamically expanding VHDX with a fixed virtual capacity' + } +} catch { + $resultCode = 1 + $report = [pscustomobject]@{ + passed = $false + administrator_token = (Test-Administrator) + error = $_.Exception.Message + probe_vhdx_max_megabytes = $maximumMegabytes + } +} finally { + try { + if ($stream) { $stream.Dispose() } + if (Test-Path -LiteralPath $testFile -PathType Leaf) { + Remove-Item -LiteralPath $testFile -Force + } + if (Test-Path -LiteralPath $vhdPath -PathType Leaf) { + $mountItem = Get-Item -LiteralPath $mountPath -Force -ErrorAction SilentlyContinue + if ($mountItem -and ($mountItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + & "$env:WINDIR\System32\mountvol.exe" $mountPath /D | Out-Null + if ($LASTEXITCODE -ne 0) { throw "MountVol could not remove the exact temporary mount point ($LASTEXITCODE)." } + } + Set-Content -LiteralPath $detachScript -Value @( + "select vdisk file=`"$vhdPath`"", + 'detach vdisk', + 'exit' + ) -Encoding ascii + & "$env:WINDIR\System32\diskpart.exe" /s $detachScript 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { throw "DiskPart could not detach the temporary VHDX ($LASTEXITCODE)." } + Remove-Item -LiteralPath $vhdPath -Force + } + foreach ($file in @($diskpartScript, $detachScript)) { + if (Test-Path -LiteralPath $file -PathType Leaf) { Remove-Item -LiteralPath $file -Force } + } + if (Test-Path -LiteralPath $mountPath -PathType Container) { + $mountItem = Get-Item -LiteralPath $mountPath -Force + if (($mountItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0 -and + -not (Get-ChildItem -LiteralPath $mountPath -Force)) { + Remove-Item -LiteralPath $mountPath -Force + } + } + if ((Test-Path -LiteralPath $probeDirectory -PathType Container) -and + -not (Get-ChildItem -LiteralPath $probeDirectory -Force)) { + Remove-Item -LiteralPath $probeDirectory -Force + } + if ((Test-Path -LiteralPath $probeParent -PathType Container) -and + -not (Get-ChildItem -LiteralPath $probeParent -Force)) { + Remove-Item -LiteralPath $probeParent -Force + } + } catch { + $cleanupError = $_.Exception.Message + $resultCode = 1 + } + if ($cleanupError) { $report | Add-Member -NotePropertyName cleanup_error -NotePropertyValue $cleanupError -Force } + if ($ReportPath) { + $report | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $ReportPath -Encoding utf8 + } else { + $report | ConvertTo-Json -Depth 5 + } +} + +exit $resultCode diff --git a/SovereignAI-Starter/tests/test_auth.py b/SovereignAI-Starter/tests/test_auth.py index 72e76f2..5cca622 100644 --- a/SovereignAI-Starter/tests/test_auth.py +++ b/SovereignAI-Starter/tests/test_auth.py @@ -320,6 +320,81 @@ class AuthenticationTests(unittest.TestCase): self.assertEqual(second_own.status_code, 200, second_own.text) self.assertEqual(second_own.json()["files"], ["two.md"]) + def test_desktop_project_registration_grants_only_selected_folder_to_owner(self) -> None: + _first_user, first_token = self._register() + _second_user, second_token = self._register() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "sample-project" + root.mkdir() + (root / "README.md").write_text("project details", encoding="utf-8") + first_headers = {"Authorization": f"Bearer {first_token}"} + second_headers = {"Authorization": f"Bearer {second_token}"} + + registered = self.client.post( + "/v1/agent/projects", + headers=first_headers, + json={"workspace_path": str(root)}, + ) + first_files = self.client.post( + "/v1/agent/workspace/files", + headers=first_headers, + json={"workspace_path": str(root)}, + ) + second_files = self.client.post( + "/v1/agent/workspace/files", + headers=second_headers, + json={"workspace_path": str(root)}, + ) + overlap_registration = self.client.post( + "/v1/agent/projects", + headers=second_headers, + json={"workspace_path": str(root.parent)}, + ) + revoked = self.client.request( + "DELETE", + "/v1/agent/projects", + headers=first_headers, + json={"workspace_path": str(root)}, + ) + after_revoke = self.client.post( + "/v1/agent/workspace/files", + headers=first_headers, + json={"workspace_path": str(root)}, + ) + + self.assertEqual(registered.status_code, 201, registered.text) + self.assertEqual(registered.json()["files"], ["README.md"]) + self.assertEqual(first_files.status_code, 200, first_files.text) + self.assertEqual(second_files.status_code, 403, second_files.text) + self.assertEqual(overlap_registration.status_code, 409, overlap_registration.text) + self.assertEqual(revoked.status_code, 200, revoked.text) + self.assertTrue(revoked.json()["removed"]) + self.assertEqual(after_revoke.status_code, 403, after_revoke.text) + + def test_deleted_project_folder_can_still_be_unregistered(self) -> None: + _user_id, token = self._register() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "vanished-project" + root.mkdir() + headers = {"Authorization": f"Bearer {token}"} + registered = self.client.post( + "/v1/agent/projects", + headers=headers, + json={"workspace_path": str(root)}, + ) + self.assertEqual(registered.status_code, 201, registered.text) + root.rmdir() + + revoked = self.client.request( + "DELETE", + "/v1/agent/projects", + headers=headers, + json={"workspace_path": str(root)}, + ) + + self.assertEqual(revoked.status_code, 200, revoked.text) + self.assertTrue(revoked.json()["removed"]) + def test_local_bootstrap_is_restricted_to_loopback_clients(self) -> None: remote_client = TestClient(app, client=("192.0.2.10", 43210)) remote = remote_client.post("/v1/auth/local-session", json={})