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.
Files changed (95) hide show
  1. {soliplex-0.78/src/soliplex.egg-info → soliplex-0.78.2}/PKG-INFO +1 -1
  2. {soliplex-0.78 → soliplex-0.78.2}/pyproject.toml +1 -1
  3. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/audit.py +75 -41
  4. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/rag.py +5 -2
  5. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/skills.py +1 -1
  6. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/tools.py +4 -0
  7. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/models.py +1 -1
  8. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/skills/bwrap_sandbox/__init__.py +117 -67
  9. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/agui.py +72 -10
  10. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/room_file_uploads.py +8 -2
  11. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/sandbox_workdirs.py +89 -15
  12. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/thread_file_uploads.py +26 -24
  13. {soliplex-0.78 → soliplex-0.78.2/src/soliplex.egg-info}/PKG-INFO +1 -1
  14. {soliplex-0.78 → soliplex-0.78.2}/LICENSE +0 -0
  15. {soliplex-0.78 → soliplex-0.78.2}/MANIFEST.in +0 -0
  16. {soliplex-0.78 → soliplex-0.78.2}/README.md +0 -0
  17. {soliplex-0.78 → soliplex-0.78.2}/example/skills/bare-bones/SKILL.md +0 -0
  18. {soliplex-0.78 → soliplex-0.78.2}/example/skills/stanzas/SKILL.md +0 -0
  19. {soliplex-0.78 → soliplex-0.78.2}/setup.cfg +0 -0
  20. {soliplex-0.78 → soliplex-0.78.2}/skills/soliplex-docs/SKILL.md +0 -0
  21. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/agents.py +0 -0
  22. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/agui/__init__.py +0 -0
  23. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/agui/parser.py +0 -0
  24. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/agui/persistence.py +0 -0
  25. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/agui/schema.py +0 -0
  26. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/agui/util.py +0 -0
  27. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/authn.py +0 -0
  28. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/authz/__init__.py +0 -0
  29. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/authz/persistence.py +0 -0
  30. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/authz/schema.py +0 -0
  31. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/capabilities/filesystem.py +0 -0
  32. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/capabilities/rag_audit.py +0 -0
  33. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/admin_users.py +0 -0
  34. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/ask.py +0 -0
  35. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/cli_util.py +0 -0
  36. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/main.py +0 -0
  37. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/ollama.py +0 -0
  38. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/room_authz.py +0 -0
  39. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/serve.py +0 -0
  40. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/cli/types.py +0 -0
  41. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/completions.py +0 -0
  42. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/__init__.py +0 -0
  43. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/_utils.py +0 -0
  44. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/agents.py +0 -0
  45. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/agui.py +0 -0
  46. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/authsystem.py +0 -0
  47. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/completions.py +0 -0
  48. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/exceptions.py +0 -0
  49. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/installation.py +0 -0
  50. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/logfire.py +0 -0
  51. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/meta.py +0 -0
  52. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/middleware.py +0 -0
  53. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/quizzes.py +0 -0
  54. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/rooms.py +0 -0
  55. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/routing.py +0 -0
  56. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/config/secrets.py +0 -0
  57. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/examples.py +0 -0
  58. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/installation.py +0 -0
  59. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/log_ingest.py +0 -0
  60. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/loggers.py +0 -0
  61. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/main.py +0 -0
  62. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/mcp_auth.py +0 -0
  63. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/mcp_client.py +0 -0
  64. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/mcp_server.py +0 -0
  65. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/ollama.py +0 -0
  66. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/quizzes.py +0 -0
  67. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/rag_audit.py +0 -0
  68. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/sandbox_audit.py +0 -0
  69. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/secrets.py +0 -0
  70. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/skills/bwrap_sandbox/SKILL.md +0 -0
  71. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/titles.py +0 -0
  72. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/tools/__init__.py +0 -0
  73. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/tools/agui_run_feedback.py +0 -0
  74. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/tools/file_uploads.py +0 -0
  75. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/tools/rag.py +0 -0
  76. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/tui/cli.py +0 -0
  77. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/tui/main.py +0 -0
  78. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/tui/rest_api.py +0 -0
  79. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/tui/serve.py +0 -0
  80. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/util.py +0 -0
  81. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/__init__.py +0 -0
  82. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/authn.py +0 -0
  83. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/authz.py +0 -0
  84. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/completions.py +0 -0
  85. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/installation.py +0 -0
  86. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/log_ingest.py +0 -0
  87. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/quizzes.py +0 -0
  88. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/rooms.py +0 -0
  89. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/stats.py +0 -0
  90. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex/views/streaming.py +0 -0
  91. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex.egg-info/SOURCES.txt +0 -0
  92. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex.egg-info/dependency_links.txt +0 -0
  93. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex.egg-info/entry_points.txt +0 -0
  94. {soliplex-0.78 → soliplex-0.78.2}/src/soliplex.egg-info/requires.txt +0 -0
  95. {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 _room_authz_groups(the_installation):
572
- """Bucket configured rooms by authorization state; collect stale rows."""
573
- configured = sorted(the_installation._config.room_configs)
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
- "default": list(configured),
579
- "public": [],
580
- "private": [],
581
- "stale": [],
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(the_installation) -> dict:
626
+ def _invalid_acl_json_paths(room_policies) -> dict:
610
627
  """Collect ACL entries whose stored 'json_path' fails to validate.
611
628
 
612
- Reads every room policy via 'RoomAuthorizationPolicy.list_room_policies'
613
- -- which returns the unchecked models, tolerating entries that would
614
- fail 'policy.as_model' -- and re-validates each surfaced 'json_path'
615
- against the currently-loaded JSONPath environment. Typically an
616
- entry lands here when it was authored under a meta-config that
617
- registered filter functions which are no longer present.
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. A RAM-based authz DB has no persisted rows and returns '{}'.
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 asyncio.run(_list_room_policies(dburi)):
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
- groups = _room_authz_groups(the_installation)
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(the_installation)
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(the_installation) -> list[str]:
703
- """Return every stored 'AdminUser.json_path', in insertion order.
704
-
705
- Reads them via
706
- 'AdminUserPolicy.list_admin_user_discriminators'. Returns an
707
- empty list when the authz DB is RAM-based (no persisted rows can
708
- exist).
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
- return asyncio.run(_list_admin_discriminators(dburi))
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
- the_installation,
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 _admin_user_json_paths(the_installation):
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(the_installation)
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
- rag_lancedb_path = f"MISSING: {exc.rag_db_filename}"
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
- "rag_lancedb_path": rag_lancedb_path,
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 {"path": self.path}
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.get_extra_parameters(),
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 workdirs_path is not None:
429
- workdir = workdirs_path / room_id / str(thread_id) / str(run_id)
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
- workdir = None
433
-
434
- return workdir
471
+ return None
435
472
 
436
473
 
437
- def get_extra_volumes(
438
- rooms_upload_path: pathlib.Path | None,
439
- threads_upload_path: pathlib.Path | None,
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
- result = {}
444
-
445
- if rooms_upload_path is not None:
446
- room_dir = rooms_upload_path / room_id
447
- if room_dir.exists():
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
- result["room"] = bs_models.VolumeInfo(
486
+ return bs_models.VolumeInfo(
454
487
  host_path=None,
455
488
  writable=False,
456
489
  )
457
490
 
458
- if threads_upload_path is not None:
459
- thread_dir = threads_upload_path / str(thread_id)
460
- if thread_dir.exists():
461
- result["thread"] = bs_models.VolumeInfo(
462
- host_path=thread_dir,
463
- writable=False,
464
- )
465
- else:
466
- result["thread"] = bs_models.VolumeInfo(
467
- host_path=None,
468
- writable=False,
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 transcripts_path is None:
492
- return None
493
-
494
- run_dir = transcripts_path / room_id / str(thread_id) / str(run_id)
495
- run_dir.mkdir(parents=True, exist_ok=True)
496
- target = run_dir / f"{uuid.uuid4()}{suffix}"
497
- target.write_text(content, encoding="utf-8")
498
- target.chmod(0o600)
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
- return str(target)
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 or ""
585
- thread_id = deps.thread_id or ""
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 / room_id
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 / thread_id
596
- if threads_upload_path is not None
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 or "",
618
- deps.thread_id or "",
619
- deps.run_id or "",
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 or "",
626
- deps.thread_id or "",
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 or "",
638
- deps.thread_id or "",
639
- deps.run_id or "",
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 or "",
676
- deps.thread_id or "",
677
- deps.run_id or "",
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 or "",
684
- deps.thread_id or "",
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 or "",
696
- deps.thread_id or "",
697
- deps.run_id or "",
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
- agui_adapter = await ai_ag_ui.AGUIAdapter.from_request(
736
- request=request,
737
- agent=(
738
- await the_installation.get_agent_for_room(
739
- room_id=room_id,
740
- user=the_user_claims,
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