Scope workspace roots per account

This commit is contained in:
Hamza Ayed
2026-10-03 01:44:00 +03:00
parent 1b56f70aa6
commit 4345e43e29
10 changed files with 250 additions and 54 deletions
+3 -3
View File
@@ -148,7 +148,7 @@ python scripts/eval_knowledge_retrieval.py --dataset evals/knowledge_retrieval_p
- لتشغيل النسخة المكتبية، شغّل `start-api.ps1` أولًا، ثم افتح `flutter_app/build/windows/x64/runner/Release/flutter_app.exe` بعد بنائه.
- يعرض شريط المحادثة قائمة النماذج المثبتة من Ollama؛ اختيار النموذج يمرر اسمه مع الطلب.
- زر المجلد يفعّل وضع الوكيل للقراءة فقط، وزر الكرة الأرضية يفعّل البحث العميق متعدد المصادر. عند تشغيل `start-api.ps1` تكون مساحة العمل الافتراضية مجلد المشروع `SovereignAI-Starter`؛ لتغييرها عرّف `SOVEREIGNAI_WORKSPACE` قبل تشغيل الخدمة. يمكن لمسؤول الخدمة التصريح بعدة جذور عبر `SOVEREIGNAI_ALLOWED_WORKSPACES` مفصولة بفاصل المسارات الخاص بالنظام؛ أي مجلد يختاره العميل يجب أن يقع داخل أحدها. هذه قائمة سماح على مستوى الخادم وليست بعد تعيينًا خاصًا بكل حساب.
- زر المجلد يفعّل وضع الوكيل للقراءة فقط، وزر الكرة الأرضية يفعّل البحث العميق متعدد المصادر. عند تشغيل `start-api.ps1` تكون مساحة العمل الافتراضية مجلد المشروع `SovereignAI-Starter`؛ لتغييرها عرّف `SOVEREIGNAI_WORKSPACE` قبل تشغيل الخدمة. يمكن لمسؤول الخدمة التصريح بعدة جذور عبر `SOVEREIGNAI_ALLOWED_WORKSPACES` مفصولة بفاصل المسارات الخاص بالنظام؛ أي مجلد يختاره العميل يجب أن يقع داخل أحدها. لتخصيص مساحات منفصلة للحسابات، عرّف `SOVEREIGNAI_USER_WORKSPACES` ككائن JSON مفاتيحه عناوين البريد وقيمه مصفوفات مسارات فرعية ضمن الجذور المسموحة، مثل `{"user@example.com":["D:\\SovereignAI\\workspaces\\user"]}`. يرفض الخادم جذور الحسابات المتداخلة ويمنع الحساب من اختيار مسار حساب آخر. حساب بلا تخصيص يستطيع الدردشة لكنه لا يستطيع قراءة مساحة عمل.
- الوكيل يطابق السؤال مع ملفات النص/الكود المدعومة، ويمكنه قراءة PDF رقمي يحدده المستخدم؛ يمرر مقتطفات محدودة للنموذج، وعند ذكر مسار نسبي مثل `app/main.py` يزيد المقتطف إلى 12,000 حرف كحد أقصى. يعيد أسماء الملفات التي قرأها. الملفات المخفية، مجلدات `.git` وبيئات البناء، والملفات الكبيرة أو خارج جذر مساحة العمل مستثناة.
- في وضع الوكيل اختر المهارة من أيقونة الدماغ بجانب زر مساحة العمل: **شرح الكود** أو **مراجعة الكود** أو **خطة اختبارات**. يستعرض `GET /v1/agent/skills` المهارات وأدوات كل منها؛ يتحقق الخادم من صلاحيات الأداة ولا يعتمد على طلب النموذج وحده. مراجعة الكود تستطيع طلب معاينة diff فقط، ولا تطبق تغييرًا.
- فهرس معرفة محلي: في وضع مساحة العمل حدد ملفات UTF-8 أو PDF رقميًا/ممسوحًا ثم اضغط زر الفهرسة بجانب أيقونة المجلد؛ زر الإزالة يحذف الملفات المحددة من الفهرس. يدعم `POST /v1/agent/knowledge/index` و`DELETE /v1/agent/knowledge/index` و`POST /v1/agent/knowledge/search`. يقتصر الطلب على 20 ملفًا و2 ميغابايت إجمالًا، و256 كيلوبايت للملف النصي، وPDF حتى 30 صفحة و24 ألف محرف مستخرج. يجمع PDF المختلط نص الصفحات الرقمية وOCR لأول 3 صفحات ممسوحة، ويضيف تضمينات Granite المحلية الاختيارية إلى FTS5 عند توفر النموذج؛ لا يرفع المحتوى إلى خدمة خارجية. تعذر التضمين يبقي البحث النصي فعالًا. إعادة الفهرسة تستبدل النسخة السابقة، والبحث يحذف تلقائيًا أي مستند تغيّر أو لم يعد ضمن المساحة.
@@ -163,9 +163,9 @@ python scripts/eval_knowledge_retrieval.py --dataset evals/knowledge_retrieval_p
- تحفظ FastAPI المحادثات والرسائل في SQLite محليًا. على Windows يوجد الملف في `%LOCALAPPDATA%\SovereignAI\data\sovereign_ai.sqlite3`، وخارج مجلد المشروع لتجنب مزامنة قاعدة البيانات مع OneDrive.
- لكل سجل محادثة `user_id`، وتُفلتر القراءة والتعديل والحذف بهوية جلسة Bearer موثقة؛ لم يعد `X-User-ID` يمنح أي صلاحية. في الوضع المحلي تطلب الواجهة تلقائيًا جلسة للمستخدم المحلي، ولا يصدرها الخادم إلا لاتصال loopback.
- يوفّر API الآن `POST /v1/auth/register` و`POST /v1/auth/login` و`GET /v1/auth/me` و`POST /v1/auth/logout`. كلمات المرور تُخزن بتجزئة PBKDF2 مع salt؛ رمز الجلسة العشوائي يُخزن كـSHA-256 وينتهي بعد 7 أيام ويمكن إلغاؤه. أضاف Flutter واجهة تسجيل ودخول وخروج، ويحفظ رمز الحساب عبر `flutter_secure_storage` حيث تتوفر آلية النظام. على الويب تعتمد الحزمة على تخزين المتصفح؛ دعمها تجريبي ويحتاج HTTPS أو localhost، لذا لا نعدّه حماية مماثلة لمخزن مفاتيح نظام التشغيل.
- بناء Windows يتطلب مكوّن C++ ATL ضمن Visual Studio Build Tools بسبب إضافة `flutter_secure_storage_windows`؛ إذا ظهر خطأ `atlstr.h` ثبّت المكوّن ثم أعد بناء التطبيق. على Android الحد الأدنى صار API 23.
- بناء Windows يتطلب مكوّن C++ ATL ضمن Visual Studio Build Tools بسبب إضافة `flutter_secure_storage_windows`؛ ATL مثبت الآن ونجح بناء Debug على هذا الجهاز. على Android الحد الأدنى صار API 23.
- كل عمليات `/v1/*` الخاصة تتطلب الآن Bearer session؛ الاستثناءات العامة هي الصحة وقائمة النماذج وتسجيل/دخول الحساب وإنشاء الجلسة المحلية. عميل Flutter يرسل الجلسة للمحادثة والوكيل والملفات والمعرفة والبحث والصوت. ملكية سجل تدقيق الوكيل وفهرس المعرفة ومقترحات تعديل الملفات مرتبطة بمعرّف الحساب.
- أضيف حد أولي لمحاولات الدخول وإنشاء الحسابات في SQLite، لكنه لم يخضع بعد لاختبار ضغط/خلف reverse proxy؛ ولا توجد استعادة لكلمة المرور. كذلك يجب حصر مساحات العمل التي يستطيع كل حساب قراءتها ضمن مجلدات مصرح بها؛ عملية FastAPI ترى صلاحيات ملفات نظام التشغيل الذي يشغلها. أبقِ الخدمة على `127.0.0.1` إلى أن تكتمل هذه الضوابط ويُختبر النشر عبر TLS.
- أضيف حد أولي لمحاولات الدخول وإنشاء الحسابات في SQLite، لكنه لم يخضع بعد لاختبار ضغط/خلف reverse proxy؛ ولا توجد استعادة لكلمة المرور. يخصص `SOVEREIGNAI_USER_WORKSPACES` مجلدات منفصلة للحسابات ضمن جذور `SOVEREIGNAI_ALLOWED_WORKSPACES`، لكن عملية FastAPI ما زالت ترث صلاحيات نظام التشغيل. أبقِ الخدمة على `127.0.0.1` إلى أن يكتمل العزل على مستوى العملية ويُختبر TLS.
- رسائل الدردشة ترسل إلى Ollama المحلي. التسجيل الصوتي يحوّل إلى نص عبر Groq Whisper؛ لذلك يُرسل الصوت إلى Groq عند الضغط على إيقاف التسجيل.
- مفتاح Groq يجب أن يبقى في متغير البيئة `GROQ_API_KEY` الخاص بخادم FastAPI، ولا يوضع في Flutter أو في ملفات المشروع.
- على جهاز التطوير الحالي حُفظ المتغير في بيئة Windows الخاصة بالمستخدم، وليس في ملف `.env`. يقرأه `start-api.ps1` عند تشغيل الخادم، ويظهر `/health` حالة الإعداد فقط دون إظهار المفتاح. عند استضافة الخادم لاحقًا، أضف المفتاح إلى إعدادات البيئة السرية في خدمة الاستضافة.
+11 -10
View File
@@ -2,7 +2,7 @@
هذه خارطة تنفيذ تدريجية للمشروع الحالي. التطبيق اليوم Flutter، وواجهة الخدمة FastAPI، والتخزين SQLite، وتوليد النص عبر خادم Ollama محلي. لا نبدأ بتدريب نموذج من الصفر؛ نبني منتجًا قابلًا لتبديل النموذج ونقيس كل مرحلة قبل توسيع الصلاحيات.
## الحالة الحالية — 2026-10-02
## الحالة الحالية — 2026-10-03
- الأساس المحلي يعمل: Flutter Windows Debug ↔ FastAPI ↔ Ollama، مع سجل SQLite للمحادثات ونسخ الإجابات. Gemma 4 E2B هو الافتراضي للنصوص، وطلبات الصور وPDFات الممسوحة تتحول تلقائيًا إلى Ministral 3:3b المحلي عند توفره. أضيف عقد أدوات الوكيل وسجل تدقيق للبيانات الوصفية فقط؛ وضع الوكيل ينفذ الآن خطوة اختيار أداة واحدة ثم يعيد النتيجة إلى Gemma لصياغة الرد. توجد ثلاث مهارات محلية قابلة للاختيار مع قائمة أدوات مسموحة يتحقق منها الخادم. فهرس SQLite يدعم FTS5 وتضمينات Granite محلية اختيارية لملفات النص والكود وPDF الرقمي والممسوح والمختلط؛ PDF الممسوح يفهرس أول 3 صفحات عبر OCR الاختياري. الوكيل يسترجع المقاطع نصيًا ودلاليًا ويهمل النتائج القديمة عند تغير الملف. تجربة تضمين API حقيقية أعادت الملف المقصود أولًا في 3/3 أسئلة عربية؛ بقي توسيع التقييم على ملفات واقعية ومعايرة OCR وترتيب الأعمدة.
- اكتملت وظائف المحادثة الأساسية وطبقة مزوّد Ollama، بما فيها جداول Markdown القابلة للتمرير والقوائم المتداخلة ومربعات مهام GFM؛ اجتاز التطبيق تحليل Flutter و10 اختبارات واجهة وحالة.
@@ -22,12 +22,12 @@
- إعدادات عنوان API والنموذج المختار والإشعارات والمظهر الداكن تُحفظ محليًا: ملف إعدادات عبر `path_provider` للأجهزة و`localStorage` للويب. عند غياب plugin يُبلّغ التطبيق أن الإعداد لن يستمر بعد الجلسة. Windows Debug أنشأ ملف الإعدادات فعليًا؛ اختبار Cubit أكد الاستعادة بعد إعادة إنشائه، واختبار الواجهة أكد تبديل السمة، وبناء Flutter Web نجح (2026-10-02).
- `/v1/models` يعرض القدرات التي يعلنها Ollama فعلًا لكل نموذج، مع علامة تحقق واضحة؛ قائمة Flutter تعرض النص/الصور/الصوت/الأدوات/التفكير/التضمينات دون تخمين. تحقق حي أظهر Gemma 4 E2B: نص، صور، صوت، أدوات، تفكير؛ Ministral 3:3b: نص، صور، أدوات.
- أضيف زر البحث العميق متعدد المصادر وزرا اختيار الملفات إلى الواجهة؛ أضيف تقييم الإجابة بإعجاب/عدم إعجاب محفوظ لكل نسخة في SQLite. التقييم يجمع بيانات تقييم، ولا يدرّب أوزان Gemma تلقائيًا.
- Flutter Windows Debug وFastAPI يعملان محليًا؛ مسار تحليل المرفقات يدعم PDF الرقمي، ويحوّل أول 3 صفحات من PDF الممسوح محليًا ثم يرسلها لنموذج الرؤية المحلي. تجربة حية على صورة اللقاء استخرجت العنوان والمكان عمان والتاريخ 15 تشرين الأول والوقت 6:30 مساءً دون ذكر سنة غير ظاهرة. أُضيفت جلسات حسابات وعزل سجل المحادثات والمعرفة والتدقيق، لكن واجهة الدخول وصلاحيات نظام الملفات وحدود الدخول لم تكتمل؛ لا يوجد نشر شبكي آمن بعد. الصوت يعتمد على Groq خارجي.
- Flutter Windows Debug وFastAPI يعملان محليًا؛ مسار تحليل المرفقات يدعم PDF الرقمي، ويحوّل أول 3 صفحات من PDF الممسوح محليًا ثم يرسلها لنموذج الرؤية المحلي. تجربة حية على صورة اللقاء استخرجت العنوان والمكان عمان والتاريخ 15 تشرين الأول والوقت 6:30 مساءً دون ذكر سنة غير ظاهرة. اكتملت واجهة الحساب، وتحديد محاولات الدخول، وتعيين جذور ملفات منفصلة للحسابات بإعداد مسؤول الخادم. عزل عملية FastAPI على مستوى نظام التشغيل، واستعادة كلمة المرور، وTLS، والتخزين الآمن للويب ما زالت مفتوحة؛ لا يوجد نشر شبكي آمن بعد. الصوت يعتمد على Groq خارجي.
- التدريب والضبط الدقيق وتوزيع Windows مراحل لاحقة، وليست مما يفعّله التطبيق حاليًا.
- 2026-10-03: اكتمل فرض Bearer على كل عمليات `/v1` الخاصة (عدا الصحة وقائمة النماذج ومسارات بدء المصادقة العامة)، وربط Flutter بالجلسة لكل طلب محادثة/وكيل/ملف/معرفة/ويب/صوت. أصبحت ملكية فهرس المعرفة وسجل التدقيق حسب الحساب، ومعاينة تعديل الملف لا تُطبق إلا بجلسة صاحبها. تحقق OpenAPI وواجهات رفض الرمز وعزل حسابين وترحيل SQLite: 61 اختبار Python ناجح، و11 اختبار Flutter وتحليل Flutter بلا ملاحظات. يبقى قصر الوصول إلى مسارات نظام الملفات لكل مستخدم، وواجهة الدخول وتخزين الرمز الآمن وحدود محاولات الدخول؛ لذلك يظل التشغيل loopback فقط.
- 2026-10-03: أضيفت شاشة Flutter للحساب (إنشاء/دخول/خروج)، وعند تبديل الهوية تمسح المحادثات المحملة ثم تعيد قراءتها تحت الجلسة الجديدة. رمز الحساب يمر عبر `flutter_secure_storage` ويُستعاد بالتحقق من `/v1/auth/me`؛ اختبار Flutter 11/11 و`flutter analyze` بلا ملاحظات. Android مضبوط على API 23. **لم يكتمل بناء Windows:** Visual Studio Build Tools يفتقد ATL (`atlstr.h`) المطلوب من الإضافة، ومحاولة إضافة المكوّن لم تُثبته؛ يجب إصلاح مكوّن البناء ثم إعادة `flutter build windows --debug` قبل اعتبار خطوة Windows مجربة. بناء الويب بدأ لكن لم يُحسم بعد.
- 2026-10-03: أضيف تحديد دائم لمحاولات الدخول عبر SQLite: خمس كلمات مرور خاطئة لكل حساب أو عنوان اتصال خلال 15 دقيقة، وحدّ إنشاء 30 حسابًا لكل اتصال في الساعة. تُخزن بصمات SHA-256 للمفاتيح بدل البريد أو IP الخام، ويرجع الخادم 429 و`Retry-After`. تحقق اختبار انقضاء النافذة ومجموعة Python الكاملة: 62 اختبارًا ناجحًا، و`compileall` و`git diff --check` ناجحان. لا يزال هذا حدًا أوليًا خلف خدمة loopback، وليس سياسة كافية وحده لخدمة عامة.
- 2026-10-03: أضيفت قائمة سماح جذور لمساحات العمل على مستوى الخدمة؛ اختيار المجلد يتطلب أن يقع داخل جذر يحدده المسؤول في `SOVEREIGNAI_ALLOWED_WORKSPACES`. اختُبر رفض المسار الخارجي وقبول المسار الداخلي، ونجحت مجموعة Python الآن بـ63 اختبارًا مع `compileall` و`git diff --check`. ما زال ربط الجذر بالحساب مستقلًا مطلوبًا.
- 2026-10-03: أضيفت شاشة Flutter للحساب (إنشاء/دخول/خروج)، وعند تبديل الهوية تمسح المحادثات المحملة ثم تعيد قراءتها تحت الجلسة الجديدة. رمز الحساب يمر عبر `flutter_secure_storage` ويُستعاد بالتحقق من `/v1/auth/me`؛ اختبار Flutter 11/11 و`flutter analyze` بلا ملاحظات. Android مضبوط على API 23. ثُبت مكوّن ATL ثم نجح `flutter build windows --debug`، وأُطلقت نسخة Windows Debug متصلة بخادم تجريبي على `127.0.0.1:8100` وقاعدة `.live-api-data`؛ فحص `/health` أعاد HTTP 200. لم أتحقق من التفاعل المرئي للشاشة بعد.
- 2026-10-03: عُزلت مساحة العمل حسب الحساب عبر `SOVEREIGNAI_USER_WORKSPACES`؛ يتحقق الخادم من أن كل مسار داخل جذور المسؤول، ويرفض الجذور المتداخلة أو غير المخصصة، مع تحويل المنع إلى HTTP 403. أضيفت تغطية لحسابين وجذور منفصلة. تحقق API حي منفصل على 8100: إنشاء جلسة loopback ثم إرسال تحية عربية إلى `POST /v1/chat/completions` عبر Gemma 4 أعاد ردًا وHTTP 200. فحص `/health` أيضًا HTTP 200. هذا يثبت API والنموذج؛ اتصال نافذة Flutter بهذا المنفذ لم يُثبت، ولا تسجل الخدمة طلبًا صادرًا من التطبيق حتى الآن. تحديث الاختبارات الكامل لم يُعَد في هذه الجولة لأن `pytest` غير موجود في بيئة Python المتاحة، و`unittest discover` لا يشغل اختبارات pytest ويصطدم بإنشاء ملفات مؤقتة خارج مساحة العمل. الاختبار الكامل السابق كان 65 اختبارًا ناجحًا قبل هذا التغيير الصغير في معرّف المستخدم المحلي؛ يلزم إعادة تشغيل pytest في بيئة مناسبة.
- 2026-10-03: حد الدخول محفوظ في SQLite: خمس كلمات مرور خاطئة لكل حساب أو اتصال خلال 15 دقيقة، وحدّ إنشاء 30 حسابًا لكل اتصال في الساعة. تُخزن بصمات SHA-256، ويرجع API حالة 429 و`Retry-After`. أضيفت الآن جذور منفصلة لكل حساب بإعداد `SOVEREIGNAI_USER_WORKSPACES`؛ طلب API أكد أن الحساب لا يختار مساحة حساب آخر، والجذور المتداخلة مرفوضة. مجموعة Python كاملة 65/65، و`compileall` و`git diff --check` ناجحان.
## المرحلة 1 — تجربة المحادثة
@@ -66,14 +66,15 @@
- [x] ربط CRUD المحادثات والتقييم بهوية الجلسة، والتحقق من أن حسابًا ثانيًا لا يقرأ محادثة الحساب الأول.
- [x] فرض Bearer على جميع عمليات `/v1` الخاصة وإرسال الجلسة من Flutter للمحادثة/الوكيل/الملفات/المعرفة/البحث والصوت؛ فحص OpenAPI يضمن ألا توجد عملية خاصة بلا HTTP Bearer.
- [x] عزل فهرس المعرفة وسجل التدقيق بمعرّف الحساب، وربط رمز معاينة تعديل الملفات بصاحبها؛ اختبار API أثبت عدم استرجاع حساب لمحتوى فهرسه حساب آخر.
- [ ] ربط جذور مساحة العمل بكل حساب على حدة، وإزالة صلاحيات FastAPI العامة عن ملفات المضيف قبل السماح بعميل شبكي أو خدمة مستضافة.
- [x] إضافة قائمة سماح على مستوى الخادم عبر `SOVEREIGNAI_ALLOWED_WORKSPACES`؛ يرفض API الآن أي مجلد خارج الجذور المعتمدة عند ضبطها. هذا يقلل الوصول إلى المضيف لكنه لا يعزل حسابين داخل الجذر نفسه، ولا يغني عن إعدادها في كل خدمة تشغيل.
- [x] ربط جذور مساحة العمل بالحساب عبر إعداد مسؤول الخدمة `SOVEREIGNAI_USER_WORKSPACES` (بريد الحساب ← مسارات مخصصة تحت القائمة العامة)؛ يرفض API الحساب الذي لا يملك تخصيصًا، ويرفض المسارات المتداخلة بين الحسابات. اختبار API أكد أن لكل حساب جذره فقط (2026-10-03).
- [ ] عزل عملية FastAPI عن ملفات المضيف غير المصرح بها على مستوى نظام التشغيل قبل السماح بعميل شبكي أو خدمة مستضافة؛ القائمة البرمجية وحدها لا تحد صلاحيات العملية نفسها.
- [x] إضافة قائمة سماح على مستوى الخادم عبر `SOVEREIGNAI_ALLOWED_WORKSPACES`؛ يرفض API أي مجلد خارج الجذور المعتمدة. مع `SOVEREIGNAI_USER_WORKSPACES` يطبق الخادم كذلك حدود جذور كل حساب. لا يغني ذلك عن عزل صلاحيات عملية FastAPI على مستوى نظام التشغيل.
- [x] بناء واجهة إنشاء الحساب/الدخول والخروج وربطها بجلسات API؛ عند تبديل الحساب تمسح الواجهة الحالة المحلية ثم تعيد تحميل المحادثات والمهارات تحت الهوية الجديدة.
- [x] حفظ رمز الحساب عبر `flutter_secure_storage` في مخزن النظام على المنصات المدعومة، واستعادة `/v1/auth/me` عند بدء التطبيق؛ Android مضبوط على API 23 كحد أدنى. تخزين الويب تجريبي ويعتمد على المتصفح، لذلك لا يُعامل كحماية نظام تشغيل.
- [ ] إضافة حدود لمحاولات الدخول/التسجيل وتدفق استعادة كلمة المرور واختبار نشر محلي عبر TLS قبل الوصول الشبكي.
- [x] حد أولي دائم لتخمين كلمات المرور وإنشاء الحسابات في SQLite مع نافذة انتهاء و`Retry-After`؛ يلزم اختبار API كامل ومراجعة الحدود تحت reverse proxy موثوق قبل أي نشر خارجي.
- [ ] إضافة تدفق استعادة كلمة المرور واختبار نشر محلي عبر TLS قبل الوصول الشبكي.
- [x] حد أولي دائم لتخمين كلمات المرور وإنشاء الحسابات في SQLite مع نافذة انتهاء و`Retry-After`؛ اختبرته مجموعة Python كاملة، وتبقى مراجعة الحدود تحت reverse proxy موثوق قبل أي نشر خارجي.
- [ ] تهيئة تخزين آمن للحساب على الويب أو تعطيل حفظ الرمز هناك؛ التحقق من متطلبات Linux keyring وWindows/macOS على منصات بناء فعلية.
- [ ] تثبيت/تفعيل مكوّن C++ ATL في Visual Studio Build Tools وإعادة بناء Windows؛ المحاولة بتاريخ 2026-10-03 ما زالت تُظهر الخطأ `atlstr.h` المفقود.
- [x] تثبيت مكوّن C++ ATL في Visual Studio Build Tools؛ تحقق وجود `atlstr.h` ونجح `flutter build windows --debug` (2026-10-03).
- تصميم بيانات المستخدمين والمحادثات والمرفقات ونسخ الإجابات مع ملكية واضحة وفهارس وترحيلات قاعدة بيانات.
- SQLite مناسب لنسخة محلية أحادية الجهاز. عند تشغيل خدمة لعدة مستخدمين/أجهزة، ننتقل إلى PostgreSQL، مع نسخ احتياطية وسياسة حذف وتصدير.
- تخزين الملفات الكبيرة في مساحة ملفات منظّمة، وحفظ بياناتها الوصفية ومراجعها في قاعدة البيانات، لا في سجل الرسائل ككتل ضخمة.
+19 -6
View File
@@ -236,10 +236,15 @@ class WorkspaceAgentRequest(AgentRequest):
@app.post("/v1/agent/workspace/files", dependencies=[Depends(get_authenticated_user_id)])
async def list_workspace_files(request: WorkspaceFilesRequest) -> dict[str, Any]:
async def list_workspace_files(
request: WorkspaceFilesRequest,
user_id: str = Depends(get_authenticated_user_id),
) -> dict[str, Any]:
"""Return bounded relative file names from the folder chosen by the desktop user."""
try:
root = workspace.selected_root(request.workspace_path)
root = workspace.selected_root(request.workspace_path, user_id=user_id)
except workspace.WorkspaceAccessDenied as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if root is None:
@@ -592,7 +597,9 @@ async def index_workspace_knowledge(
) -> dict[str, Any]:
"""Index selected files and optionally add local semantic vectors."""
try:
root = workspace.selected_root(request.workspace_path)
root = workspace.selected_root(request.workspace_path, user_id=user_id)
except workspace.WorkspaceAccessDenied as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if root is None:
@@ -721,7 +728,9 @@ def delete_workspace_knowledge(
) -> dict[str, Any]:
"""Delete selected files from this user's local knowledge index."""
try:
root = workspace.selected_root(request.workspace_path)
root = workspace.selected_root(request.workspace_path, user_id=user_id)
except workspace.WorkspaceAccessDenied as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if root is None:
@@ -747,7 +756,9 @@ async def search_workspace_knowledge(
user_id: str = Depends(get_authenticated_user_id),
) -> dict[str, Any]:
try:
root = workspace.selected_root(request.workspace_path)
root = workspace.selected_root(request.workspace_path, user_id=user_id)
except workspace.WorkspaceAccessDenied as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if root is None:
@@ -1453,7 +1464,9 @@ async def _execute_agent(
await report("يتحقق من المهمة ومساحة العمل المحددة.")
try:
selected_workspace = workspace.selected_root(request.workspace_path)
selected_workspace = workspace.selected_root(request.workspace_path, user_id=user_id)
except workspace.WorkspaceAccessDenied as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
selected_skill = skills.get_skill(request.skill_id)
+67 -5
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import os
import re
import json
import difflib
import hashlib
import tempfile
@@ -29,6 +30,10 @@ MAX_PENDING_PROPOSALS = 32
_pending_changes: dict[str, dict[str, object]] = {}
class WorkspaceAccessDenied(ValueError):
"""Raised when an authenticated account selects another user's workspace."""
def configured_roots() -> tuple[Path, ...]:
"""Return the server administrator's allow-listed workspace roots."""
configured = os.getenv("SOVEREIGNAI_ALLOWED_WORKSPACES")
@@ -55,10 +60,66 @@ def configured_root() -> Path | None:
return roots[0] if roots else None
def selected_root(value: str | None) -> Path | None:
"""Resolve an explicitly selected directory, falling back to server config."""
def _roots_for_user(user_id: str | None) -> tuple[Path, ...]:
roots = configured_roots()
from app import auth, database
if user_id is None or user_id == database.LOCAL_USER_ID:
return roots
email = auth.account_email(user_id)
if email is None:
raise ValueError("الحساب الحالي لا يملك مساحة عمل مخصصة على الخادم.")
raw_mapping = os.getenv("SOVEREIGNAI_USER_WORKSPACES", "")
try:
mapping = json.loads(raw_mapping) if raw_mapping else {}
except json.JSONDecodeError as exc:
raise ValueError("إعداد مساحات العمل حسب الحساب غير صالح على الخادم.") from exc
if not isinstance(mapping, dict):
raise ValueError("إعداد مساحات العمل حسب الحساب يجب أن يكون كائن JSON.")
resolved_by_email: dict[str, tuple[Path, ...]] = {}
for assigned_email, assigned_values in mapping.items():
if not isinstance(assigned_email, str) or not isinstance(assigned_values, list):
raise ValueError("كل مستخدم في إعداد مساحات العمل يجب أن يقابله مصفوفة مسارات.")
assigned_email = assigned_email.strip().casefold()
if not assigned_email or assigned_email in resolved_by_email:
raise ValueError("البريد مكرر أو فارغ في إعداد مساحات العمل.")
assigned_roots: list[Path] = []
for item in assigned_values:
if not isinstance(item, str) or not item.strip():
raise ValueError("مسار مساحة العمل في إعداد الخادم غير صالح.")
try:
root = Path(item).expanduser().resolve(strict=True)
except (OSError, RuntimeError) as exc:
raise ValueError("أحد جذور مساحة العمل المخصصة غير موجود أو غير متاح.") from exc
if not root.is_dir() or not any(root == allowed or allowed in root.parents for allowed in roots):
raise ValueError("جذر الحساب يجب أن يقع داخل قائمة جذور الخادم المصرح بها.")
if root in assigned_roots:
raise ValueError("جذر مساحة العمل مكرر للحساب نفسه.")
assigned_roots.append(root)
resolved_by_email[assigned_email] = tuple(assigned_roots)
# Reject overlapping account roots so one account cannot read a parent or
# child directory assigned to another account.
owners = list(resolved_by_email.items())
for index, (_first_email, first_roots) in enumerate(owners):
for _second_email, second_roots in owners[index + 1 :]:
for first_root in first_roots:
for second_root in second_roots:
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:
raise WorkspaceAccessDenied("لم يخصص مسؤول الخادم مساحة عمل لهذا الحساب.")
return assigned
def selected_root(value: str | None, *, user_id: str | None = None) -> Path | None:
"""Resolve a selected directory only within this user's server-assigned roots."""
allowed_roots = _roots_for_user(user_id)
if value is None or not value.strip():
return configured_root()
return allowed_roots[0] if allowed_roots else None
try:
root = Path(value).expanduser().resolve(strict=True)
except (OSError, RuntimeError) as exc:
@@ -69,11 +130,12 @@ def selected_root(value: str | None) -> Path | None:
raise ValueError("اختر مجلد مشروع محددًا، وليس جذر القرص.")
if root.name.startswith(".") or root.name in IGNORED_PARTS:
raise ValueError("لا يمكن استخدام مجلد مخفي أو مستثنى كمساحة عمل.")
allowed_roots = configured_roots()
if allowed_roots and not any(
root == allowed or allowed in root.parents for allowed in allowed_roots
):
raise ValueError("المساحة المحددة خارج مجلدات المشاريع المصرح بها على الخادم.")
raise WorkspaceAccessDenied("المجلد المحدد خارج مساحة العمل المخصصة لهذا الحساب.")
if not allowed_roots:
raise WorkspaceAccessDenied("لا توجد جذور مساحة عمل مخصصة لهذا الحساب على الخادم.")
return root
@@ -21,6 +21,11 @@ class AgentSkillTests(unittest.TestCase):
def setUpClass(cls) -> None:
cls.client = authenticated_client(app)
cls.workspace = str(Path(__file__).resolve().parents[1])
cls.workspace_environment = patch.dict(
os.environ, {"SOVEREIGNAI_ALLOWED_WORKSPACES": cls.workspace}
)
cls.workspace_environment.start()
cls.addClassCleanup(cls.workspace_environment.stop)
def test_skill_catalog_discloses_scope_and_permissions(self) -> None:
response = self.client.get("/v1/agent/skills")
+47
View File
@@ -1,6 +1,9 @@
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from uuid import uuid4
_PROJECT_ROOT = Path(__file__).resolve().parents[1]
@@ -199,6 +202,50 @@ class AuthenticationTests(unittest.TestCase):
)
auth.clear_login_failures(email, client_host)
def test_workspace_files_are_isolated_by_account_assignment(self) -> None:
first_email = f"{uuid4().hex}@example.test"
second_email = f"{uuid4().hex}@example.test"
_, first_token = self._register(first_email)
_, second_token = self._register(second_email)
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
first_root = parent / "first"
second_root = parent / "second"
first_root.mkdir()
second_root.mkdir()
(first_root / "one.md").write_text("first", encoding="utf-8")
(second_root / "two.md").write_text("second", encoding="utf-8")
environment = {
"SOVEREIGNAI_ALLOWED_WORKSPACES": str(parent),
"SOVEREIGNAI_USER_WORKSPACES": json.dumps(
{
first_email: [str(first_root)],
second_email: [str(second_root)],
}
),
}
with patch.dict(os.environ, environment, clear=False):
first_own = self.client.post(
"/v1/agent/workspace/files",
headers={"Authorization": f"Bearer {first_token}"},
json={"workspace_path": str(first_root)},
)
first_other = self.client.post(
"/v1/agent/workspace/files",
headers={"Authorization": f"Bearer {first_token}"},
json={"workspace_path": str(second_root)},
)
second_own = self.client.post(
"/v1/agent/workspace/files",
headers={"Authorization": f"Bearer {second_token}"},
json={"workspace_path": str(second_root)},
)
self.assertEqual(first_own.status_code, 200, first_own.text)
self.assertEqual(first_own.json()["files"], ["one.md"])
self.assertEqual(first_other.status_code, 403, first_other.text)
self.assertEqual(second_own.status_code, 200, second_own.text)
self.assertEqual(second_own.json()["files"], ["two.md"])
def test_local_bootstrap_is_restricted_to_loopback_clients(self) -> None:
remote = self.client.post("/v1/auth/local-session", json={})
self.assertEqual(remote.status_code, 403, remote.text)
+35 -16
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import base64
import hashlib
import json
import os
import tempfile
import unittest
@@ -25,6 +26,10 @@ class KnowledgeIndexTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.base = Path(self.temp.name)
self.workspace_environment = patch.dict(
os.environ, {"SOVEREIGNAI_ALLOWED_WORKSPACES": str(self.base)}
)
self.workspace_environment.start()
self.database = self.base / "knowledge.sqlite3"
self.workspace = self.base / "project"
self.workspace.mkdir()
@@ -34,6 +39,7 @@ class KnowledgeIndexTests(unittest.TestCase):
knowledge.initialize()
def tearDown(self) -> None:
self.workspace_environment.stop()
self.temp.cleanup()
def _index(self, text: str, root: Path | None = None) -> None:
@@ -167,11 +173,14 @@ class KnowledgeIndexTests(unittest.TestCase):
def test_api_knowledge_index_isolated_between_account_sessions(self) -> None:
marker = "OwnerScopedKnowledgeMarker"
(self.workspace / "private.md").write_text(marker, encoding="utf-8")
(self.other_workspace / "private.md").write_text(marker, encoding="utf-8")
first_email = f"{uuid4().hex}@example.test"
second_email = f"{uuid4().hex}@example.test"
owner_id = auth.create_account(
f"{uuid4().hex}@example.test", "account one secure passphrase"
first_email, "account one secure passphrase"
)
other_id = auth.create_account(
f"{uuid4().hex}@example.test", "account two secure passphrase"
second_email, "account two secure passphrase"
)
self.addCleanup(self._delete_test_users, owner_id, other_id)
owner_token, _ = auth.issue_session(owner_id)
@@ -182,20 +191,29 @@ class KnowledgeIndexTests(unittest.TestCase):
other_client = TestClient(
app, headers={"Authorization": f"Bearer {other_token}"}
)
payload = {
"workspace_path": str(self.workspace),
"files": ["private.md"],
}
indexed = owner_client.post("/v1/agent/knowledge/index", json=payload)
owner_results = owner_client.post(
"/v1/agent/knowledge/search",
json={"workspace_path": str(self.workspace), "task": marker},
)
other_results = other_client.post(
"/v1/agent/knowledge/search",
json={"workspace_path": str(self.workspace), "task": marker},
)
with patch.dict(
os.environ,
{
"SOVEREIGNAI_USER_WORKSPACES": json.dumps(
{
first_email: [str(self.workspace)],
second_email: [str(self.other_workspace)],
}
),
},
):
indexed = owner_client.post(
"/v1/agent/knowledge/index",
json={"workspace_path": str(self.workspace), "files": ["private.md"]},
)
owner_results = owner_client.post(
"/v1/agent/knowledge/search",
json={"workspace_path": str(self.workspace), "task": marker},
)
other_results = other_client.post(
"/v1/agent/knowledge/search",
json={"workspace_path": str(self.other_workspace), "task": marker},
)
self.assertEqual(indexed.status_code, 200, indexed.text)
self.assertEqual(owner_results.status_code, 200, owner_results.text)
@@ -207,6 +225,7 @@ class KnowledgeIndexTests(unittest.TestCase):
workspace_path=self.workspace,
relative_path="private.md",
)
(self.other_workspace / "private.md").unlink(missing_ok=True)
@staticmethod
def _delete_test_users(*user_ids: str) -> None:
@@ -38,7 +38,13 @@ class MixedPdfKnowledgeIntegrationTests(unittest.TestCase):
try:
with (
patch.object(database, "DATABASE_PATH", database_path),
patch.dict(os.environ, {"KNOWLEDGE_EMBEDDING_MODEL": "granite-embedding:278m"}),
patch.dict(
os.environ,
{
"KNOWLEDGE_EMBEDDING_MODEL": "granite-embedding:278m",
"SOVEREIGNAI_ALLOWED_WORKSPACES": str(data_dir),
},
),
patch("app.main.embeddings.embed_texts", new=AsyncMock(side_effect=fake_embeddings)),
):
database.initialize_database()
+14 -11
View File
@@ -243,17 +243,20 @@ class PdfAnalysisTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "meetup.pdf").write_bytes(make_pdf("Community Meetup Amman"))
listed = self.client.post(
"/v1/agent/workspace/files", json={"workspace_path": str(root)}
)
indexed = self.client.post(
"/v1/agent/knowledge/index",
json={"workspace_path": str(root), "files": ["meetup.pdf"]},
)
searched = self.client.post(
"/v1/agent/knowledge/search",
json={"workspace_path": str(root), "task": "Community Meetup"},
)
with patch.dict(
os.environ, {"SOVEREIGNAI_ALLOWED_WORKSPACES": str(root)}
):
listed = self.client.post(
"/v1/agent/workspace/files", json={"workspace_path": str(root)}
)
indexed = self.client.post(
"/v1/agent/knowledge/index",
json={"workspace_path": str(root), "files": ["meetup.pdf"]},
)
searched = self.client.post(
"/v1/agent/knowledge/search",
json={"workspace_path": str(root), "task": "Community Meetup"},
)
self.assertEqual(listed.status_code, 200, listed.text)
self.assertIn("meetup.pdf", listed.json()["files"])
@@ -5,10 +5,11 @@ from __future__ import annotations
import tempfile
import unittest
import os
import json
from pathlib import Path
from unittest.mock import patch
from app import workspace
from app import auth, workspace
class WorkspaceChangeTests(unittest.TestCase):
@@ -99,10 +100,49 @@ class WorkspaceChangeTests(unittest.TestCase):
{"SOVEREIGNAI_ALLOWED_WORKSPACES": str(allowed)},
clear=False,
):
with self.assertRaisesRegex(ValueError, "خارج مجلدات المشاريع"):
with self.assertRaisesRegex(ValueError, "خارج مساحة العمل"):
workspace.selected_root(str(outside))
self.assertEqual(workspace.selected_root(str(nested)), nested.resolve())
def test_account_workspaces_are_separate_and_admin_config_must_not_overlap(self) -> None:
allowed = self.root / "accounts"
first_root = allowed / "first"
second_root = allowed / "second"
first_root.mkdir(parents=True)
second_root.mkdir()
config = {
"first@example.test": [str(first_root)],
"second@example.test": [str(second_root)],
}
with (
patch.dict(
os.environ,
{
"SOVEREIGNAI_ALLOWED_WORKSPACES": str(allowed),
"SOVEREIGNAI_USER_WORKSPACES": json.dumps(config),
},
clear=False,
),
patch.object(
auth,
"account_email",
side_effect=lambda user_id: {
"user-first": "first@example.test",
"user-second": "second@example.test",
}.get(user_id),
),
):
self.assertEqual(
workspace.selected_root(str(first_root), user_id="user-first"),
first_root.resolve(),
)
with self.assertRaisesRegex(ValueError, "خارج مساحة العمل"):
workspace.selected_root(str(second_root), user_id="user-first")
config["second@example.test"] = [str(allowed)]
os.environ["SOVEREIGNAI_USER_WORKSPACES"] = json.dumps(config)
with self.assertRaisesRegex(ValueError, "متداخلة"):
workspace.selected_root(str(first_root), user_id="user-first")
def test_rejects_create_overwrite_and_update_of_missing_file(self) -> None:
target = self.root / "src" / "existing.py"
target.write_text("value = 1\n", encoding="utf-8")