soliplex 0.78.1__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.1/src/soliplex.egg-info → soliplex-0.78.2}/PKG-INFO +1 -1
  2. {soliplex-0.78.1 → soliplex-0.78.2}/pyproject.toml +1 -1
  3. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/rag.py +5 -2
  4. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/skills.py +1 -1
  5. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/tools.py +4 -0
  6. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/models.py +1 -1
  7. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/skills/bwrap_sandbox/__init__.py +117 -67
  8. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/views/agui.py +72 -10
  9. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/views/room_file_uploads.py +8 -2
  10. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/views/sandbox_workdirs.py +89 -15
  11. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/views/thread_file_uploads.py +26 -24
  12. {soliplex-0.78.1 → soliplex-0.78.2/src/soliplex.egg-info}/PKG-INFO +1 -1
  13. {soliplex-0.78.1 → soliplex-0.78.2}/LICENSE +0 -0
  14. {soliplex-0.78.1 → soliplex-0.78.2}/MANIFEST.in +0 -0
  15. {soliplex-0.78.1 → soliplex-0.78.2}/README.md +0 -0
  16. {soliplex-0.78.1 → soliplex-0.78.2}/example/skills/bare-bones/SKILL.md +0 -0
  17. {soliplex-0.78.1 → soliplex-0.78.2}/example/skills/stanzas/SKILL.md +0 -0
  18. {soliplex-0.78.1 → soliplex-0.78.2}/setup.cfg +0 -0
  19. {soliplex-0.78.1 → soliplex-0.78.2}/skills/soliplex-docs/SKILL.md +0 -0
  20. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/agents.py +0 -0
  21. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/agui/__init__.py +0 -0
  22. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/agui/parser.py +0 -0
  23. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/agui/persistence.py +0 -0
  24. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/agui/schema.py +0 -0
  25. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/agui/util.py +0 -0
  26. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/authn.py +0 -0
  27. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/authz/__init__.py +0 -0
  28. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/authz/persistence.py +0 -0
  29. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/authz/schema.py +0 -0
  30. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/capabilities/filesystem.py +0 -0
  31. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/capabilities/rag_audit.py +0 -0
  32. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/cli/admin_users.py +0 -0
  33. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/cli/ask.py +0 -0
  34. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/cli/audit.py +0 -0
  35. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/cli/cli_util.py +0 -0
  36. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/cli/main.py +0 -0
  37. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/cli/ollama.py +0 -0
  38. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/cli/room_authz.py +0 -0
  39. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/cli/serve.py +0 -0
  40. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/cli/types.py +0 -0
  41. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/completions.py +0 -0
  42. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/__init__.py +0 -0
  43. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/_utils.py +0 -0
  44. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/agents.py +0 -0
  45. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/agui.py +0 -0
  46. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/authsystem.py +0 -0
  47. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/completions.py +0 -0
  48. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/exceptions.py +0 -0
  49. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/installation.py +0 -0
  50. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/logfire.py +0 -0
  51. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/meta.py +0 -0
  52. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/middleware.py +0 -0
  53. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/quizzes.py +0 -0
  54. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/rooms.py +0 -0
  55. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/routing.py +0 -0
  56. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/config/secrets.py +0 -0
  57. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/examples.py +0 -0
  58. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/installation.py +0 -0
  59. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/log_ingest.py +0 -0
  60. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/loggers.py +0 -0
  61. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/main.py +0 -0
  62. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/mcp_auth.py +0 -0
  63. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/mcp_client.py +0 -0
  64. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/mcp_server.py +0 -0
  65. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/ollama.py +0 -0
  66. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/quizzes.py +0 -0
  67. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/rag_audit.py +0 -0
  68. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/sandbox_audit.py +0 -0
  69. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/secrets.py +0 -0
  70. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/skills/bwrap_sandbox/SKILL.md +0 -0
  71. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/titles.py +0 -0
  72. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/tools/__init__.py +0 -0
  73. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/tools/agui_run_feedback.py +0 -0
  74. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/tools/file_uploads.py +0 -0
  75. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/tools/rag.py +0 -0
  76. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/tui/cli.py +0 -0
  77. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/tui/main.py +0 -0
  78. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/tui/rest_api.py +0 -0
  79. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/tui/serve.py +0 -0
  80. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/util.py +0 -0
  81. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/views/__init__.py +0 -0
  82. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/views/authn.py +0 -0
  83. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/views/authz.py +0 -0
  84. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/views/completions.py +0 -0
  85. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/views/installation.py +0 -0
  86. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/views/log_ingest.py +0 -0
  87. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/views/quizzes.py +0 -0
  88. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/views/rooms.py +0 -0
  89. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/views/stats.py +0 -0
  90. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex/views/streaming.py +0 -0
  91. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex.egg-info/SOURCES.txt +0 -0
  92. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex.egg-info/dependency_links.txt +0 -0
  93. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex.egg-info/entry_points.txt +0 -0
  94. {soliplex-0.78.1 → soliplex-0.78.2}/src/soliplex.egg-info/requires.txt +0 -0
  95. {soliplex-0.78.1 → 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.1
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.1"
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"
@@ -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
@@ -1,10 +1,14 @@
1
+ import os
1
2
  import pathlib
3
+ import stat
4
+ import urllib.parse as url_parse
2
5
 
3
6
  import fastapi
4
7
  import pydantic
5
8
  from fastapi import responses
6
9
 
7
- from soliplex import authn as authn_package
10
+ from soliplex import agui
11
+ from soliplex import authn
8
12
  from soliplex import authz
9
13
  from soliplex import installation
10
14
  from soliplex import loggers
@@ -16,11 +20,20 @@ from soliplex.views import util as soliplex_views_util
16
20
  router = fastapi.APIRouter(tags=["uploads"])
17
21
 
18
22
  depend_the_installation = installation.depend_the_installation
23
+ depend_the_threads = agui.depend_the_threads
19
24
  depend_the_room_authz = views.depend_the_room_authz_policy
20
25
  depend_the_user_claims = views.depend_the_user_claims
21
26
  depend_the_logger = views.depend_the_logger
22
27
 
23
28
 
29
+ CHUNKSIZE = 2**16 # 64k
30
+
31
+ # DO NOT SIMPLIFY HERE:
32
+ # This is the RFC 5987 extended-parameter syntax for a
33
+ # 'Content-Disposition' header value using 'attachment'.
34
+ ATTACHMENT_PREFIX = "attachment; filename*=UTF-8''"
35
+
36
+
24
37
  @soliplex_views_util.logfire_span(
25
38
  "GET /v1/workdirs/{room_id}/thread/{thread_id}/{run_id}",
26
39
  )
@@ -31,8 +44,9 @@ async def get_workdirs_room_thread_run(
31
44
  thread_id: pydantic.UUID4,
32
45
  run_id: pydantic.UUID4,
33
46
  the_installation: installation.Installation = depend_the_installation,
47
+ the_threads: agui.ThreadStorage = depend_the_threads,
34
48
  the_room_authz: authz.RoomAuthorizationPolicy = depend_the_room_authz,
35
- the_user_claims: authn_package.UserClaims = depend_the_user_claims,
49
+ the_user_claims: authn.UserClaims = depend_the_user_claims,
36
50
  the_logger: loggers.LogWrapper = depend_the_logger,
37
51
  ) -> models.RunWorkdirFiles:
38
52
  """Return a list of files uploaded to the thread"""
@@ -41,9 +55,12 @@ async def get_workdirs_room_thread_run(
41
55
 
42
56
  the_logger.debug(loggers.WORKDIRS_GET_ROOM_THREAD_RUN)
43
57
 
44
- _room_config = await soliplex_views_agui._check_user_in_room(
58
+ await soliplex_views_agui._check_thread_ownership(
45
59
  room_id=room_id,
60
+ thread_id=thread_id,
61
+ run_id=run_id,
46
62
  the_installation=the_installation,
63
+ the_threads=the_threads,
47
64
  the_room_authz=the_room_authz,
48
65
  the_user_claims=the_user_claims,
49
66
  the_logger=the_logger,
@@ -62,7 +79,7 @@ async def get_workdirs_room_thread_run(
62
79
 
63
80
  if run_dir.is_dir():
64
81
  for file_or_sub in run_dir.glob("*"):
65
- if file_or_sub.is_file():
82
+ if not file_or_sub.is_symlink() and file_or_sub.is_file():
66
83
  filename = file_or_sub.name
67
84
  filename_urls[filename] = request.url_for(
68
85
  # View function name, not the route path.
@@ -87,13 +104,48 @@ async def get_workdirs_room_thread_run(
87
104
  )
88
105
 
89
106
 
107
+ def _open_no_symlinks(file_path: pathlib.Path):
108
+ """Return a streaming generator for 'file_path'
109
+
110
+ First ensure that it is a regular file (no symlinks, etc.)
111
+ """
112
+ not_found = f"No workdir file: {file_path.name}"
113
+
114
+ try:
115
+ fd = os.open(
116
+ file_path,
117
+ os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC,
118
+ )
119
+ except OSError: # ELOOP for a symlink, ENOENT, EACCES
120
+ raise fastapi.HTTPException(
121
+ status_code=404,
122
+ detail=not_found,
123
+ ) from None
124
+
125
+ st = os.fstat(fd)
126
+ if not stat.S_ISREG(st.st_mode): # directory, FIFO, device, socket
127
+ os.close(fd)
128
+ raise fastapi.HTTPException(status_code=404, detail=not_found)
129
+
130
+ def _stream(): # now owns the fd
131
+ with os.fdopen(fd, "rb") as fh:
132
+ while chunk := fh.read(CHUNKSIZE):
133
+ yield chunk
134
+
135
+ return _stream, st.st_size
136
+
137
+
138
+ def _disposition(file_path: pathlib.Path) -> str:
139
+ attachment_name = url_parse.quote(file_path.name, safe="")
140
+ return f"{ATTACHMENT_PREFIX}{attachment_name}"
141
+
142
+
90
143
  @soliplex_views_util.logfire_span(
91
144
  "GET "
92
145
  "/v1/workdirs/{room_id}/thread/{thread_id}/run/{run_id}/file/{filename}"
93
146
  )
94
147
  @router.get(
95
148
  "/v1/workdirs/{room_id}/thread/{thread_id}/run/{run_id}/file/{filename}",
96
- response_class=responses.FileResponse,
97
149
  )
98
150
  async def get_workdirs_room_thread_run_filename(
99
151
  room_id: str,
@@ -101,19 +153,28 @@ async def get_workdirs_room_thread_run_filename(
101
153
  run_id: pydantic.UUID4,
102
154
  filename: str,
103
155
  the_installation: installation.Installation = depend_the_installation,
156
+ the_threads: agui.ThreadStorage = depend_the_threads,
104
157
  the_room_authz: authz.RoomAuthorizationPolicy = depend_the_room_authz,
105
- the_user_claims: authn_package.UserClaims = depend_the_user_claims,
158
+ the_user_claims: authn.UserClaims = depend_the_user_claims,
106
159
  the_logger: loggers.LogWrapper = depend_the_logger,
107
- ) -> str: # file path, converted to file response by FastAPI
108
- """Return a list of files uploaded to the thread"""
160
+ ) -> responses.StreamingResponse:
161
+ """Return a file uploaded to the thread.
162
+
163
+ In order to prevent an agent-created symlink from exposing a file
164
+ not in the workdir, this view returns a streaming response, rather
165
+ than using `fastapi`s 'FileResponse-from-filename' affordance.
166
+ """
109
167
  thread_id = str(thread_id)
110
168
  run_id = str(run_id)
111
169
 
112
170
  the_logger.debug(loggers.WORKDIRS_GET_ROOM_THREAD_RUN_FILE)
113
171
 
114
- _room_config = await soliplex_views_agui._check_user_in_room(
172
+ await soliplex_views_agui._check_thread_ownership(
115
173
  room_id=room_id,
174
+ thread_id=thread_id,
175
+ run_id=run_id,
116
176
  the_installation=the_installation,
177
+ the_threads=the_threads,
117
178
  the_room_authz=the_room_authz,
118
179
  the_user_claims=the_user_claims,
119
180
  the_logger=the_logger,
@@ -130,10 +191,23 @@ async def get_workdirs_room_thread_run_filename(
130
191
  run_dir = pathlib.Path(workdirs_path) / room_id / thread_id / run_id
131
192
 
132
193
  file_path = run_dir / filename
133
- if not file_path.is_file():
134
- raise fastapi.HTTPException(
135
- status_code=404,
136
- detail=f"No workdir file: {filename}",
137
- )
138
194
 
139
- return str(file_path)
195
+ streamer, size = _open_no_symlinks(file_path)
196
+
197
+ stream = streamer()
198
+
199
+ # Declare one fixed, inert type rather than guessing from the
200
+ # filename as 'FileResponse' did: the name is chosen by
201
+ # model-authored code, so a guess would hand the browser a
202
+ # content type derived from untrusted input. Together with the
203
+ # 'attachment' disposition and 'nosniff', this makes the browser
204
+ # download the file and never interpret it.
205
+ return responses.StreamingResponse(
206
+ stream,
207
+ media_type="application/octet-stream",
208
+ headers={
209
+ "content-length": str(size),
210
+ "content-disposition": _disposition(file_path),
211
+ "x-content-type-options": "nosniff",
212
+ },
213
+ )
@@ -5,7 +5,7 @@ import pydantic
5
5
  from fastapi import responses
6
6
 
7
7
  from soliplex import agui
8
- from soliplex import authn as authn_package
8
+ from soliplex import authn
9
9
  from soliplex import authz
10
10
  from soliplex import installation
11
11
  from soliplex import loggers
@@ -32,18 +32,21 @@ async def get_uploads_room_id_thread_thread_id(
32
32
  room_id: str,
33
33
  thread_id: pydantic.UUID4,
34
34
  the_installation: installation.Installation = depend_the_installation,
35
+ the_threads: agui.ThreadStorage = depend_the_threads,
35
36
  the_room_authz: authz.RoomAuthorizationPolicy = depend_the_room_authz,
36
- the_user_claims: authn_package.UserClaims = depend_the_user_claims,
37
+ the_user_claims: authn.UserClaims = depend_the_user_claims,
37
38
  the_logger: loggers.LogWrapper = depend_the_logger,
38
- ) -> models.RoomUploads:
39
+ ) -> models.ThreadUploads:
39
40
  """Return a list of files uploaded to the thread"""
40
41
  thread_id = str(thread_id)
41
42
 
42
43
  the_logger.debug(loggers.UPLOADS_GET_ROOM_THREAD)
43
44
 
44
- _room_config = await soliplex_views_agui._check_user_in_room(
45
+ await soliplex_views_agui._check_thread_ownership(
45
46
  room_id=room_id,
47
+ thread_id=thread_id,
46
48
  the_installation=the_installation,
49
+ the_threads=the_threads,
47
50
  the_room_authz=the_room_authz,
48
51
  the_user_claims=the_user_claims,
49
52
  the_logger=the_logger,
@@ -97,18 +100,28 @@ async def get_uploads_room_id_thread_thread_id_filename(
97
100
  thread_id: pydantic.UUID4,
98
101
  filename: str,
99
102
  the_installation: installation.Installation = depend_the_installation,
103
+ the_threads: agui.ThreadStorage = depend_the_threads,
100
104
  the_room_authz: authz.RoomAuthorizationPolicy = depend_the_room_authz,
101
- the_user_claims: authn_package.UserClaims = depend_the_user_claims,
105
+ the_user_claims: authn.UserClaims = depend_the_user_claims,
102
106
  the_logger: loggers.LogWrapper = depend_the_logger,
103
107
  ) -> str: # file path, converted to file response by FastAPI
104
- """Download a file from the room uploads directory"""
108
+ """Download a file from the thread uploads directory
109
+
110
+ Unlike sandbox workdir files, which are created by a possibly-
111
+ hallucinating or mischievous agent, files in the thread-uploads tree
112
+ are durable. The operator may choose to add a symlink into a particular
113
+ thread's directory to share a large dataset, without requiring that
114
+ the user download it from somewhere and then re-upload it here.
115
+ """
105
116
  thread_id = str(thread_id)
106
117
 
107
118
  the_logger.debug(loggers.UPLOADS_GET_ROOM_THREAD_FILE)
108
119
 
109
- _room_config = await soliplex_views_agui._check_user_in_room(
120
+ await soliplex_views_agui._check_thread_ownership(
110
121
  room_id=room_id,
122
+ thread_id=thread_id,
111
123
  the_installation=the_installation,
124
+ the_threads=the_threads,
112
125
  the_room_authz=the_room_authz,
113
126
  the_user_claims=the_user_claims,
114
127
  the_logger=the_logger,
@@ -127,7 +140,7 @@ async def get_uploads_room_id_thread_thread_id_filename(
127
140
  if not thread_dir.is_dir():
128
141
  raise fastapi.HTTPException(
129
142
  status_code=404,
130
- detail="No uploads in thread: {thread_id}",
143
+ detail=f"No uploads in thread: {thread_id}",
131
144
  )
132
145
 
133
146
  file_path = thread_dir / filename
@@ -152,7 +165,7 @@ async def post_uploads_room_id_thread_thread_id(
152
165
  the_installation: installation.Installation = depend_the_installation,
153
166
  the_threads: agui.ThreadStorage = depend_the_threads,
154
167
  the_room_authz: authz.RoomAuthorizationPolicy = depend_the_room_authz,
155
- the_user_claims: authn_package.UserClaims = depend_the_user_claims,
168
+ the_user_claims: authn.UserClaims = depend_the_user_claims,
156
169
  the_logger: loggers.LogWrapper = depend_the_logger,
157
170
  ) -> fastapi.Response:
158
171
  """Upload a file for a thread within the given room
@@ -164,16 +177,17 @@ async def post_uploads_room_id_thread_thread_id(
164
177
 
165
178
  the_logger.debug(loggers.UPLOADS_POST_ROOM_THREAD)
166
179
 
167
- user_name = the_user_claims.get("preferred_username", "<unknown>")
168
- _room_config = await soliplex_views_agui._check_user_in_room(
180
+ room_config = await soliplex_views_agui._check_thread_ownership(
169
181
  room_id=room_id,
182
+ thread_id=thread_id,
170
183
  the_installation=the_installation,
184
+ the_threads=the_threads,
171
185
  the_room_authz=the_room_authz,
172
186
  the_user_claims=the_user_claims,
173
187
  the_logger=the_logger,
174
188
  )
175
189
 
176
- if not _room_config.has_sandbox:
190
+ if not room_config.has_sandbox:
177
191
  raise fastapi.HTTPException(
178
192
  status_code=405,
179
193
  detail="Sandbox not configured",
@@ -189,18 +203,6 @@ async def post_uploads_room_id_thread_thread_id(
189
203
  headers={"Allow": "GET"},
190
204
  )
191
205
 
192
- try:
193
- await the_threads.get_thread(
194
- user_name=user_name,
195
- room_id=room_id,
196
- thread_id=thread_id,
197
- )
198
- except agui.AGUI_Exception as exc:
199
- raise fastapi.HTTPException(
200
- status_code=exc.status_code,
201
- detail=exc.args,
202
- ) from None
203
-
204
206
  thread_dir = pathlib.Path(uploads_path) / thread_id
205
207
  thread_dir.mkdir(parents=True, exist_ok=True)
206
208
  stripped_filename = pathlib.Path(upload_file.filename).name
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soliplex
3
- Version: 0.78.1
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
File without changes
File without changes
File without changes
File without changes