soliplex 0.78__tar.gz → 0.78.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {soliplex-0.78/src/soliplex.egg-info → soliplex-0.78.2}/PKG-INFO +1 -1
- {soliplex-0.78 → soliplex-0.78.2}/pyproject.toml +1 -1
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/audit.py +75 -41
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/rag.py +5 -2
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/skills.py +1 -1
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/tools.py +4 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/models.py +1 -1
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/skills/bwrap_sandbox/__init__.py +117 -67
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/agui.py +72 -10
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/room_file_uploads.py +8 -2
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/sandbox_workdirs.py +89 -15
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/thread_file_uploads.py +26 -24
- {soliplex-0.78 → soliplex-0.78.2/src/soliplex.egg-info}/PKG-INFO +1 -1
- {soliplex-0.78 → soliplex-0.78.2}/LICENSE +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/MANIFEST.in +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/README.md +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/example/skills/bare-bones/SKILL.md +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/example/skills/stanzas/SKILL.md +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/setup.cfg +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/skills/soliplex-docs/SKILL.md +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/agents.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/agui/__init__.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/agui/parser.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/agui/persistence.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/agui/schema.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/agui/util.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/authn.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/authz/__init__.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/authz/persistence.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/authz/schema.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/capabilities/filesystem.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/capabilities/rag_audit.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/admin_users.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/ask.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/cli_util.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/main.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/ollama.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/room_authz.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/serve.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/types.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/completions.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/__init__.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/_utils.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/agents.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/agui.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/authsystem.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/completions.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/exceptions.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/installation.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/logfire.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/meta.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/middleware.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/quizzes.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/rooms.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/routing.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/secrets.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/examples.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/installation.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/log_ingest.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/loggers.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/main.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/mcp_auth.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/mcp_client.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/mcp_server.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/ollama.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/quizzes.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/rag_audit.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/sandbox_audit.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/secrets.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/skills/bwrap_sandbox/SKILL.md +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/titles.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/tools/__init__.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/tools/agui_run_feedback.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/tools/file_uploads.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/tools/rag.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/tui/cli.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/tui/main.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/tui/rest_api.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/tui/serve.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/util.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/__init__.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/authn.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/authz.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/completions.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/installation.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/log_ingest.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/quizzes.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/rooms.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/stats.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/streaming.py +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex.egg-info/SOURCES.txt +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex.egg-info/dependency_links.txt +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex.egg-info/entry_points.txt +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex.egg-info/requires.txt +0 -0
- {soliplex-0.78 → soliplex-0.78.2}/src/soliplex.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: soliplex
|
|
3
|
-
Version: 0.78
|
|
3
|
+
Version: 0.78.2
|
|
4
4
|
Summary: An AI-powered Retrieval-Augmented Generation (RAG) system with a modern web interface.
|
|
5
5
|
Author-email: Enfold <info@enfoldsystems.net>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "soliplex"
|
|
7
|
-
version = "0.78"
|
|
7
|
+
version = "0.78.2"
|
|
8
8
|
description = "An AI-powered Retrieval-Augmented Generation (RAG) system with a modern web interface."
|
|
9
9
|
authors = [{ name = "Enfold", email = "info@enfoldsystems.net" }]
|
|
10
10
|
readme = "README.md"
|
|
@@ -568,22 +568,39 @@ async def _list_admin_discriminators(dburi):
|
|
|
568
568
|
return await policy.list_admin_user_discriminators()
|
|
569
569
|
|
|
570
570
|
|
|
571
|
-
def
|
|
572
|
-
"""
|
|
573
|
-
|
|
571
|
+
def _room_policies(the_installation) -> tuple[list, str | None]:
|
|
572
|
+
"""Return ``(policies, error)`` for the stored room policies.
|
|
573
|
+
|
|
574
|
+
``policies`` holds the unchecked policy models read via
|
|
575
|
+
'RoomAuthorizationPolicy.list_room_policies' and ``error`` is ``None``
|
|
576
|
+
on success. A RAM-based authz DB can hold no persisted rows, so it
|
|
577
|
+
short-circuits to ``([], None)`` without touching the database.
|
|
578
|
+
|
|
579
|
+
When the database itself cannot be reached -- e.g. its DBURI names a
|
|
580
|
+
Postgres server that isn't listening -- ``policies`` is empty and
|
|
581
|
+
``error`` carries the exception message, so the audit can report the
|
|
582
|
+
unreachable DB instead of dying on the traceback.
|
|
583
|
+
"""
|
|
574
584
|
dburi = the_installation.authorization_dburi_async
|
|
575
585
|
|
|
576
586
|
if dburi in cli_util._RAM_DBURIS:
|
|
577
|
-
return
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
}
|
|
587
|
+
return [], None
|
|
588
|
+
|
|
589
|
+
try:
|
|
590
|
+
return list(asyncio.run(_list_room_policies(dburi))), None
|
|
591
|
+
except Exception as exc:
|
|
592
|
+
return [], f"{type(exc).__name__}: {exc}"
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _room_authz_groups(the_installation, room_policies):
|
|
596
|
+
"""Bucket configured rooms by authorization state; collect stale rows.
|
|
597
|
+
|
|
598
|
+
``room_policies`` is the policy list from '_room_policies'.
|
|
599
|
+
"""
|
|
600
|
+
configured = sorted(the_installation._config.room_configs)
|
|
583
601
|
|
|
584
602
|
policies = {
|
|
585
|
-
policy.room_id: policy.default_allow_deny
|
|
586
|
-
for policy in asyncio.run(_list_room_policies(dburi))
|
|
603
|
+
policy.room_id: policy.default_allow_deny for policy in room_policies
|
|
587
604
|
}
|
|
588
605
|
|
|
589
606
|
configured_set = set(configured)
|
|
@@ -606,26 +623,21 @@ def _room_authz_groups(the_installation):
|
|
|
606
623
|
}
|
|
607
624
|
|
|
608
625
|
|
|
609
|
-
def _invalid_acl_json_paths(
|
|
626
|
+
def _invalid_acl_json_paths(room_policies) -> dict:
|
|
610
627
|
"""Collect ACL entries whose stored 'json_path' fails to validate.
|
|
611
628
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
629
|
+
``room_policies`` is the policy list from '_room_policies' -- the
|
|
630
|
+
unchecked models, which tolerate entries that would fail
|
|
631
|
+
'policy.as_model'. Each surfaced 'json_path' is re-validated against
|
|
632
|
+
the currently-loaded JSONPath environment. Typically an entry lands
|
|
633
|
+
here when it was authored under a meta-config that registered filter
|
|
634
|
+
functions which are no longer present.
|
|
618
635
|
|
|
619
636
|
Returns a dict mapping 'room_id' to a list of '(json_path, error)'
|
|
620
|
-
pairs.
|
|
637
|
+
pairs.
|
|
621
638
|
"""
|
|
622
|
-
dburi = the_installation.authorization_dburi_async
|
|
623
|
-
|
|
624
|
-
if dburi in cli_util._RAM_DBURIS:
|
|
625
|
-
return {}
|
|
626
|
-
|
|
627
639
|
invalid: dict = {}
|
|
628
|
-
for policy in
|
|
640
|
+
for policy in room_policies:
|
|
629
641
|
for entry in policy.acl_entries:
|
|
630
642
|
if entry.json_path is None:
|
|
631
643
|
continue
|
|
@@ -651,7 +663,13 @@ def _audit_room_authz_section(
|
|
|
651
663
|
tc_rule("Configured rooms by authorization state")
|
|
652
664
|
tc_line()
|
|
653
665
|
|
|
654
|
-
|
|
666
|
+
room_policies, db_error = _room_policies(the_installation)
|
|
667
|
+
if db_error is not None:
|
|
668
|
+
tc_print(f"ERROR: authorization database unreachable: {db_error}")
|
|
669
|
+
tc_line()
|
|
670
|
+
return {"room_authz": {"unreachable": db_error}}
|
|
671
|
+
|
|
672
|
+
groups = _room_authz_groups(the_installation, room_policies)
|
|
655
673
|
|
|
656
674
|
for label, room_ids in (
|
|
657
675
|
(
|
|
@@ -677,7 +695,7 @@ def _audit_room_authz_section(
|
|
|
677
695
|
tc_print(" (none)")
|
|
678
696
|
tc_line()
|
|
679
697
|
|
|
680
|
-
invalid_acls = _invalid_acl_json_paths(
|
|
698
|
+
invalid_acls = _invalid_acl_json_paths(room_policies)
|
|
681
699
|
tc_print("ACL entries with invalid JSONPath:")
|
|
682
700
|
if invalid_acls:
|
|
683
701
|
for room_id in sorted(invalid_acls):
|
|
@@ -699,36 +717,47 @@ def _audit_room_authz_section(
|
|
|
699
717
|
return errors
|
|
700
718
|
|
|
701
719
|
|
|
702
|
-
def _admin_user_json_paths(
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
720
|
+
def _admin_user_json_paths(
|
|
721
|
+
the_installation,
|
|
722
|
+
) -> tuple[list[str], str | None]:
|
|
723
|
+
"""Return ``(json_paths, error)`` for the stored admin rows.
|
|
724
|
+
|
|
725
|
+
``json_paths`` holds every stored 'AdminUser.json_path', in insertion
|
|
726
|
+
order, read via 'AdminUserPolicy.list_admin_user_discriminators';
|
|
727
|
+
``error`` is ``None`` on success. A RAM-based authz DB can hold no
|
|
728
|
+
persisted rows, so it short-circuits to ``([], None)`` without
|
|
729
|
+
touching the database.
|
|
730
|
+
|
|
731
|
+
When the database itself cannot be reached -- e.g. its DBURI names a
|
|
732
|
+
Postgres server that isn't listening -- ``json_paths`` is empty and
|
|
733
|
+
``error`` carries the exception message, so the audit can report the
|
|
734
|
+
unreachable DB instead of dying on the traceback.
|
|
709
735
|
"""
|
|
710
736
|
dburi = the_installation.authorization_dburi_async
|
|
711
737
|
|
|
712
738
|
if dburi in cli_util._RAM_DBURIS:
|
|
713
|
-
return []
|
|
739
|
+
return [], None
|
|
714
740
|
|
|
715
|
-
|
|
741
|
+
try:
|
|
742
|
+
return list(asyncio.run(_list_admin_discriminators(dburi))), None
|
|
743
|
+
except Exception as exc:
|
|
744
|
+
return [], f"{type(exc).__name__}: {exc}"
|
|
716
745
|
|
|
717
746
|
|
|
718
747
|
def _invalid_admin_user_json_paths(
|
|
719
|
-
|
|
748
|
+
json_paths,
|
|
720
749
|
) -> list[tuple[str, str]]:
|
|
721
750
|
"""Collect admin entries whose 'json_path' fails to validate.
|
|
722
751
|
|
|
723
752
|
Same intent as '_invalid_acl_json_paths' but for the 'AdminUser'
|
|
724
753
|
table: a row typically lands here when it was authored under a
|
|
725
754
|
meta-config that registered filter functions which are no longer
|
|
726
|
-
present.
|
|
755
|
+
present. ``json_paths`` is the list from '_admin_user_json_paths'.
|
|
727
756
|
|
|
728
757
|
Returns a list of '(json_path, error)' pairs.
|
|
729
758
|
"""
|
|
730
759
|
invalid: list[tuple[str, str]] = []
|
|
731
|
-
for json_path in
|
|
760
|
+
for json_path in json_paths:
|
|
732
761
|
try:
|
|
733
762
|
authz.validate_json_path(json_path)
|
|
734
763
|
except authz.InvalidJSONPath as exc:
|
|
@@ -749,7 +778,12 @@ def _audit_admin_users_section(
|
|
|
749
778
|
tc_rule("Configured admin users")
|
|
750
779
|
tc_line()
|
|
751
780
|
|
|
752
|
-
json_paths = _admin_user_json_paths(the_installation)
|
|
781
|
+
json_paths, db_error = _admin_user_json_paths(the_installation)
|
|
782
|
+
if db_error is not None:
|
|
783
|
+
tc_print(f"ERROR: authorization database unreachable: {db_error}")
|
|
784
|
+
tc_line()
|
|
785
|
+
return {"admin_users": {"unreachable": db_error}}
|
|
786
|
+
|
|
753
787
|
tc_print(f"Admin users ({len(json_paths)}):")
|
|
754
788
|
if json_paths:
|
|
755
789
|
for json_path in json_paths:
|
|
@@ -763,7 +797,7 @@ def _audit_admin_users_section(
|
|
|
763
797
|
tc_print(" (none)")
|
|
764
798
|
tc_line()
|
|
765
799
|
|
|
766
|
-
invalid = _invalid_admin_user_json_paths(
|
|
800
|
+
invalid = _invalid_admin_user_json_paths(json_paths)
|
|
767
801
|
tc_print("Admin users with invalid JSONPath:")
|
|
768
802
|
if invalid:
|
|
769
803
|
for json_path, error in invalid:
|
|
@@ -177,10 +177,13 @@ class _RAGDatabaseBase:
|
|
|
177
177
|
try:
|
|
178
178
|
rag_lancedb_path = self.rag_lancedb_path
|
|
179
179
|
except RagDbFileNotFound as exc:
|
|
180
|
-
|
|
180
|
+
missing = exc.rag_db_filename
|
|
181
|
+
db_name = f"MISSING: {missing.stem}"
|
|
182
|
+
else:
|
|
183
|
+
db_name = rag_lancedb_path.stem
|
|
181
184
|
|
|
182
185
|
return {
|
|
183
|
-
"
|
|
186
|
+
"database_names": [db_name],
|
|
184
187
|
}
|
|
185
188
|
|
|
186
189
|
|
|
@@ -177,7 +177,7 @@ class FilesystemSkillConfig:
|
|
|
177
177
|
|
|
178
178
|
@property
|
|
179
179
|
def extra_parameters(self) -> dict[str, pathlib.Path]:
|
|
180
|
-
return {
|
|
180
|
+
return {}
|
|
181
181
|
|
|
182
182
|
def with_defer_loading(self, defer_loading: bool) -> FilesystemSkillConfig:
|
|
183
183
|
"""A copy whose capability loads as a room asked.
|
|
@@ -315,6 +315,10 @@ class ToolConfig:
|
|
|
315
315
|
def get_extra_parameters(self) -> dict:
|
|
316
316
|
return {}
|
|
317
317
|
|
|
318
|
+
@property
|
|
319
|
+
def extra_parameters(self) -> dict:
|
|
320
|
+
return self.get_extra_parameters()
|
|
321
|
+
|
|
318
322
|
|
|
319
323
|
SDTC_TOOL_NAME = "soliplex.tools.rag.search_documents"
|
|
320
324
|
_, SDTC_TOOL_KIND = SDTC_TOOL_NAME.rsplit(".", 1)
|
|
@@ -111,7 +111,7 @@ class Tool(pydantic.BaseModel):
|
|
|
111
111
|
tool_requires=tool_config.tool_requires,
|
|
112
112
|
allow_mcp=tool_config.allow_mcp,
|
|
113
113
|
agui_feature_names=list(tool_config.agui_feature_names),
|
|
114
|
-
extra_parameters=tool_config.
|
|
114
|
+
extra_parameters=tool_config.extra_parameters,
|
|
115
115
|
)
|
|
116
116
|
|
|
117
117
|
|
|
@@ -419,63 +419,102 @@ async def skill_run_python(
|
|
|
419
419
|
return str(output)
|
|
420
420
|
|
|
421
421
|
|
|
422
|
+
class InvalidSubdir(ValueError):
|
|
423
|
+
def __init__(self, path, expected_parent):
|
|
424
|
+
self.path = path
|
|
425
|
+
self.expected_parent = expected_parent
|
|
426
|
+
super().__init__(f"{path} not a subdir of {expected_parent}")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _check_is_subdir(path: pathlib.Path, expected_parent: pathlib.Path):
|
|
430
|
+
ep_resolved = expected_parent.resolve()
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
resolved = path.resolve()
|
|
434
|
+
except ValueError as exc:
|
|
435
|
+
raise InvalidSubdir(path, ep_resolved) from exc
|
|
436
|
+
else:
|
|
437
|
+
if resolved.parent == ep_resolved:
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
raise InvalidSubdir(path, ep_resolved)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _check_subdirs(base: pathlib.Path, paths: list[str]) -> pathlib.Path:
|
|
444
|
+
current = base
|
|
445
|
+
for path in paths:
|
|
446
|
+
on_deck = current / path
|
|
447
|
+
_check_is_subdir(on_deck, current)
|
|
448
|
+
current = on_deck
|
|
449
|
+
return current.resolve()
|
|
450
|
+
|
|
451
|
+
|
|
422
452
|
def get_workdir(
|
|
423
453
|
workdirs_path: pathlib.Path | None,
|
|
424
|
-
room_id: str,
|
|
425
|
-
thread_id: str,
|
|
426
|
-
run_id: str,
|
|
454
|
+
room_id: str | None,
|
|
455
|
+
thread_id: str | None,
|
|
456
|
+
run_id: str | None,
|
|
427
457
|
):
|
|
428
|
-
if
|
|
429
|
-
|
|
458
|
+
if (
|
|
459
|
+
workdirs_path is not None
|
|
460
|
+
and room_id is not None
|
|
461
|
+
and thread_id is not None
|
|
462
|
+
and run_id is not None
|
|
463
|
+
):
|
|
464
|
+
workdir = _check_subdirs(
|
|
465
|
+
workdirs_path,
|
|
466
|
+
[room_id, str(thread_id), str(run_id)],
|
|
467
|
+
)
|
|
430
468
|
workdir.mkdir(parents=True, exist_ok=True)
|
|
469
|
+
return workdir
|
|
431
470
|
else:
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
return workdir
|
|
471
|
+
return None
|
|
435
472
|
|
|
436
473
|
|
|
437
|
-
def
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
room_id: str,
|
|
441
|
-
thread_id: str,
|
|
474
|
+
def _get_upload_volume(
|
|
475
|
+
upload_path: pathlib.Path | None,
|
|
476
|
+
volume_id: str | None,
|
|
442
477
|
):
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
result["room"] = bs_models.VolumeInfo(
|
|
449
|
-
host_path=room_dir,
|
|
478
|
+
if upload_path is not None and volume_id is not None:
|
|
479
|
+
volume_dir = _check_subdirs(upload_path, [str(volume_id)])
|
|
480
|
+
if volume_dir.exists():
|
|
481
|
+
return bs_models.VolumeInfo(
|
|
482
|
+
host_path=volume_dir,
|
|
450
483
|
writable=False,
|
|
451
484
|
)
|
|
452
485
|
else:
|
|
453
|
-
|
|
486
|
+
return bs_models.VolumeInfo(
|
|
454
487
|
host_path=None,
|
|
455
488
|
writable=False,
|
|
456
489
|
)
|
|
457
490
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
491
|
+
|
|
492
|
+
def get_extra_volumes(
|
|
493
|
+
rooms_upload_path: pathlib.Path | None,
|
|
494
|
+
threads_upload_path: pathlib.Path | None,
|
|
495
|
+
room_id: str | None,
|
|
496
|
+
thread_id: str | None,
|
|
497
|
+
):
|
|
498
|
+
result = {}
|
|
499
|
+
|
|
500
|
+
room_volume = _get_upload_volume(rooms_upload_path, room_id)
|
|
501
|
+
|
|
502
|
+
if room_volume is not None:
|
|
503
|
+
result["room"] = room_volume
|
|
504
|
+
|
|
505
|
+
thread_volume = _get_upload_volume(threads_upload_path, str(thread_id))
|
|
506
|
+
|
|
507
|
+
if thread_volume is not None:
|
|
508
|
+
result["thread"] = thread_volume
|
|
470
509
|
|
|
471
510
|
return result
|
|
472
511
|
|
|
473
512
|
|
|
474
513
|
def write_transcript(
|
|
475
514
|
transcripts_path: pathlib.Path | None,
|
|
476
|
-
room_id: str,
|
|
477
|
-
thread_id: str,
|
|
478
|
-
run_id: str,
|
|
515
|
+
room_id: str | None,
|
|
516
|
+
thread_id: str | None,
|
|
517
|
+
run_id: str | None,
|
|
479
518
|
*,
|
|
480
519
|
content: str,
|
|
481
520
|
suffix: str,
|
|
@@ -488,16 +527,24 @@ def write_transcript(
|
|
|
488
527
|
with the saved transcript. Returns 'None' (writing nothing) when no
|
|
489
528
|
'transcripts_path' is configured.
|
|
490
529
|
"""
|
|
491
|
-
if
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
530
|
+
if (
|
|
531
|
+
transcripts_path is not None
|
|
532
|
+
and room_id is not None
|
|
533
|
+
and thread_id is not None
|
|
534
|
+
and run_id is not None
|
|
535
|
+
):
|
|
536
|
+
run_dir = _check_subdirs(
|
|
537
|
+
transcripts_path,
|
|
538
|
+
[room_id, str(thread_id), str(run_id)],
|
|
539
|
+
)
|
|
540
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
541
|
+
target = run_dir / f"{uuid.uuid4()}{suffix}"
|
|
542
|
+
target.write_text(content, encoding="utf-8")
|
|
543
|
+
target.chmod(0o600)
|
|
499
544
|
|
|
500
|
-
|
|
545
|
+
return str(target)
|
|
546
|
+
else:
|
|
547
|
+
return None
|
|
501
548
|
|
|
502
549
|
|
|
503
550
|
def create_sandbox_toolset(
|
|
@@ -581,19 +628,22 @@ def create_sandbox_toolset(
|
|
|
581
628
|
|
|
582
629
|
else:
|
|
583
630
|
deps = ctx.deps
|
|
584
|
-
room_id = deps.room_id
|
|
585
|
-
thread_id = deps.thread_id
|
|
631
|
+
room_id = deps.room_id
|
|
632
|
+
thread_id = deps.thread_id
|
|
586
633
|
|
|
587
634
|
return await skill_list_volume_files(
|
|
588
635
|
volume=volume,
|
|
589
636
|
room_upload_path=(
|
|
590
|
-
rooms_upload_path
|
|
591
|
-
if rooms_upload_path is not None
|
|
637
|
+
_check_subdirs(rooms_upload_path, [room_id])
|
|
638
|
+
if (rooms_upload_path is not None and room_id is not None)
|
|
592
639
|
else None
|
|
593
640
|
),
|
|
594
641
|
thread_upload_path=(
|
|
595
|
-
threads_upload_path
|
|
596
|
-
if
|
|
642
|
+
_check_subdirs(threads_upload_path, [thread_id])
|
|
643
|
+
if (
|
|
644
|
+
threads_upload_path is not None
|
|
645
|
+
and thread_id is not None
|
|
646
|
+
)
|
|
597
647
|
else None
|
|
598
648
|
),
|
|
599
649
|
)
|
|
@@ -614,16 +664,16 @@ def create_sandbox_toolset(
|
|
|
614
664
|
deps = ctx.deps
|
|
615
665
|
workdir = get_workdir(
|
|
616
666
|
workdirs_path,
|
|
617
|
-
deps.room_id
|
|
618
|
-
deps.thread_id
|
|
619
|
-
deps.run_id
|
|
667
|
+
deps.room_id,
|
|
668
|
+
deps.thread_id,
|
|
669
|
+
deps.run_id,
|
|
620
670
|
)
|
|
621
671
|
|
|
622
672
|
extra_volumes = get_extra_volumes(
|
|
623
673
|
rooms_upload_path,
|
|
624
674
|
threads_upload_path,
|
|
625
|
-
deps.room_id
|
|
626
|
-
deps.thread_id
|
|
675
|
+
deps.room_id,
|
|
676
|
+
deps.thread_id,
|
|
627
677
|
)
|
|
628
678
|
|
|
629
679
|
with sandbox_audit.audit_sandbox_exec(
|
|
@@ -634,9 +684,9 @@ def create_sandbox_toolset(
|
|
|
634
684
|
) as access:
|
|
635
685
|
ref = write_transcript(
|
|
636
686
|
transcripts_path,
|
|
637
|
-
deps.room_id
|
|
638
|
-
deps.thread_id
|
|
639
|
-
deps.run_id
|
|
687
|
+
deps.room_id,
|
|
688
|
+
deps.thread_id,
|
|
689
|
+
deps.run_id,
|
|
640
690
|
content=(
|
|
641
691
|
command
|
|
642
692
|
if isinstance(command, str)
|
|
@@ -672,16 +722,16 @@ def create_sandbox_toolset(
|
|
|
672
722
|
deps = ctx.deps
|
|
673
723
|
workdir = get_workdir(
|
|
674
724
|
workdirs_path,
|
|
675
|
-
deps.room_id
|
|
676
|
-
deps.thread_id
|
|
677
|
-
deps.run_id
|
|
725
|
+
deps.room_id,
|
|
726
|
+
deps.thread_id,
|
|
727
|
+
deps.run_id,
|
|
678
728
|
)
|
|
679
729
|
|
|
680
730
|
extra_volumes = get_extra_volumes(
|
|
681
731
|
rooms_upload_path,
|
|
682
732
|
threads_upload_path,
|
|
683
|
-
deps.room_id
|
|
684
|
-
deps.thread_id
|
|
733
|
+
deps.room_id,
|
|
734
|
+
deps.thread_id,
|
|
685
735
|
)
|
|
686
736
|
|
|
687
737
|
with sandbox_audit.audit_sandbox_exec(
|
|
@@ -692,9 +742,9 @@ def create_sandbox_toolset(
|
|
|
692
742
|
) as access:
|
|
693
743
|
ref = write_transcript(
|
|
694
744
|
transcripts_path,
|
|
695
|
-
deps.room_id
|
|
696
|
-
deps.thread_id
|
|
697
|
-
deps.run_id
|
|
745
|
+
deps.room_id,
|
|
746
|
+
deps.thread_id,
|
|
747
|
+
deps.run_id,
|
|
698
748
|
content=script,
|
|
699
749
|
suffix=".py",
|
|
700
750
|
)
|
|
@@ -99,6 +99,61 @@ async def _check_user_room_agent(
|
|
|
99
99
|
return user_profile, agent
|
|
100
100
|
|
|
101
101
|
|
|
102
|
+
async def _check_thread_ownership(
|
|
103
|
+
*,
|
|
104
|
+
room_id: str,
|
|
105
|
+
thread_id: str,
|
|
106
|
+
run_id: str | None = None,
|
|
107
|
+
the_installation: installation.Installation,
|
|
108
|
+
the_threads: agui.ThreadStorage,
|
|
109
|
+
the_room_authz: authz.RoomAuthorizationPolicy,
|
|
110
|
+
the_user_claims: authn.UserClaims,
|
|
111
|
+
the_logger: loggers.LogWrapper,
|
|
112
|
+
) -> config_rooms.RoomConfig:
|
|
113
|
+
"""Raise if user is not the owner of the thread
|
|
114
|
+
|
|
115
|
+
If 'run_id' is passed, also check that it is a valid member of the thread.
|
|
116
|
+
"""
|
|
117
|
+
user_name = the_user_claims.get("preferred_username", "<unknown>")
|
|
118
|
+
|
|
119
|
+
_room_config = await _check_user_in_room( # raises
|
|
120
|
+
room_id=room_id,
|
|
121
|
+
the_installation=the_installation,
|
|
122
|
+
the_room_authz=the_room_authz,
|
|
123
|
+
the_user_claims=the_user_claims,
|
|
124
|
+
the_logger=the_logger,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
try: # verify user's ownership of the thread
|
|
128
|
+
await the_threads.get_thread(
|
|
129
|
+
user_name=user_name,
|
|
130
|
+
room_id=room_id,
|
|
131
|
+
thread_id=thread_id,
|
|
132
|
+
)
|
|
133
|
+
except agui.AGUI_Exception:
|
|
134
|
+
raise fastapi.HTTPException(
|
|
135
|
+
status_code=404,
|
|
136
|
+
detail="No such thread",
|
|
137
|
+
) from None
|
|
138
|
+
|
|
139
|
+
if run_id is not None:
|
|
140
|
+
try:
|
|
141
|
+
await the_threads.get_run(
|
|
142
|
+
user_name=user_name,
|
|
143
|
+
room_id=room_id,
|
|
144
|
+
thread_id=thread_id,
|
|
145
|
+
run_id=run_id,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
except agui.AGUI_Exception:
|
|
149
|
+
raise fastapi.HTTPException(
|
|
150
|
+
status_code=404,
|
|
151
|
+
detail="No such run",
|
|
152
|
+
) from None
|
|
153
|
+
|
|
154
|
+
return _room_config
|
|
155
|
+
|
|
156
|
+
|
|
102
157
|
@util.logfire_span("GET /v1/rooms/{room_id}/agui")
|
|
103
158
|
@router.get("/v1/rooms/{room_id}/agui")
|
|
104
159
|
async def get_room_agui(
|
|
@@ -732,16 +787,12 @@ async def post_room_agui_thread_id_run_id(
|
|
|
732
787
|
the_logger=the_logger,
|
|
733
788
|
)
|
|
734
789
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
the_room_authz=the_room_authz,
|
|
742
|
-
the_logger=the_logger,
|
|
743
|
-
)
|
|
744
|
-
),
|
|
790
|
+
# This adapter is only used to encode the stream from the database:
|
|
791
|
+
# it doesn't need an 'agent' or a 'run_input' for that job.
|
|
792
|
+
agui_adapter = ai_ag_ui.AGUIAdapter(
|
|
793
|
+
agent=None,
|
|
794
|
+
run_input=None,
|
|
795
|
+
accept=request.headers.get("accept"),
|
|
745
796
|
)
|
|
746
797
|
|
|
747
798
|
db_event_stream = streaming_views.stream_from_db(
|
|
@@ -787,6 +838,17 @@ async def post_room_agui_thread_id_run_id(
|
|
|
787
838
|
agent=agent,
|
|
788
839
|
)
|
|
789
840
|
|
|
841
|
+
# Ensure IDs in request body match the checked URL path IDs.
|
|
842
|
+
# The values must match the ones minted / serialized earlier
|
|
843
|
+
# by us *exactly*: client must return them verbatim. See #1325.
|
|
844
|
+
run_input = agui_adapter.run_input
|
|
845
|
+
|
|
846
|
+
if thread_id != run_input.thread_id or run_id != run_input.run_id:
|
|
847
|
+
raise fastapi.HTTPException(
|
|
848
|
+
status_code=400,
|
|
849
|
+
detail="Invalid run input",
|
|
850
|
+
)
|
|
851
|
+
|
|
790
852
|
try:
|
|
791
853
|
async with sqla_asyncio.AsyncSession(
|
|
792
854
|
bind=request.state.threads_engine,
|
|
@@ -110,7 +110,13 @@ async def get_uploads_room_filename(
|
|
|
110
110
|
the_user_claims: authn_package.UserClaims = depend_the_user_claims,
|
|
111
111
|
the_logger: loggers.LogWrapper = depend_the_logger,
|
|
112
112
|
) -> str: # file path, converted to file response by FastAPI
|
|
113
|
-
"""Download a file from the room uploads directory
|
|
113
|
+
"""Download a file from the room uploads directory
|
|
114
|
+
|
|
115
|
+
Unlike sandbox workdir files, which are created by a possibly-
|
|
116
|
+
hallucinating or mischievous agent, files in the room-uploads tree
|
|
117
|
+
are under purview of the admin / operator: symlinks to share e.g.
|
|
118
|
+
large datasets between rooms may be intentional and appropriate.
|
|
119
|
+
"""
|
|
114
120
|
the_logger.debug(loggers.UPLOADS_GET_ROOM_FILE)
|
|
115
121
|
|
|
116
122
|
_room_config = await soliplex_views_agui._check_user_in_room(
|
|
@@ -134,7 +140,7 @@ async def get_uploads_room_filename(
|
|
|
134
140
|
if not room_dir.is_dir():
|
|
135
141
|
raise fastapi.HTTPException(
|
|
136
142
|
status_code=404,
|
|
137
|
-
detail="No uploads in room: {room_id}",
|
|
143
|
+
detail=f"No uploads in room: {room_id}",
|
|
138
144
|
)
|
|
139
145
|
|
|
140
146
|
file_path = room_dir / filename
|