Compare commits

...
24 Commits
Author SHA1 Message Date
praison a72e156c4d Release v4.6.40 2026-05-19 07:23:30 +01:00
Mervin Praison 24385d6487 Merge pull request #1686 from MervinPraison/security/batch-12-remaining
refactor: security batch 3 — platform service scoping (do not auto-merge)
2026-05-19 07:21:49 +01:00
praisonai-triage-agent[bot]andMervin Praison 2f8162207f security: fix workspace scoping gaps in issue service
- Add workspace_id parameter to assign() and transition() methods
- Fix SECURITY_TRIAGE.md duplicate entries and formatting
- Ensures cross-tenant mutations are prevented

Co-authored-by: Mervin Praison <[email protected]>
2026-05-19 06:07:52 +00:00
praisonandCursor 516d8b6d88 refactor: enforce workspace scope in platform services
Add workspace_id checks to issue, project, and agent services;
restrict admin/owner role assignment to owners; default platform bind
to 127.0.0.1; scope dependency deletes to parent issue.

Co-authored-by: Cursor <[email protected]>
2026-05-19 07:01:41 +01:00
praisonandCursor 7eb98e895b chore: add security release and advisory publish scripts
Co-authored-by: Cursor <[email protected]>
2026-05-19 06:57:05 +01:00
Mervin Praison ef79b7a056 Merge pull request #1685 from MervinPraison/security/batch-11-hardening
refactor: security batch 2 — platform IDOR and deploy hardening
2026-05-19 06:56:40 +01:00
praisonandCursor 3ea8376610 refactor: harden platform scoping and deploy output sanitisation
Extend workspace resource checks to projects, labels, dependencies, and
activity; sanitise legacy deploy HTML output; harden example eval paths;
fix workspace delete membership cleanup; add security regression tests.

Co-authored-by: Cursor <[email protected]>
2026-05-19 06:56:26 +01:00
MervinPraison 6360b24c06 docs: auto-update feature parity trackers [skip ci] 2026-05-19 05:54:22 +00:00
Mervin Praison b0d8f77752 Merge pull request #1684 from MervinPraison/security/batch-10-hardening
refactor: security batch — 10 hardening fixes
2026-05-19 06:53:57 +01:00
praisonandCursor 179cab02db refactor: harden input validation and access controls
Batch security hardening across agents, wrapper, and platform:
sandbox AST checks, SSRF URL validation, MCP path containment,
fail-closed call-server auth, write_file workspace bounds, platform
IDOR scoping, workspace RBAC, GHA branch quoting, and JWT issuance guard.

Co-authored-by: Cursor <[email protected]>
2026-05-19 06:34:42 +01:00
praison 402d7ed9fc Release v4.6.39 2026-05-19 06:28:39 +01:00
701e8e6a6a fix: wrapper layer architectural fixes for concurrency safety (#1680) (#1681)
* fix: wrapper layer architectural fixes for concurrency safety (#1680)

- Replace _run_praisonai event loop creation with _async_bridge.run_sync to prevent deadlocks in nested async contexts
- Remove module-level OpenAI client LRU cache that evicts in-use clients; move to per-instance ownership in BaseAutoGenerator
- Replace inbuilt_tools custom lazy-import scaffolding with existing _load_optional pattern to eliminate DRY violations

Fixes #1680

Co-authored-by: MervinPraison <[email protected]>

* test: align lazy-loading tests with per-instance OpenAI client ownership

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/682c9dfc-e131-45a2-816e-05044bc4a04a

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback on concurrency and error handling

- Fix race condition in OpenAI client initialization/teardown with proper locking
- Fix AttributeError in __del__ method for partially initialized objects
- Fix RuntimeError swallowing in InteractiveRuntime exception handling to preserve fail-fast semantics
- Fix PRAISONAI_TOOLS_AVAILABLE coupling to allow tools with just praisonai_tools package
- Add missing trailing newline to inbuilt_tools/__init__.py

Addresses feedback from CodeRabbit, Greptile, and Copilot code reviews.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-18 16:35:04 +01:00
MervinPraison ef09341cc0 docs: auto-update feature parity trackers [skip ci] 2026-05-18 15:34:14 +00:00
Jonathan Hill 3a4d7e11c8 fix: guard against empty/filtered LLM responses in core LLM paths (#1682)
Add null checks before accessing choices[0].message in four files:
- openai_client.py: sync/async tool-call paths and responses-API path
- guardrails.py: check/aguard result extraction
- rag.py: query_rag and aquery_rag answer extraction
- code_agent.py: generate/review/explain/refactor/fix methods
2026-05-18 16:33:32 +01:00
92644518c0 fix: wrapper layer architectural fixes for concurrency safety (#1673)
* fix: wrapper layer architectural fixes for concurrency safety

- Fix InMemoryJobStore: Add locks to all read methods to prevent inconsistent snapshots under concurrency
- Fix scheduler retry: Replace uninterruptible time.sleep with Event.wait for responsive stop()
- Fix ToolRegistry: Add threading.Lock synchronization to match other registries

Fixes #1672

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback - dead code, missing lock, unused import, tests

- Remove broken try-except in _get_lock() (loop var was unused, second call
  would have re-raised the same RuntimeError) - per Greptile P2 inline comment
- Protect get_stats() with list() snapshot to prevent RuntimeError on concurrent
  dict mutation - per Greptile P1 finding
- Remove unused `import time` from agent_scheduler.py (time.sleep was replaced
  with Event.wait() in the PR changes)
- Add newline at end of tool_registry.py
- Fix existing retry tests that patched time.sleep (now gone) to mock
  _stop_event.wait() instead; add two new tests that verify Event.wait behavior
- Add TestToolRegistryThreadSafety tests for concurrent register/read/clear
- Add TestInMemoryJobStoreConcurrency tests for concurrent async saves/reads
  and get_stats() snapshot correctness

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/82f4b33b-1b67-4451-be2c-4f53c57bdcf1

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback - dead code, missing lock, unused import, tests

Fixes identified by Greptile, CodeRabbit, and Copilot reviewers:

1. InMemoryJobStore.get_stats() - Added missing async lock protection
   - Method now takes snapshot under lock to prevent concurrent mutation
   - Made get_stats() async to match other store methods

2. Simplified _get_lock() implementation
   - Removed dead try-except code that had unused 'loop' variable
   - Kept lazy initialization pattern for Python 3.9 compatibility

3. Validated ToolRegistry threading safety
   - Confirmed threading.Lock is correct (not asyncio.Lock)
   - ToolRegistry is used in sync contexts, not async
   - Thread safety tests pass

All syntax checks and focused tests pass.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-17 04:17:48 +01:00
cd54574b20 fix: Resolve architectural gaps in registry fragmentation and singleton patterns (#1675)
* fix: Resolve architectural gaps in registry fragmentation and singleton patterns

This commit addresses three critical architectural violations identified in issue #1674:

**Gap 1 - Registry Consolidation**: Unified four different registry patterns under canonical PluginRegistry:
- Enhanced PluginRegistry with alias support for consistent naming
- Migrated LLMProviderRegistry from class-level singleton to PluginRegistry inheritance
- Converted cli_backends from module-level dict to PluginRegistry pattern
- Refactored StoreRegistry to inherit from PluginRegistry instead of duplicating logic

**Gap 2 - Singleton Elimination**: Removed process-global singletons for multi-agent safety:
- Converted Profiler from __new__ singleton to per-instance with context-aware fallback
- Replaced LLMProviderRegistry._instance with module-level default pattern
- Added create_exec_approval_manager() for per-agent ExecApprovalManager instances
- Refactored telemetry init to avoid process-wide os.environ mutation

**Gap 3 - Async Surface**: Added complete async interface to PraisonAIDB adapter:
- Implemented aon_* async versions of all lifecycle hooks (aon_user_message, etc.)
- Added async context manager support (__aenter__, __aexit__, aclose)
- Graceful fallback: async methods detect store capabilities and use asyncio.to_thread for sync stores

All changes maintain backward compatibility while enabling proper multi-agent isolation and async safety.

Fixes #1674

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <[email protected]>

* fix: Resolve Profiler compatibility issue with per-instance delegation pattern

- Renamed Profiler class to _ProfilerImpl to avoid circular reference
- Created ProfilerCompat delegation wrapper for backward compatibility
- Added context-aware profiler support via get_profiler()
- Fixed all decorators and class-level API calls to work correctly
- Maintains full backward compatibility while enabling per-agent isolation

Resolves critical compatibility issue identified by Copilot review

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-17 04:16:33 +01:00
68035af76d fix(tests): BotOS integration tests use registry unregister (#1669)
* fix(tests): unregister mock bot platforms via BotPlatformRegistry

Co-authored-by: Cursor <[email protected]>

* fix(tests): add exception handling to _unregister_bot_platform helper

Prevents cleanup failures from masking primary test errors by catching
KeyError/ValueError exceptions during platform unregistration.

Addresses Gemini reviewer feedback.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-15 07:49:31 +01:00
MervinPraison c989f2c31e docs: auto-update feature parity trackers [skip ci] 2026-05-14 18:16:52 +00:00
a2c51a0760 fix(praisonaiagents): export execute_code via lazy tools loader (#1666)
* fix(tools): lazy-export execute_code as module function

Co-authored-by: Cursor <[email protected]>

* test(tools): add regression test for execute_code lazy export

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/a9d10f35-24e5-4e53-a064-043ecb6a0b08

Co-authored-by: MervinPraison <[email protected]>

* fix(tools): extend lazy-export pattern to all python code tools

Maps analyze_code, format_code, lint_code, disassemble_code, and python_tools
to direct function imports (None) instead of PythonTools class instantiation.
This ensures consistent lazy loading, fixes module alias imports, and avoids
unnecessary dependency checks until functions are actually called.

Addresses reviewer feedback for architectural consistency.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-14 19:16:21 +01:00
7a601b5373 fix(tests): align framework and datastore mocks with current APIs (#1665)
* fix(tests): patch get_default_registry and session store loader

Co-authored-by: Cursor <[email protected]>

* test: scope datastore impl cache reset via patch.object

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/2a49bd60-577c-45b9-8236-3ee18f853a01

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-14 19:16:16 +01:00
12f430e29c fix(tests): preserve provider API keys from the environment (#1664)
* fix(tests): only set placeholder API keys when unset

Co-authored-by: Cursor <[email protected]>

* test: add focused coverage for setup_test_environment fixture

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0bafd91e-1df1-4422-8ac1-e4a0a8f8ee8c

Co-authored-by: MervinPraison <[email protected]>

* test: dedupe provider key list in fixture coverage

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0bafd91e-1df1-4422-8ac1-e4a0a8f8ee8c

Co-authored-by: MervinPraison <[email protected]>

* fix(tests): use existence check instead of truthiness for env vars

Addresses issue identified by all reviewers: the previous check
'if not os.environ.get(key)' treated empty strings as missing and
would overwrite them, inconsistent with the intent to preserve
any pre-existing environment variable values.

Now uses 'if key not in os.environ' which only sets placeholders
when the key is truly absent, aligning with the teardown logic.

Co-authored-by: Mervin Praison <[email protected]>

* test: avoid hardcoded fixture test path default

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0bafd91e-1df1-4422-8ac1-e4a0a8f8ee8c

Co-authored-by: MervinPraison <[email protected]>

* test: clarify fixture helper naming in env setup tests

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0bafd91e-1df1-4422-8ac1-e4a0a8f8ee8c

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-14 19:15:51 +01:00
c3c78fbc7b docs: verify pytest failure categories (#1663)
* docs: add verification notes for pytest failure categories

Co-authored-by: Cursor <[email protected]>

* docs: fix verification command path assumptions

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/8421f477-fa95-4465-a7b1-0c1c6da79fa2

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback on pytest verification guide

- Add missing executable verification commands for sections 4 & 5
- Fix conftest.py to only set placeholder API keys when unset/empty
- Align implementation with documented behavior for mixed testing

Co-authored-by: Mervin Praison <[email protected]>

* Delete docs/testing/VERIFY_TEST_FAILURE_CATEGORIES.md

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-14 19:14:43 +01:00
bf120c8dca fix(tests): bot _BUILTIN_PLATFORMS uses lazy loaders (#1667)
* fix(tests): expect lazy loader callables in bot registry

Co-authored-by: Cursor <[email protected]>

* fix(tests): improve bot registry tests with identity checks

- Replace weak __name__ assertions with robust identity checks
- Use 'is' operator instead of name comparison for loader validation
- Addresses Gemini Code Assist feedback for more reliable tests

Co-authored-by: Mervin Praison <[email protected]>

* test(bots): split loader-shape and adapter-identity assertions

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/b57884c2-8621-4f00-94e1-85eeb82a1cd5

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-14 19:12:37 +01:00
praisonai-triage-agent[bot]andMervinPraison 13d073b02a fix: move scripts folder from root to appropriate location (#1661)
* fix: move scripts folder from root to appropriate location (fixes #1659)

- Move all smoke test scripts from /scripts to src/praisonai/tests/integration/
- Update documentation in moved files to reflect new paths
- Per AGENTS.md architecture, these bot/BotOS integration tests belong in praisonai wrapper tests
- Preserves git history with git mv command

Co-authored-by: MervinPraison <[email protected]>

* fix: add __test__ = False to smoke test files to prevent pytest collection

- Prevents accidental pytest discovery in environments with different collection rules
- Follows Python testing best practices for non-test files in test directories
- Addresses Copilot's review feedback for explicit test exclusion

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-05-13 10:33:57 +01:00
87 changed files with 2468 additions and 1239 deletions

No files matched your search

+5 -2
View File
@@ -181,12 +181,15 @@ jobs:
- name: Fetch PR branch and Setup Remote
if: github.event.issue.pull_request
env:
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
IS_FORK: ${{ steps.check_fork.outputs.is_fork }}
run: |
# Fetch PR head from base repo and put it into local branch
git fetch origin pull/${{ github.event.issue.number }}/head:${{ steps.check_fork.outputs.pr_branch }}
git fetch origin pull/${{ github.event.issue.number }}/head:"${PR_BRANCH}"
# If it's a fork, make origin point to local to trick claude-code-action's `git fetch origin <branch>`
if [ "${{ steps.check_fork.outputs.is_fork }}" == "true" ]; then
if [ "${IS_FORK}" = "true" ]; then
git remote set-url origin file://$(pwd)
fi
+34
View File
@@ -0,0 +1,34 @@
# Security release checklist (maintainer)
After merging security PRs [#1684](https://github.com/MervinPraison/PraisonAI/pull/1684) and [#1685](https://github.com/MervinPraison/PraisonAI/pull/1685):
## 1. Publish PyPI (requires `PYPI_TOKEN`)
```bash
cd src/praisonai-agents
export PYPI_TOKEN=...
praisonai publish pypi
cd ../praisonai
python scripts/bump_and_release.py <WRAPPER_VERSION> --agents <AGENTS_VERSION> --wait
```
## 2. Publish GitHub security advisories
Set versions to match PyPI, then:
```bash
AGENTS_VERSION=1.6.40 WRAPPER_VERSION=4.6.40 ./scripts/security_publish_advisories.sh
```
Add reporter `credits` per advisory via `gh api` before publishing if not already set.
## 3. Verify
```bash
pip index versions praisonaiagents | head -1
pip index versions praisonai | head -1
gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id) \(.state) \(.cve_id // "pending")"'
```
Resources: https://github.com/MervinPraison/PraisonAI · https://docs.praison.ai · https://praison.ai
+26
View File
@@ -0,0 +1,26 @@
# Security advisory triage (maintainer)
| GHSA | Status after batch 1+2 | Notes |
|------|------------------------|-------|
| GHSA-4mr5-g6f9-cfrh | fixed batch 1 | Sandbox AST |
| GHSA-5c6w-wwfq-7qqm | fixed batch 1 | spider_tools SSRF |
| GHSA-9cr9-25q5-8prj | fixed batch 1 | MCP yaml paths |
| GHSA-86qc-r5v2-v6x6 | fixed batch 1 | call server auth |
| GHSA-hvhp-v2gc-268q | fixed batch 1 | write_file cwd |
| GHSA-5cxw-77wg-jrf3 | fixed batch 1 | @url mentions |
| GHSA-xp85-6wwf-r67c | fixed batch 1 | GHA branch quote |
| GHSA-3qg8-5g3r-79v5 | fixed batch 1+2 | JWT + issue guard |
| GHSA-xwq8, 7p8g, w388, g8rr | fixed batch 1 | platform RBAC/IDOR partial |
| GHSA-943m, 5jx9, 4x6r, 27p4, cp4f | fixed batch 2 | platform IDOR completion |
| GHSA-vg22-4gmj-prxw | fixed batch 2 | example eval hardening |
| GHSA-8444-4fhq-fxpq | already-fixed | APIConfig.auth_enabled default True |
| GHSA-78r8-wwqv-r299 | already-fixed | load_user_module gate |
| GHSA-gv23-xxxx-xxxx, GHSA-h8q5-cp56-rr65, GHSA-6h6v-xxxx-xxxx, GHSA-h37g-4h4p-9x97 | fixed batch 3 | service-layer workspace_id on get/update/delete |
| GHSA-c2m8-xxxx-xxxx, GHSA-8g2p-xxxx-xxxx | fixed batch 3 | only owner assigns admin/owner |
| GHSA-h8q5-cp56-rr65 | fixed batch 3 | bind default 127.0.0.1 (+ PLATFORM_HOST) |
| GHSA-6xj3-927j-6pqw | not-applicable | Open WebUI path; not in this repo |
| GHSA-gmjg, 9q28 | published | prior release |
**Code fixed on main; GHSA state still triage until PyPI publish + advisory publish.**
Resources: https://github.com/MervinPraison/PraisonAI · https://docs.praison.ai · https://praison.ai
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.38" \
"praisonai>=4.6.40" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.38" \
"praisonai>=4.6.40" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.38" \
"praisonai>=4.6.40" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -23,12 +23,39 @@ print(f"[2] Updated to v{managed.agent_version}")
result = agent.start("What is 123 + 456?", stream=True)
# ── 3. Custom tool ──
def _safe_calc(expr: str) -> str:
import ast
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expr):
return "error"
try:
tree = ast.parse(expr, mode="eval")
for node in ast.walk(tree):
if not isinstance(
node,
(
ast.Expression,
ast.BinOp,
ast.UnaryOp,
ast.Constant,
ast.Add,
ast.Sub,
ast.Mult,
ast.Div,
ast.USub,
ast.UAdd,
),
):
return "error"
val = eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
return str(val)
except Exception:
return "error"
def handle_calculator(tool_name, tool_input):
expr = tool_input.get("expression", "0")
try:
val = eval(expr, {"__builtins__": {}})
except Exception:
val = "error"
val = _safe_calc(str(expr))
print(f" [Calculator: {expr} = {val}]")
return str(val)
+26 -5
View File
@@ -44,12 +44,33 @@ def main():
def calculate(expression: str) -> str:
"""Calculate a math expression safely."""
try:
# Safe eval for basic math
allowed = set("0123456789+-*/.() ")
if all(c in allowed for c in expression):
return str(eval(expression))
import ast
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
return "Error: Invalid expression"
try:
tree = ast.parse(expression, mode="eval")
for node in ast.walk(tree):
if not isinstance(
node,
(
ast.Expression,
ast.BinOp,
ast.UnaryOp,
ast.Constant,
ast.Add,
ast.Sub,
ast.Mult,
ast.Div,
ast.USub,
ast.UAdd,
),
):
return "Error: Invalid expression"
return str(
eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
)
except Exception as e:
return f"Error: {e}"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Publish security advisories after PyPI release.
# Usage: AGENTS_VERSION=1.6.40 WRAPPER_VERSION=4.6.40 ./scripts/security_publish_advisories.sh
set -euo pipefail
REPO="MervinPraison/PraisonAI"
AGENTS_VERSION="${AGENTS_VERSION:?Set AGENTS_VERSION (e.g. 1.6.40)}"
WRAPPER_VERSION="${WRAPPER_VERSION:?Set WRAPPER_VERSION (e.g. 4.6.40)}"
LAST_AGENTS="${LAST_AGENTS:-1.6.39}"
LAST_WRAPPER="${LAST_WRAPPER:-4.6.39}"
# Batch 1 + 2 GHSA IDs (update credits login per advisory via gh api first if needed)
AGENTS_GHSAS=(
GHSA-4mr5-g6f9-cfrh
GHSA-5c6w-wwfq-7qqm
GHSA-5cxw-77wg-jrf3
)
WRAPPER_GHSAS=(
GHSA-9cr9-25q5-8prj
GHSA-86qc-r5v2-v6x6
GHSA-hvhp-v2gc-268q
GHSA-xp85-6wwf-r67c
GHSA-8444-4fhq-fxpq
GHSA-78r8-wwqv-r299
GHSA-vg22-4gmj-prxw
GHSA-6xj3-927j-6pqw
)
patch_advisory() {
local ghsa="$1" package="$2" last="$3" patched="$4"
gh api "repos/${REPO}/security-advisories/${ghsa}" --method PATCH --input - <<EOF
{
"state": "draft",
"vulnerabilities": [
{
"package": {"ecosystem": "pip", "name": "${package}"},
"vulnerable_version_range": "<= ${last}",
"patched_versions": ">= ${patched}"
}
]
}
EOF
gh api "repos/${REPO}/security-advisories/${ghsa}" --method PATCH -f state=published
gh api "repos/${REPO}/security-advisories/${ghsa}/cve" --method POST || true
echo "Published ${ghsa}"
}
for ghsa in "${AGENTS_GHSAS[@]}"; do
patch_advisory "$ghsa" "praisonaiagents" "$LAST_AGENTS" "$AGENTS_VERSION"
done
for ghsa in "${WRAPPER_GHSAS[@]}"; do
patch_advisory "$ghsa" "praisonai" "$LAST_WRAPPER" "$WRAPPER_VERSION"
done
echo "Done. Verify: gh api repos/${REPO}/security-advisories --jq '.[] | select(.state==\"published\") | .ghsa_id'"
@@ -164,7 +164,8 @@ Follow best practices and coding standards."""
messages=messages,
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
code = response.choices[0].message.content
self._log("Code generated successfully", "green")
return code
@@ -204,7 +205,8 @@ Follow best practices and coding standards."""
messages=messages,
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
code = response.choices[0].message.content
self._log("Code generated successfully", "green")
return code
@@ -365,6 +367,8 @@ Provide clear, actionable feedback."""
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
review = response.choices[0].message.content
self._log("Review completed", "green")
return review
@@ -405,6 +409,8 @@ Include:
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
explanation = response.choices[0].message.content
self._log("Explanation completed", "green")
return explanation
@@ -451,6 +457,8 @@ Only output the refactored code."""
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
refactored = response.choices[0].message.content
self._log("Refactoring completed", "green")
return refactored
@@ -495,6 +503,8 @@ Ensure the fix:
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
fixed = response.choices[0].message.content
self._log("Fix completed", "green")
return fixed
@@ -907,6 +907,8 @@ class OpenAIClient:
final_response = self._responses_to_chat_completion(raw)
# Display the response text if display_fn provided
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
response_text = final_response.choices[0].message.content or ""
if display_fn and response_text:
from rich.markdown import Markdown
@@ -1167,6 +1169,8 @@ class OpenAIClient:
final_response = self._responses_to_chat_completion(raw)
# Display the response text if display_fn provided
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
response_text = final_response.choices[0].message.content or ""
if display_fn and response_text:
from rich.markdown import Markdown
@@ -1638,8 +1642,10 @@ class OpenAIClient:
)
# Check for tool calls
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
# Emit llm_content for intermediate narrative display
# (gpt-4.1+ models produce text alongside tool calls)
response_content = getattr(final_response.choices[0].message, 'content', None)
@@ -1835,10 +1841,12 @@ class OpenAIClient:
if not final_response:
return None
# Check for tool calls
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
# Emit llm_content for intermediate narrative display
# (gpt-4.1+ models produce text alongside tool calls)
response_content = getattr(final_response.choices[0].message, 'content', None)
@@ -2037,6 +2045,8 @@ class OpenAIClient:
return
# Check for tool calls
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
if tool_calls and execute_tool_fn:
@@ -75,12 +75,12 @@ TOOL_MAPPINGS = {
# Python Tools
'execute_code': ('.python_tools', 'PythonTools'),
'analyze_code': ('.python_tools', 'PythonTools'),
'format_code': ('.python_tools', 'PythonTools'),
'lint_code': ('.python_tools', 'PythonTools'),
'disassemble_code': ('.python_tools', 'PythonTools'),
'python_tools': ('.python_tools', 'PythonTools'),
'execute_code': ('.python_tools', None),
'analyze_code': ('.python_tools', None),
'format_code': ('.python_tools', None),
'lint_code': ('.python_tools', None),
'disassemble_code': ('.python_tools', None),
'python_tools': ('.python_tools', None),
# Chain of Thought Training Tools
@@ -326,7 +326,7 @@ def __getattr__(name: str) -> Any:
if name in [
'duckduckgo', 'internet_search', 'searxng_search', 'searxng',
'scrape_page', 'extract_links', 'crawl', 'extract_text',
'execute_command', 'list_processes', 'kill_process', 'get_system_info',
'execute_command', 'execute_code', 'analyze_code', 'format_code', 'lint_code', 'disassemble_code', 'list_processes', 'kill_process', 'get_system_info',
'tavily', 'tavily_search', 'tavily_extract', 'tavily_crawl', 'tavily_map',
'tavily_search_async', 'tavily_extract_async',
'ydc', 'ydc_search', 'ydc_contents', 'ydc_news', 'ydc_images',
@@ -268,6 +268,11 @@ class MentionsParser:
def _process_url_mention(self, url: str) -> Optional[str]:
"""Process @url:https://... mention."""
try:
from praisonaiagents.tools.spider_tools import SpiderTools
if not SpiderTools()._validate_url(url):
return f"# URL: {url}\n[Blocked: URL is not allowed]"
import urllib.request
req = urllib.request.Request(
@@ -36,6 +36,30 @@ def _safe_getattr(obj, name, *default):
return getattr(obj, name, *default) if default else getattr(obj, name)
_SANDBOX_BLOCKED_ATTRS = frozenset({
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__',
'__wrapped__', '__closure__', '__annotations__',
'__self__', # C builtins leak real builtins module (GHSA-4mr5-g6f9-cfrh)
# Frame/code object introspection
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
'f_globals', 'f_locals', 'f_builtins', 'f_code',
'co_consts', 'co_names',
'__getattribute__', '__getattr__', '__setattr__', '__delattr__',
'__dir__', '__get__', '__set__', '__delete__',
})
_SANDBOX_BLOCKED_CALLS = frozenset({
'exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir', 'vars',
})
def _validate_code_ast(code: str):
"""Validate code using AST — catches attacks that bypass text checks.
@@ -48,23 +72,6 @@ def _validate_code_ast(code: str):
except SyntaxError:
return None # let compile() handle syntax errors later
# Dangerous dunder attributes attackers use for sandbox escape
_blocked_attrs = frozenset({
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__',
'__wrapped__', '__closure__', '__annotations__',
# Frame/code object introspection
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
'f_globals', 'f_locals', 'f_builtins', 'f_code',
'co_consts', 'co_names',
'__getattribute__', '__getattr__', '__setattr__', '__delattr__',
'__dir__', '__get__', '__set__', '__delete__',
})
for node in ast.walk(tree):
# Block import statements
if isinstance(node, (ast.Import, ast.ImportFrom)):
@@ -72,28 +79,26 @@ def _validate_code_ast(code: str):
# Block attribute access to dangerous dunders
if isinstance(node, ast.Attribute):
if node.attr in _blocked_attrs:
if node.attr in _SANDBOX_BLOCKED_ATTRS:
return (
f"Access to attribute '{node.attr}' is restricted"
)
# Block calls to dangerous builtins by name
# Block calls to dangerous builtins (bare name or attribute access)
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id in (
'exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir',
):
if isinstance(func, ast.Name) and func.id in _SANDBOX_BLOCKED_CALLS:
return f"Call to '{func.id}' is not allowed"
if isinstance(func, ast.Attribute) and func.attr in _SANDBOX_BLOCKED_CALLS:
return f"Call to '{func.attr}' is not allowed"
# Block dangerous constants (strings containing dunders)
# Fallback for Python 3.7 ast.Str
if isinstance(node, ast.Constant) and isinstance(node.value, str):
if any(attr in node.value for attr in _blocked_attrs):
if any(attr in node.value for attr in _SANDBOX_BLOCKED_ATTRS):
return f"String constant contains restricted attribute name"
elif type(node).__name__ == 'Str':
if any(attr in getattr(node, 's', '') for attr in _blocked_attrs):
if any(attr in getattr(node, 's', '') for attr in _SANDBOX_BLOCKED_ATTRS):
return f"String constant contains restricted attribute name"
return None
@@ -112,7 +117,16 @@ def _execute_code_sandboxed(
"""
if limits is None:
limits = ResourceLimits.minimal()
ast_error = _validate_code_ast(code)
if ast_error:
return {
'result': None,
'stdout': '',
'stderr': f'Security Error: {ast_error}',
'success': False,
}
try:
# Create temporary file for the code
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
@@ -151,21 +165,8 @@ def safe_execute():
}}
# Block dangerous patterns
blocked_attrs = {{
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__',
'__wrapped__', '__closure__', '__annotations__',
# Frame/code object introspection
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
'f_globals', 'f_locals', 'f_builtins', 'f_code',
'co_consts', 'co_names',
'__getattribute__', '__getattr__', '__setattr__', '__delattr__',
'__dir__', '__get__', '__set__', '__delete__',
}}
blocked_attrs = {set(_SANDBOX_BLOCKED_ATTRS)!r}
blocked_calls = {set(_SANDBOX_BLOCKED_CALLS)!r}
for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
@@ -182,14 +183,20 @@ def safe_execute():
"stderr": f"Access to attribute '{{node.attr}}' is restricted",
"success": False
}}
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
if node.func.id in ('exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir'):
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id in blocked_calls:
return {{
"result": None,
"stdout": "",
"stderr": f"Call to '{{node.func.id}}' is not allowed",
"stderr": f"Call to '{{func.id}}' is not allowed",
"success": False
}}
if isinstance(func, ast.Attribute) and func.attr in blocked_calls:
return {{
"result": None,
"stdout": "",
"stderr": f"Call to '{{func.attr}}' is not allowed",
"success": False
}}
if isinstance(node, ast.Constant) and isinstance(node.value, str):
@@ -481,7 +488,7 @@ def _execute_code_direct(
'__import__', 'import ', 'from ', 'exec', 'eval',
'compile', 'open(', 'file(', 'input(', 'raw_input',
'__subclasses__', '__bases__', '__globals__', '__code__',
'__class__', 'globals(', 'locals(', 'vars('
'__class__', '__self__', 'globals(', 'locals(', 'vars('
]
code_lower = code.lower()
@@ -11,6 +11,8 @@ content = scrape_page("https://example.com")
"""
import logging
import ipaddress
import socket
from typing import List, Dict, Union, Optional, Any
from importlib import util
import json
@@ -20,6 +22,48 @@ import os
import hashlib
import time
def _host_is_blocked(hostname: str) -> bool:
"""Return True when hostname resolves to loopback/private/internal targets."""
if not hostname:
return True
host = hostname.lower().rstrip(".")
if host in ("localhost", "0.0.0.0", "::1") or host.endswith(".localhost"):
return True
if host in ("169.254.169.254", "metadata.google.internal"):
return True
if any(host.endswith(suffix) for suffix in (".local", ".internal", ".localdomain")):
return True
def _ip_blocked(ip: ipaddress._BaseAddress) -> bool:
return bool(
ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local
)
if host.isdigit():
try:
return _ip_blocked(ipaddress.ip_address(int(host)))
except (ValueError, OverflowError):
return True
if host.startswith("0x"):
try:
return _ip_blocked(ipaddress.ip_address(int(host, 16)))
except (ValueError, OverflowError):
return True
try:
return _ip_blocked(ipaddress.ip_address(host))
except ValueError:
pass
try:
return _ip_blocked(ipaddress.ip_address(socket.inet_aton(host)))
except OSError:
pass
return False
class SpiderTools:
"""Tools for web scraping and crawling."""
@@ -59,31 +103,9 @@ class SpiderTools:
if not parsed.hostname:
return False
# Reject local/internal addresses
hostname = parsed.hostname.lower()
# Block localhost and loopback
if hostname in ['localhost', '127.0.0.1', '0.0.0.0', '::1']:
if _host_is_blocked(parsed.hostname):
return False
# Block private IP ranges
import ipaddress
try:
ip = ipaddress.ip_address(hostname)
if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local:
return False
except ValueError:
# Not an IP address, continue with domain validation
pass
# Block common internal domains
if any(hostname.endswith(domain) for domain in ['.local', '.internal', '.localdomain']):
return False
# Block metadata service endpoints
if hostname in ['169.254.169.254', 'metadata.google.internal']:
return False
return True
except Exception:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.38"
version = "1.6.40"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -21,6 +21,16 @@ import pytest
from unittest.mock import MagicMock
def _unregister_bot_platform(name: str) -> None:
"""Remove a runtime-registered bot platform (tests only)."""
from praisonai.bots._registry import get_default_bot_registry
try:
get_default_bot_registry().unregister(name.lower())
except (KeyError, ValueError):
pass
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Helper: Mock adapter that simulates a platform bot
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -59,9 +69,10 @@ class TestBotWithMockAdapter:
"""Integration tests for Bot class with a mock platform adapter."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
def teardown_method(self):
self._cleanup()
@@ -148,10 +159,14 @@ class TestBotOSLifecycle:
"""Integration tests for BotOS multi-bot orchestration."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mock1", MockPlatformAdapter)
register_platform("mock2", MockPlatformAdapter)
self._cleanup = lambda: (_custom_platforms.pop("mock1", None), _custom_platforms.pop("mock2", None))
self._cleanup = lambda: (
_unregister_bot_platform("mock1"),
_unregister_bot_platform("mock2"),
)
def teardown_method(self):
self._cleanup()
@@ -212,9 +227,10 @@ class TestRealAgentInBot:
"""Tests with a real praisonaiagents.Agent inside Bot."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
def teardown_method(self):
self._cleanup()
@@ -246,9 +262,10 @@ class TestAgentTeamInBotOS:
"""AgentTeam (multi-agent) inside Bot and BotOS."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
def teardown_method(self):
self._cleanup()
@@ -300,9 +317,10 @@ class TestAgentFlowInBotOS:
"""AgentFlow (deterministic workflow) inside Bot and BotOS."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
def teardown_method(self):
self._cleanup()
@@ -355,9 +373,8 @@ class TestPlatformExtensibility:
"""Third-party platform registration and usage."""
def teardown_method(self):
from praisonai.bots._registry import _custom_platforms
_custom_platforms.pop("custom_chat", None)
_custom_platforms.pop("my_platform", None)
_unregister_bot_platform("custom_chat")
_unregister_bot_platform("my_platform")
def test_register_and_use_custom_platform(self):
from praisonai.bots._registry import register_platform, resolve_adapter
@@ -389,7 +406,7 @@ class TestPlatformExtensibility:
def test_resolve_unknown_platform_raises(self):
from praisonai.bots._registry import resolve_adapter
with pytest.raises(ValueError, match="Unknown platform"):
with pytest.raises(ValueError, match=r"Unknown .*plugin"):
resolve_adapter("nonexistent_xyz")
@@ -401,9 +418,10 @@ class TestBotOSFromConfig:
"""BotOS.from_config() YAML config loading."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
def teardown_method(self):
self._cleanup()
@@ -459,7 +477,8 @@ platforms:
def test_from_config_multi_platform(self):
from praisonai.bots import BotOS
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mock2", MockPlatformAdapter)
yaml_content = """\
@@ -484,7 +503,7 @@ platforms:
assert "mock2" in botos.list_bots()
finally:
os.unlink(path)
_custom_platforms.pop("mock2", None)
_unregister_bot_platform("mock2")
def test_from_config_invalid_raises(self):
from praisonai.bots import BotOS
@@ -0,0 +1,10 @@
"""@url mentions must not fetch loopback targets."""
from praisonaiagents.tools.mentions import MentionsParser
def test_url_mention_blocks_loopback():
parser = MentionsParser()
result = parser._process_url_mention("http://127.0.0.1:8765/")
assert result is not None
assert "Blocked" in result
@@ -361,3 +361,11 @@ class TestProfileExports:
from praisonaiagents.tools import AUTONOMY_PROFILE
assert AUTONOMY_PROFILE is not None
def test_import_execute_code_uses_module_function(self):
"""execute_code should resolve to python_tools.execute_code function."""
from praisonaiagents.tools import execute_code
from praisonaiagents.tools.python_tools import execute_code as execute_code_impl
assert callable(execute_code)
assert execute_code is execute_code_impl
@@ -112,6 +112,22 @@ class TestSandboxEscapePrevention:
result = sandbox.run("__import__('os')")
assert result["success"] is False
def test_print_self_blocked(self, sandbox):
"""print.__self__ leaks real builtins module (GHSA-4mr5-g6f9-cfrh)."""
result = sandbox.run("b = print.__self__")
assert result["success"] is False
assert "restricted" in result["stderr"].lower()
def test_vars_call_blocked(self, sandbox):
"""vars() can expose builtins.__dict__ after __self__ leak."""
result = sandbox.run("vars({})")
assert result["success"] is False
def test_attribute_dunder_call_blocked(self, sandbox):
"""Attribute calls bypass bare-name Call checks."""
result = sandbox.run("(1).__class__.__mro__")
assert result["success"] is False
# ── Legitimate Code (all must PASS) ─────────────────────────────────────────
@@ -38,6 +38,16 @@ def test_still_blocks_loopback():
assert spider._validate_url("http://localhost/") is False
def test_blocks_alternate_loopback_encodings():
"""GHSA-5c6w-wwfq-7qqm: non-canonical loopback host forms."""
spider = SpiderTools()
assert spider._validate_url("http://localhost.:8765/") is False
assert spider._validate_url("http://127.1:8765/") is False
assert spider._validate_url("http://0177.0.0.1:8765/") is False
assert spider._validate_url("http://0x7f000001:8765/") is False
assert spider._validate_url("http://2130706433:8765/") is False
def test_rejects_non_string_input():
spider = SpiderTools()
assert spider._validate_url(None) is False # type: ignore[arg-type]
+1 -1
View File
@@ -2992,7 +2992,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.38"
version = "1.6.40"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -7,12 +7,18 @@ Usage::
"""
import argparse
import os
import sys
def main() -> None:
default_host = os.environ.get("PLATFORM_HOST", "127.0.0.1")
parser = argparse.ArgumentParser(description="PraisonAI Platform Server")
parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
parser.add_argument(
"--host",
default=default_host,
help="Bind host (default: 127.0.0.1, or PLATFORM_HOST env)",
)
parser.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)")
parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
args = parser.parse_args()
@@ -71,3 +71,57 @@ async def require_workspace_member(
)
user.workspace_id = workspace_id
return user
async def require_workspace_admin(
workspace_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> AuthIdentity:
"""Require admin or owner role in the workspace."""
return await require_workspace_member(
workspace_id, user, session, min_role="admin"
)
async def require_workspace_owner(
workspace_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> AuthIdentity:
"""Require owner role in the workspace."""
return await require_workspace_member(
workspace_id, user, session, min_role="owner"
)
def ensure_resource_in_workspace(
resource_workspace_id: str | None,
workspace_id: str,
*,
label: str = "Resource",
) -> None:
"""Reject cross-workspace access (IDOR) with a generic 404."""
if resource_workspace_id != workspace_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"{label} not found",
)
async def require_issue_in_workspace(
workspace_id: str,
issue_id: str,
session: AsyncSession,
):
"""Load an issue and verify it belongs to the URL workspace."""
from ..db.models import Issue
issue = await session.get(Issue, issue_id)
if issue is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Issue not found",
)
ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
return issue
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_db, require_workspace_member
from ..deps import get_db, require_issue_in_workspace, require_workspace_member
from ..schemas import ActivityLogResponse
from ...services.activity_service import ActivityService
@@ -38,6 +38,7 @@ async def list_issue_activity(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = ActivityService(session)
logs = await svc.list_for_issue(issue_id, limit=limit, offset=offset)
return [ActivityLogResponse.model_validate(log) for log in logs]
@@ -58,7 +58,7 @@ async def get_agent(
session: AsyncSession = Depends(get_db),
):
svc = AgentService(session)
agent = await svc.get(agent_id)
agent = await svc.get(agent_id, workspace_id=workspace_id)
if agent is None:
raise HTTPException(status_code=404, detail="Agent not found")
return AgentResponse.model_validate(agent)
@@ -75,6 +75,7 @@ async def update_agent(
svc = AgentService(session)
agent = await svc.update(
agent_id,
workspace_id=workspace_id,
name=body.name,
status=body.status,
instructions=body.instructions,
@@ -95,6 +96,6 @@ async def delete_agent(
session: AsyncSession = Depends(get_db),
):
svc = AgentService(session)
deleted = await svc.delete(agent_id)
deleted = await svc.delete(agent_id, workspace_id=workspace_id)
if not deleted:
raise HTTPException(status_code=404, detail="Agent not found")
@@ -9,9 +9,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_db, require_workspace_member
from ..schemas import DependencyCreate, DependencyResponse
from ..deps import get_db, require_issue_in_workspace, require_workspace_member
from ...services.dependency_service import DependencyService
from ..schemas import DependencyCreate, DependencyResponse
router = APIRouter(
prefix="/workspaces/{workspace_id}/issues/{issue_id}/dependencies",
@@ -27,6 +27,10 @@ async def create_dependency(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
await require_issue_in_workspace(
workspace_id, body.depends_on_issue_id, session
)
svc = DependencyService(session)
dep = await svc.create(issue_id, body.depends_on_issue_id, body.type)
return DependencyResponse.model_validate(dep)
@@ -39,6 +43,7 @@ async def list_dependencies(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = DependencyService(session)
deps = await svc.list_for_issue(issue_id)
return [DependencyResponse.model_validate(d) for d in deps]
@@ -52,7 +57,13 @@ async def delete_dependency(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = DependencyService(session)
dep = await svc.get(dep_id)
if dep is None:
raise HTTPException(status_code=404, detail="Dependency not found")
if dep.issue_id != issue_id and dep.depends_on_issue_id != issue_id:
raise HTTPException(status_code=404, detail="Dependency not found")
deleted = await svc.delete(dep_id)
if not deleted:
raise HTTPException(status_code=404, detail="Dependency not found")
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_db, require_workspace_member
from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member
from ..schemas import (
CommentCreate,
CommentResponse,
@@ -87,7 +87,7 @@ async def get_issue(
session: AsyncSession = Depends(get_db),
):
svc = IssueService(session)
issue = await svc.get(issue_id)
issue = await svc.get(issue_id, workspace_id=workspace_id)
if issue is None:
raise HTTPException(status_code=404, detail="Issue not found")
return IssueResponse.model_validate(issue)
@@ -104,6 +104,7 @@ async def update_issue(
svc = IssueService(session)
issue = await svc.update(
issue_id,
workspace_id=workspace_id,
title=body.title,
description=body.description,
status=body.status,
@@ -132,7 +133,7 @@ async def delete_issue(
session: AsyncSession = Depends(get_db),
):
svc = IssueService(session)
deleted = await svc.delete(issue_id)
deleted = await svc.delete(issue_id, workspace_id=workspace_id)
if not deleted:
raise HTTPException(status_code=404, detail="Issue not found")
@@ -148,6 +149,11 @@ async def add_comment(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
issue_svc = IssueService(session)
issue = await issue_svc.get(issue_id)
if issue is None:
raise HTTPException(status_code=404, detail="Issue not found")
ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
svc = CommentService(session)
comment = await svc.create(
issue_id=issue_id,
@@ -166,6 +172,11 @@ async def list_comments(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
issue_svc = IssueService(session)
issue = await issue_svc.get(issue_id)
if issue is None:
raise HTTPException(status_code=404, detail="Issue not found")
ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
svc = CommentService(session)
comments = await svc.list_for_issue(issue_id)
return [CommentResponse.model_validate(c) for c in comments]
@@ -9,7 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_db, require_workspace_member
from ..deps import (
ensure_resource_in_workspace,
get_db,
require_issue_in_workspace,
require_workspace_member,
)
from ..schemas import LabelCreate, LabelResponse, LabelUpdate
from ...services.label_service import LabelService
@@ -48,6 +53,10 @@ async def update_label(
session: AsyncSession = Depends(get_db),
):
svc = LabelService(session)
label = await svc.get(label_id)
if label is None:
raise HTTPException(status_code=404, detail="Label not found")
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
label = await svc.update(label_id, body.name, body.color)
if label is None:
raise HTTPException(status_code=404, detail="Label not found")
@@ -62,6 +71,10 @@ async def delete_label(
session: AsyncSession = Depends(get_db),
):
svc = LabelService(session)
label = await svc.get(label_id)
if label is None:
raise HTTPException(status_code=404, detail="Label not found")
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
deleted = await svc.delete(label_id)
if not deleted:
raise HTTPException(status_code=404, detail="Label not found")
@@ -78,7 +91,12 @@ async def add_label_to_issue(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = LabelService(session)
label = await svc.get(label_id)
if label is None:
raise HTTPException(status_code=404, detail="Label not found")
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
await svc.add_to_issue(issue_id, label_id)
@@ -90,7 +108,12 @@ async def remove_label_from_issue(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = LabelService(session)
label = await svc.get(label_id)
if label is None:
raise HTTPException(status_code=404, detail="Label not found")
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
await svc.remove_from_issue(issue_id, label_id)
@@ -101,6 +124,7 @@ async def list_issue_labels(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = LabelService(session)
labels = await svc.list_for_issue(issue_id)
return [LabelResponse.model_validate(l) for l in labels]
@@ -56,7 +56,7 @@ async def get_project(
session: AsyncSession = Depends(get_db),
):
svc = ProjectService(session)
project = await svc.get(project_id)
project = await svc.get(project_id, workspace_id=workspace_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
return ProjectResponse.model_validate(project)
@@ -73,6 +73,7 @@ async def update_project(
svc = ProjectService(session)
project = await svc.update(
project_id,
workspace_id=workspace_id,
title=body.title,
description=body.description,
status=body.status,
@@ -92,7 +93,7 @@ async def delete_project(
session: AsyncSession = Depends(get_db),
):
svc = ProjectService(session)
deleted = await svc.delete(project_id)
deleted = await svc.delete(project_id, workspace_id=workspace_id)
if not deleted:
raise HTTPException(status_code=404, detail="Project not found")
@@ -105,4 +106,7 @@ async def project_stats(
session: AsyncSession = Depends(get_db),
):
svc = ProjectService(session)
project = await svc.get(project_id, workspace_id=workspace_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
return await svc.get_stats(project_id)
@@ -9,7 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_current_user, get_db, require_workspace_member
from ..deps import (
get_current_user,
get_db,
require_workspace_admin,
require_workspace_member,
require_workspace_owner,
)
from ..schemas import (
MemberAdd,
MemberResponse,
@@ -64,7 +70,7 @@ async def get_workspace(
async def update_workspace(
workspace_id: str,
body: WorkspaceUpdate,
user: AuthIdentity = Depends(require_workspace_member),
user: AuthIdentity = Depends(require_workspace_admin),
session: AsyncSession = Depends(get_db),
):
ws_svc = WorkspaceService(session)
@@ -77,7 +83,7 @@ async def update_workspace(
@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_workspace(
workspace_id: str,
user: AuthIdentity = Depends(require_workspace_member),
user: AuthIdentity = Depends(require_workspace_owner),
session: AsyncSession = Depends(get_db),
):
ws_svc = WorkspaceService(session)
@@ -93,10 +99,16 @@ async def delete_workspace(
async def add_member(
workspace_id: str,
body: MemberAdd,
user: AuthIdentity = Depends(require_workspace_member),
user: AuthIdentity = Depends(require_workspace_admin),
session: AsyncSession = Depends(get_db),
):
member_svc = MemberService(session)
if body.role in ("owner", "admin"):
if not await member_svc.has_role(workspace_id, user.id, "owner"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only owners can add admin or owner roles",
)
member = await member_svc.add(workspace_id, body.user_id, body.role)
return MemberResponse.model_validate(member)
@@ -117,10 +129,32 @@ async def update_member_role(
workspace_id: str,
user_id: str,
body: MemberUpdate,
user: AuthIdentity = Depends(require_workspace_member),
user: AuthIdentity = Depends(require_workspace_admin),
session: AsyncSession = Depends(get_db),
):
member_svc = MemberService(session)
target = await member_svc.get(workspace_id, user_id)
if target is None:
raise HTTPException(status_code=404, detail="Member not found")
if user_id == user.id and body.role != target.role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot change your own role",
)
if body.role in ("owner", "admin") and not await member_svc.has_role(
workspace_id, user.id, "owner"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only owners can assign admin or owner roles",
)
if target.role == "owner" and not await member_svc.has_role(
workspace_id, user.id, "owner"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only owners can change an owner's role",
)
member = await member_svc.update_role(workspace_id, user_id, body.role)
if member is None:
raise HTTPException(status_code=404, detail="Member not found")
@@ -131,10 +165,25 @@ async def update_member_role(
async def remove_member(
workspace_id: str,
user_id: str,
user: AuthIdentity = Depends(require_workspace_member),
user: AuthIdentity = Depends(require_workspace_admin),
session: AsyncSession = Depends(get_db),
):
member_svc = MemberService(session)
if user_id == user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot remove yourself from the workspace",
)
target = await member_svc.get(workspace_id, user_id)
if target is None:
raise HTTPException(status_code=404, detail="Member not found")
if target.role == "owner" and not await member_svc.has_role(
workspace_id, user.id, "owner"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only owners can remove an owner",
)
removed = await member_svc.remove(workspace_id, user_id)
if not removed:
raise HTTPException(status_code=404, detail="Member not found")
@@ -50,9 +50,16 @@ class AgentService:
await self._session.flush()
return agent
async def get(self, agent_id: str) -> Optional[Agent]:
"""Get agent by ID."""
return await self._session.get(Agent, agent_id)
async def get(
self, agent_id: str, *, workspace_id: Optional[str] = None
) -> Optional[Agent]:
"""Get agent by ID, optionally scoped to a workspace."""
agent = await self._session.get(Agent, agent_id)
if agent is None:
return None
if workspace_id is not None and agent.workspace_id != workspace_id:
return None
return agent
async def list_for_workspace(
self,
@@ -72,6 +79,8 @@ class AgentService:
async def update(
self,
agent_id: str,
*,
workspace_id: Optional[str] = None,
name: Optional[str] = None,
status: Optional[str] = None,
instructions: Optional[str] = None,
@@ -80,7 +89,7 @@ class AgentService:
max_concurrent_tasks: Optional[int] = None,
) -> Optional[Agent]:
"""Update agent fields."""
agent = await self.get(agent_id)
agent = await self.get(agent_id, workspace_id=workspace_id)
if agent is None:
return None
if name is not None:
@@ -102,9 +111,11 @@ class AgentService:
await self._session.flush()
return agent
async def delete(self, agent_id: str) -> bool:
async def delete(
self, agent_id: str, *, workspace_id: Optional[str] = None
) -> bool:
"""Delete an agent."""
agent = await self.get(agent_id)
agent = await self.get(agent_id, workspace_id=workspace_id)
if agent is None:
return False
await self._session.delete(agent)
@@ -113,6 +113,10 @@ class AuthService:
def _issue_token(self, user: User) -> str:
"""Issue a JWT for a user."""
if JWT_SECRET == _DEFAULT_SECRET and os.environ.get("PLATFORM_ENV", "dev") != "dev":
raise RuntimeError(
"Refusing to issue JWT with default PLATFORM_JWT_SECRET outside dev"
)
now = datetime.now(timezone.utc)
payload = {
"sub": user.id,
@@ -69,9 +69,16 @@ class IssueService:
await self._session.flush()
return issue
async def get(self, issue_id: str) -> Optional[Issue]:
"""Get issue by ID."""
return await self._session.get(Issue, issue_id)
async def get(
self, issue_id: str, *, workspace_id: Optional[str] = None
) -> Optional[Issue]:
"""Get issue by ID, optionally scoped to a workspace."""
issue = await self._session.get(Issue, issue_id)
if issue is None:
return None
if workspace_id is not None and issue.workspace_id != workspace_id:
return None
return issue
async def list_for_workspace(
self,
@@ -97,6 +104,8 @@ class IssueService:
async def update(
self,
issue_id: str,
*,
workspace_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[str] = None,
@@ -106,7 +115,7 @@ class IssueService:
project_id: Optional[str] = None,
) -> Optional[Issue]:
"""Update issue fields."""
issue = await self.get(issue_id)
issue = await self.get(issue_id, workspace_id=workspace_id)
if issue is None:
return None
if title is not None:
@@ -135,21 +144,27 @@ class IssueService:
issue_id: str,
assignee_type: str,
assignee_id: str,
*,
workspace_id: Optional[str] = None,
) -> Optional[Issue]:
"""Assign an issue to a member or agent."""
if assignee_type not in VALID_ASSIGNEE_TYPES:
raise ValueError(f"Invalid assignee_type: {assignee_type}")
return await self.update(
issue_id, assignee_type=assignee_type, assignee_id=assignee_id
issue_id, workspace_id=workspace_id, assignee_type=assignee_type, assignee_id=assignee_id
)
async def transition(self, issue_id: str, new_status: str) -> Optional[Issue]:
async def transition(
self, issue_id: str, new_status: str, *, workspace_id: Optional[str] = None
) -> Optional[Issue]:
"""Transition an issue to a new status."""
return await self.update(issue_id, status=new_status)
return await self.update(issue_id, workspace_id=workspace_id, status=new_status)
async def delete(self, issue_id: str) -> bool:
async def delete(
self, issue_id: str, *, workspace_id: Optional[str] = None
) -> bool:
"""Delete an issue."""
issue = await self.get(issue_id)
issue = await self.get(issue_id, workspace_id=workspace_id)
if issue is None:
return False
await self._session.delete(issue)
@@ -44,9 +44,16 @@ class ProjectService:
await self._session.flush()
return project
async def get(self, project_id: str) -> Optional[Project]:
"""Get project by ID."""
return await self._session.get(Project, project_id)
async def get(
self, project_id: str, *, workspace_id: Optional[str] = None
) -> Optional[Project]:
"""Get project by ID, optionally scoped to a workspace."""
project = await self._session.get(Project, project_id)
if project is None:
return None
if workspace_id is not None and project.workspace_id != workspace_id:
return None
return project
async def list_for_workspace(
self,
@@ -62,6 +69,8 @@ class ProjectService:
async def update(
self,
project_id: str,
*,
workspace_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[str] = None,
@@ -69,7 +78,7 @@ class ProjectService:
lead_id: Optional[str] = None,
) -> Optional[Project]:
"""Update project fields."""
project = await self.get(project_id)
project = await self.get(project_id, workspace_id=workspace_id)
if project is None:
return None
if title is not None:
@@ -85,9 +94,11 @@ class ProjectService:
await self._session.flush()
return project
async def delete(self, project_id: str) -> bool:
async def delete(
self, project_id: str, *, workspace_id: Optional[str] = None
) -> bool:
"""Delete a project."""
project = await self.get(project_id)
project = await self.get(project_id, workspace_id=workspace_id)
if project is None:
return False
await self._session.delete(project)
@@ -10,7 +10,7 @@ from __future__ import annotations
import re
from typing import Optional
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.models import Member, Workspace
@@ -98,10 +98,13 @@ class WorkspaceService:
return ws
async def delete(self, workspace_id: str) -> bool:
"""Delete a workspace."""
"""Delete a workspace and its memberships."""
ws = await self.get(workspace_id)
if ws is None:
return False
await self._session.execute(
delete(Member).where(Member.workspace_id == workspace_id)
)
await self._session.delete(ws)
await self._session.flush()
return True
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonai-platform"
version = "0.1.2"
version = "0.1.4"
description = "Platform layer for PraisonAI — workspace, auth, issues, projects"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,20 @@
"""Cross-workspace IDOR guards for platform resources."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from praisonai_platform.api.deps import ensure_resource_in_workspace
def test_project_workspace_mismatch():
with pytest.raises(HTTPException) as exc:
ensure_resource_in_workspace("ws-other", "ws-mine", label="Project")
assert exc.value.status_code == 404
def test_label_workspace_mismatch():
with pytest.raises(HTTPException) as exc:
ensure_resource_in_workspace("ws-other", "ws-mine", label="Label")
assert exc.value.status_code == 404
@@ -0,0 +1,38 @@
"""Service-layer workspace scoping for issues and projects."""
from __future__ import annotations
import pytest
from praisonai_platform.services.auth_service import AuthService
from praisonai_platform.services.issue_service import IssueService
from praisonai_platform.services.project_service import ProjectService
from praisonai_platform.services.workspace_service import WorkspaceService
@pytest.mark.asyncio
async def test_issue_get_rejects_wrong_workspace(session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_a = await WorkspaceService(session).create("A", user.id)
ws_b = await WorkspaceService(session).create("B", user.id)
issue_svc = IssueService(session)
issue = await issue_svc.create(
workspace_id=ws_a.id,
title="secret",
creator_id=user.id,
)
assert await issue_svc.get(issue.id, workspace_id=ws_a.id) is not None
assert await issue_svc.get(issue.id, workspace_id=ws_b.id) is None
@pytest.mark.asyncio
async def test_project_delete_scoped_to_workspace(session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_a = await WorkspaceService(session).create("PA", user.id)
ws_b = await WorkspaceService(session).create("PB", user.id)
proj_svc = ProjectService(session)
project = await proj_svc.create(workspace_id=ws_a.id, title="p1")
assert await proj_svc.delete(project.id, workspace_id=ws_b.id) is False
assert await proj_svc.delete(project.id, workspace_id=ws_a.id) is True
@@ -0,0 +1,18 @@
"""Workspace member RBAC and cross-workspace IDOR guards."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from praisonai_platform.api.deps import ensure_resource_in_workspace
def test_ensure_resource_in_workspace_rejects_mismatch():
with pytest.raises(HTTPException) as exc:
ensure_resource_in_workspace("ws-a", "ws-b", label="Issue")
assert exc.value.status_code == 404
def test_ensure_resource_in_workspace_allows_match():
ensure_resource_in_workspace("ws-a", "ws-a", label="Issue")
+1 -1
View File
@@ -1208,7 +1208,7 @@ wheels = [
[[package]]
name = "praisonai-platform"
version = "0.1.2"
version = "0.1.4"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-13",
"lastUpdated": "2026-05-19",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
@@ -1725,6 +1725,7 @@
"planning": [],
"plugins": [
"PluginRegistry",
"ToolDefinition",
"disable_plugins",
"enable_plugins",
"get_plugin_manager",
@@ -1749,7 +1750,6 @@
"tools": [
"FunctionTool",
"Tool",
"ToolDefinition",
"ToolRegistry",
"ToolResult"
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-13",
"lastUpdated": "2026-05-19",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"summary": {
+1 -1
View File
@@ -1,6 +1,6 @@
# Feature Parity Tracker
> **Version:** 1.5.87 | **Last Updated:** 2026-05-13
> **Version:** 1.5.87 | **Last Updated:** 2026-05-19
> **Source of Truth:** Python SDK (praisonaiagents)
## Summary
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.38.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.38.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.40.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.40.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+36 -9
View File
@@ -36,10 +36,34 @@ __all__ = [
_telemetry_lock = threading.Lock()
_telemetry_initialized = False
def _get_telemetry_defaults() -> dict[str, str]:
"""Get telemetry environment defaults without mutating os.environ.
Returns:
Dict of environment variable defaults for telemetry
"""
import os
defaults = {}
# Respect any value the user already set
if "OTEL_SDK_DISABLED" not in os.environ:
langfuse_configured = bool(
os.getenv("LANGFUSE_PUBLIC_KEY")
or os.path.exists(os.path.expanduser("~/.praisonai/langfuse.env"))
)
defaults["OTEL_SDK_DISABLED"] = "false" if langfuse_configured else "true"
if "EC_TELEMETRY" not in os.environ:
defaults["EC_TELEMETRY"] = "false"
return defaults
def _ensure_telemetry_defaults() -> None:
"""Apply telemetry env defaults exactly once, on first observability use.
Thread-safe implementation using double-checked locking pattern.
DEPRECATED: Use _apply_telemetry_defaults() with explicit config instead.
"""
global _telemetry_initialized
if _telemetry_initialized: # fast path, OK without lock
@@ -47,18 +71,21 @@ def _ensure_telemetry_defaults() -> None:
with _telemetry_lock:
if _telemetry_initialized:
return
import os
# Respect any value the user already set
if "OTEL_SDK_DISABLED" not in os.environ:
langfuse_configured = bool(
os.getenv("LANGFUSE_PUBLIC_KEY")
or os.path.exists(os.path.expanduser("~/.praisonai/langfuse.env"))
)
os.environ["OTEL_SDK_DISABLED"] = "false" if langfuse_configured else "true"
os.environ.setdefault("EC_TELEMETRY", "false") # respect user overrides
_apply_telemetry_defaults(_get_telemetry_defaults())
_telemetry_initialized = True
def _apply_telemetry_defaults(env_vars: dict[str, str]) -> None:
"""Apply telemetry defaults to os.environ.
Args:
env_vars: Dict of environment variables to set
"""
import os
for key, value in env_vars.items():
os.environ.setdefault(key, value)
# Lazy loading for heavy imports
def __getattr__(name):
"""Lazy load heavy modules to improve import time."""
+70 -6
View File
@@ -24,6 +24,7 @@ class PluginRegistry(Generic[T]):
- Built-in plugins with lazy loading
- Entry points discovery
- Runtime registration
- Alias support for alternative names
- Thread-safe operations
"""
@@ -41,6 +42,7 @@ class PluginRegistry(Generic[T]):
"""
self._entry_point_group = entry_point_group
self._items: Dict[str, Type[T]] = {}
self._aliases: Dict[str, str] = {} # alias -> canonical name
self._lock = threading.Lock()
# Load built-in plugins with error handling
@@ -76,15 +78,29 @@ class PluginRegistry(Generic[T]):
# entry_points() might not be available in older Python versions
logger.debug("Entry points not available for group %s", self._entry_point_group)
def register(self, name: str, cls: Type[T]) -> None:
def register(
self,
name: str,
cls: Type[T],
*,
aliases: Optional[list[str]] = None
) -> None:
"""Register a plugin at runtime.
Args:
name: Plugin name
cls: Plugin class
aliases: Optional list of alias names for this plugin
"""
with self._lock:
self._items[name.lower()] = cls
canonical_name = name.lower()
self._items[canonical_name] = cls
# Register aliases
if aliases:
for alias in aliases:
normalized_alias = alias.lower()
self._aliases[normalized_alias] = canonical_name
def unregister(self, name: str) -> bool:
"""Unregister a plugin.
@@ -96,7 +112,27 @@ class PluginRegistry(Generic[T]):
True if plugin was found and removed, False otherwise
"""
with self._lock:
return self._items.pop(name.lower(), None) is not None
normalized_name = name.lower()
# Check if it's an alias
if normalized_name in self._aliases:
del self._aliases[normalized_name]
return True
# Check if it's a canonical name
if normalized_name in self._items:
# Remove all aliases pointing to this plugin
aliases_to_remove = [
alias for alias, canonical in self._aliases.items()
if canonical == normalized_name
]
for alias in aliases_to_remove:
del self._aliases[alias]
del self._items[normalized_name]
return True
return False
def resolve(self, name: str) -> Type[T]:
"""Resolve a plugin name to its class.
@@ -111,10 +147,20 @@ class PluginRegistry(Generic[T]):
ValueError: If plugin is not found
"""
with self._lock:
cls = self._items.get(name.lower())
normalized_name = name.lower()
# Resolve alias to canonical name
canonical_name = self._aliases.get(normalized_name, normalized_name)
cls = self._items.get(canonical_name)
# Capture available plugins snapshot while holding lock
# to avoid race condition between check and error message
available_snapshot = sorted(self._items.keys()) if cls is None else None
if cls is None:
available_plugins = sorted(self._items.keys())
available_aliases = sorted(self._aliases.keys())
available_snapshot = available_plugins + available_aliases
else:
available_snapshot = None
if cls is None:
raise ValueError(
@@ -161,4 +207,22 @@ class PluginRegistry(Generic[T]):
self.resolve(name)
return True
except ValueError:
return False
return False
def list_aliases(self) -> Dict[str, str]:
"""List all aliases and their target plugins.
Returns:
Dict mapping alias -> canonical name
"""
with self._lock:
return dict(self._aliases)
def list_all_names(self) -> list[str]:
"""List all names including aliases.
Returns:
Sorted list of all registered names and aliases
"""
with self._lock:
return sorted(list(self._items.keys()) + list(self._aliases.keys()))
+21 -14
View File
@@ -1172,9 +1172,9 @@ class AgentsGenerator:
acp_enabled = global_config.get('acp', False)
lsp_enabled = global_config.get('lsp', False)
interactive_runtime = None
interactive_loop = None
if acp_enabled or lsp_enabled:
runtime_started = False
try:
import asyncio
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
@@ -1190,11 +1190,12 @@ class AgentsGenerator:
interactive_runtime = InteractiveRuntime(runtime_config)
self.logger.info(f"Starting InteractiveRuntime (ACP: {acp_enabled}, LSP: {lsp_enabled})")
# Create a scoped event loop instead of modifying process globals
interactive_loop = asyncio.new_event_loop()
# Start the runtime but keep it alive for agent execution
interactive_loop.run_until_complete(interactive_runtime.start())
# Runs on the persistent background loop; safe from sync and async callers.
# run_sync raises RuntimeError early if called from inside a running loop
# so the bug is loud instead of a deadlock.
from ._async_bridge import run_sync
run_sync(interactive_runtime.start())
runtime_started = True
centric_tools = create_agent_centric_tools(interactive_runtime)
self.logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
@@ -1203,13 +1204,20 @@ class AgentsGenerator:
except ImportError as e:
self.logger.warning(f"Failed to load InteractiveRuntime components: {e}")
interactive_runtime = None
interactive_loop = None
except RuntimeError:
# Don't swallow RuntimeError from run_sync - preserve fail-fast semantics
raise
except Exception as e:
if runtime_started and interactive_runtime is not None:
try:
from ._async_bridge import run_sync
run_sync(interactive_runtime.stop())
except Exception as stop_error:
self.logger.error(
f"Error stopping partially started InteractiveRuntime: {stop_error}"
)
self.logger.error(f"Error starting InteractiveRuntime: {e}")
if 'interactive_loop' in locals() and interactive_loop is not None:
interactive_loop.close()
interactive_runtime = None
interactive_loop = None
# Create agents from config
for role, details in config['roles'].items():
@@ -1443,14 +1451,13 @@ class AgentsGenerator:
self.logger.debug(f"Result: {response}")
result = response if response else ""
finally:
if interactive_runtime and interactive_loop:
if interactive_runtime:
try:
self.logger.info("Stopping InteractiveRuntime...")
interactive_loop.run_until_complete(interactive_runtime.stop())
from ._async_bridge import run_sync
run_sync(interactive_runtime.stop())
except Exception as e:
self.logger.error(f"Error stopping InteractiveRuntime: {e}")
finally:
interactive_loop.close()
if AGENTOPS_AVAILABLE:
import agentops
+14 -2
View File
@@ -29,14 +29,26 @@ logger = logging.getLogger(__name__)
# Authentication
import os
CALL_SERVER_TOKEN = os.getenv('CALL_SERVER_TOKEN')
_CALL_AUTH_DISABLED = os.getenv('PRAISONAI_CALL_AUTH', '').lower() == 'disabled'
async def verify_token(
request: Request,
authorization: Optional[str] = Header(None)
) -> None:
"""Verify API token for authentication."""
if not FASTAPI_AVAILABLE or not CALL_SERVER_TOKEN:
return # No authentication if FastAPI unavailable or no token set
if not FASTAPI_AVAILABLE:
return
if _CALL_AUTH_DISABLED:
return
if not CALL_SERVER_TOKEN:
raise HTTPException(
status_code=503,
detail=(
"CALL_SERVER_TOKEN is not configured. Set CALL_SERVER_TOKEN or "
"PRAISONAI_CALL_AUTH=disabled to run without authentication."
),
)
token = None
+35 -43
View File
@@ -15,9 +15,8 @@ from typing import Any, Dict, List, Optional, Type, TypeVar
import os
import json
import yaml
from rich import print
import threading
from collections import OrderedDict
from rich import print
from praisonai._logging import get_logger
# Type variable for Pydantic models
@@ -59,10 +58,6 @@ def _load_optional(key: str, loader):
return _optional_cache[key]
# Bounded LRU cache for OpenAI clients (one per (api_key, base_url) tuple)
_OPENAI_CLIENT_CACHE_MAX = 8
_openai_clients: "OrderedDict[tuple, object]" = OrderedDict()
_openai_clients_lock = threading.Lock()
# --- CrewAI lazy loading ---
@@ -199,38 +194,6 @@ def _check_openai_available() -> bool:
return result is not None
def _get_openai_client(api_key: str = None, base_url: str = None):
"""Lazy load OpenAI client with bounded LRU cache (thread-safe, multi-tenant).
Multi-tenant safe: each (api_key, base_url) tuple gets its own cached client.
Bounded by _OPENAI_CLIENT_CACHE_MAX with proper LRU eviction.
"""
key = (api_key or os.environ.get("OPENAI_API_KEY"), base_url)
with _openai_clients_lock:
# Check if client exists and update LRU position
client = _openai_clients.get(key)
if client is not None:
_openai_clients.move_to_end(key)
return client
# Create new client
try:
from openai import OpenAI
except ImportError as e:
raise ImportError("Install with: pip install openai") from e
client = OpenAI(api_key=key[0], base_url=key[1])
_openai_clients[key] = client
# Bound the cache; close the LRU victim
if len(_openai_clients) > _OPENAI_CLIENT_CACHE_MAX:
_, victim = _openai_clients.popitem(last=False)
try:
victim.close()
except Exception:
pass # Best-effort cleanup
return client
# Use namespaced logger; root logger is configured only by the CLI
@@ -478,6 +441,38 @@ class BaseAutoGenerator:
'api_key': ep.api_key
}
]
self._openai_client = None # lazy, per-instance
self._openai_client_lock = threading.Lock()
def _get_openai_client(self):
"""Get or create the OpenAI client for this instance."""
if self._openai_client is None:
with self._openai_client_lock:
if self._openai_client is None:
try:
from openai import OpenAI
except ImportError as e:
raise ImportError("Install with: pip install openai") from e
cfg = self.config_list[0]
self._openai_client = OpenAI(
api_key=cfg.get("api_key") or os.environ.get("OPENAI_API_KEY"),
base_url=cfg.get("base_url"),
)
return self._openai_client
def close(self):
"""Close the OpenAI client if it exists."""
if not hasattr(self, '_openai_client_lock'):
return # Object was never fully initialized
with self._openai_client_lock:
client = getattr(self, '_openai_client', None)
self._openai_client = None
if client is not None:
client.close()
def __del__(self):
"""Best-effort cleanup, but the canonical path is explicit close()."""
self.close()
def _structured_completion(self, response_model: Type[T], messages: List[Dict], **kwargs) -> T:
"""
@@ -514,10 +509,7 @@ class BaseAutoGenerator:
# Fallback to OpenAI SDK (uses beta.chat.completions.parse)
if _check_openai_available():
client = _get_openai_client(
api_key=self.config_list[0].get('api_key'),
base_url=self.config_list[0].get('base_url')
)
client = self._get_openai_client()
response = client.beta.chat.completions.parse(
model=model_name,
messages=messages,
@@ -1537,4 +1529,4 @@ Generate a workflow for: {self.topic}
with open(full_path, 'w') as f:
yaml.dump(workflow_yaml, f, default_flow_style=False, sort_keys=False)
return full_path
return full_path
@@ -86,6 +86,8 @@ Only respond with the JSON, no other text."""
)
import json
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
result_text = response.choices[0].message.content
result_data = json.loads(result_text)
@@ -160,6 +162,8 @@ Only respond with the JSON, no other text."""
)
import json
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
result_text = response.choices[0].message.content
result_data = json.loads(result_text)
+2 -2
View File
@@ -103,7 +103,7 @@ def rag_query(
**kwargs
)
answer = response.choices[0].message.content if response.choices else ""
answer = response.choices[0].message.content if response.choices and response.choices[0].message is not None else ""
usage = None
if hasattr(response, 'usage') and response.usage:
@@ -188,7 +188,7 @@ async def arag_query(
**kwargs
)
answer = response.choices[0].message.content if response.choices else ""
answer = response.choices[0].message.content if response.choices and response.choices[0].message is not None else ""
usage = None
if hasattr(response, 'usage') and response.usage:
@@ -8,10 +8,36 @@ Plugin registry for CLI backend implementations following AGENTS.md patterns:
import threading
from typing import Dict, Callable, Any, Optional, Union
from .._registry import PluginRegistry
# Thread-safe registry for CLI backend factories
_REGISTRY: Dict[str, Callable[[], Any]] = {}
_REGISTRY_LOCK = threading.Lock()
# CLI Backend Registry using canonical PluginRegistry
def _get_builtin_cli_backend_loaders() -> Dict[str, Callable[[], Any]]:
"""Get built-in CLI backend loaders."""
def claude_loader():
def factory():
from .claude import ClaudeCodeBackend
return ClaudeCodeBackend()
return factory
return {
"claude-code": claude_loader
}
# Global CLI backend registry instance
_cli_backend_registry: Optional[PluginRegistry] = None
_registry_lock = threading.Lock()
def _get_cli_backend_registry() -> PluginRegistry:
"""Get the CLI backend registry instance."""
global _cli_backend_registry
if _cli_backend_registry is None:
with _registry_lock:
if _cli_backend_registry is None:
_cli_backend_registry = PluginRegistry(
entry_point_group="praisonai.cli_backends",
builtins=_get_builtin_cli_backend_loaders()
)
return _cli_backend_registry
def register_cli_backend(backend_id: str, factory: Callable[[], Any]) -> None:
@@ -21,14 +47,18 @@ def register_cli_backend(backend_id: str, factory: Callable[[], Any]) -> None:
backend_id: Unique identifier (e.g., "claude-code", "codex-cli")
factory: Factory function that returns a CliBackendProtocol instance
"""
with _REGISTRY_LOCK:
_REGISTRY[backend_id] = factory
# Wrap factory in a loader function for PluginRegistry
def factory_loader():
return factory
registry = _get_cli_backend_registry()
registry.register(backend_id, factory_loader)
def list_cli_backends() -> list[str]:
"""List all registered CLI backend IDs."""
with _REGISTRY_LOCK:
return list(_REGISTRY.keys())
registry = _get_cli_backend_registry()
return registry.list_names()
def resolve_cli_backend(
@@ -47,12 +77,15 @@ def resolve_cli_backend(
Raises:
ValueError: If backend_id is not registered
"""
with _REGISTRY_LOCK:
if backend_id not in _REGISTRY:
available = list(_REGISTRY.keys())
raise ValueError(f"Unknown CLI backend: {backend_id}. Available: {available}")
factory = _REGISTRY[backend_id]
registry = _get_cli_backend_registry()
try:
# Get the factory loader and create the factory
factory_loader = registry.resolve(backend_id)
factory = factory_loader()
except ValueError:
available = registry.list_names()
raise ValueError(f"Unknown CLI backend: {backend_id}. Available: {available}")
# Create instance with factory
backend = factory()
@@ -77,24 +110,4 @@ def resolve_cli_backend(
return backend
def _register_builtin_backends() -> None:
"""Register built-in CLI backends (lazy loaded)."""
# Only register if not already done
with _REGISTRY_LOCK:
if "claude-code" in _REGISTRY:
return
def claude_factory():
from .claude import ClaudeCodeBackend
return ClaudeCodeBackend()
register_cli_backend("claude-code", claude_factory)
# Auto-register built-in backends on first access
def __getattr__(name: str):
"""Lazy registration trigger."""
_register_builtin_backends()
raise AttributeError(f"No attribute {name}")
# Registration happens lazily via __getattr__ on first attribute access
# Built-in backends are now registered via _get_builtin_cli_backend_loaders()
@@ -58,9 +58,10 @@ def write_file(
>>> if result['success']:
... print(f"Wrote to {result['path']}")
"""
# Resolve path
if workspace and not os.path.isabs(path):
abs_path = os.path.abspath(os.path.join(workspace, path))
# Resolve path — default workspace is cwd so relative paths cannot escape
effective_workspace = workspace or os.getcwd()
if not os.path.isabs(path):
abs_path = os.path.abspath(os.path.join(effective_workspace, path))
else:
abs_path = os.path.abspath(path)
@@ -73,14 +74,12 @@ def write_file(
'path': path,
}
# Security check - ensure path is within workspace if specified
if workspace:
if not is_path_within_directory(abs_path, workspace):
return {
'success': False,
'error': f"Path '{path}' is outside the workspace",
'path': path,
}
if not is_path_within_directory(abs_path, effective_workspace):
return {
'success': False,
'error': f"Path '{path}' is outside the workspace",
'path': path,
}
# Process content
processed_content = content
+156
View File
@@ -554,6 +554,162 @@ class PraisonAIDB:
return traces[: max(limit, 0)]
return traces
# ========================================================================
# Async Surface for async-safe agents
# ========================================================================
async def aon_agent_start(
self,
session_id: str,
name: str,
agent_id: str = "",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Async version of on_agent_start."""
self._init_stores()
if self._state_store:
if hasattr(self._state_store, "async_set_agent_state"):
await self._state_store.async_set_agent_state(
session_id,
agent_id or name,
{"status": "started", "metadata": metadata or {}}
)
else:
import asyncio
await asyncio.to_thread(
self._state_store.set_agent_state,
session_id,
agent_id or name,
{"status": "started", "metadata": metadata or {}}
)
async def aon_user_message(
self,
session_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Async version of on_user_message."""
self._init_stores()
if self._conversation_store:
from ..persistence.conversation.models import ConversationMessage
msg = ConversationMessage(
role="user",
content=content,
metadata=metadata or {}
)
if hasattr(self._conversation_store, "async_add_message"):
await self._conversation_store.async_add_message(session_id, msg)
else:
import asyncio
await asyncio.to_thread(self._conversation_store.add_message, session_id, msg)
async def aon_agent_message(
self,
session_id: str,
content: str,
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Async version of on_agent_message."""
self._init_stores()
if self._conversation_store:
from ..persistence.conversation.models import ConversationMessage
msg = ConversationMessage(
role="assistant",
content=content,
metadata=metadata or {}
)
if hasattr(self._conversation_store, "async_add_message"):
await self._conversation_store.async_add_message(session_id, msg)
else:
import asyncio
await asyncio.to_thread(self._conversation_store.add_message, session_id, msg)
async def aon_tool_call(
self,
session_id: str,
tool_name: str,
arguments: Dict[str, Any],
result: Any = None,
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Async version of on_tool_call."""
self._init_stores()
if self._conversation_store:
if hasattr(self._conversation_store, "async_add_tool_call"):
await self._conversation_store.async_add_tool_call(
session_id, tool_name, arguments, result, metadata
)
elif hasattr(self._conversation_store, "add_tool_call"):
import asyncio
await asyncio.to_thread(
self._conversation_store.add_tool_call,
session_id, tool_name, arguments, result, metadata
)
async def aon_agent_end(
self,
session_id: str,
name: str,
agent_id: str = "",
metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Async version of on_agent_end."""
self._init_stores()
if self._state_store:
if hasattr(self._state_store, "async_set_agent_state"):
await self._state_store.async_set_agent_state(
session_id,
agent_id or name,
{"status": "ended", "metadata": metadata or {}}
)
else:
import asyncio
await asyncio.to_thread(
self._state_store.set_agent_state,
session_id,
agent_id or name,
{"status": "ended", "metadata": metadata or {}}
)
async def aclose(self) -> None:
"""Async version of close."""
stores = [self._conversation_store, self._state_store, self._knowledge_store]
for store in stores:
if store is None:
continue
if hasattr(store, "async_close"):
await store.async_close()
else:
import asyncio
await asyncio.to_thread(store.close)
async def __aenter__(self):
"""Async context manager entry."""
self._init_stores()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.aclose()
def __enter__(self):
"""Sync context manager entry."""
self._init_stores()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Sync context manager exit."""
self.close()
def close(self) -> None:
"""Close all database connections."""
if self._conversation_store:
+6 -4
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==4.6.38 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.6.40 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -76,7 +76,8 @@ class CloudDeployer:
with open("api.py", "w") as file:
file.write("from flask import Flask\n")
file.write("from praisonai import PraisonAI\n")
file.write("import markdown\n\n")
file.write("import markdown\n")
file.write("import bleach\n\n")
file.write("app = Flask(__name__)\n\n")
file.write("def basic():\n")
file.write(" praisonai = PraisonAI(agent_file=\"agents.yaml\")\n")
@@ -84,8 +85,9 @@ class CloudDeployer:
file.write("@app.route('/')\n")
file.write("def home():\n")
file.write(" output = basic()\n")
file.write(" html_output = markdown.markdown(output)\n")
file.write(" return f'<html><body>{html_output}</body></html>'\n\n")
file.write(" rendered = markdown.markdown(str(output))\n")
file.write(" safe_html = bleach.clean(rendered, tags=bleach.sanitizer.ALLOWED_TAGS, attributes=bleach.sanitizer.ALLOWED_ATTRIBUTES)\n")
file.write(" return f'<html><body>{safe_html}</body></html>'\n\n")
file.write("if __name__ == \"__main__\":\n")
file.write(" import os\n")
file.write(" app.run(debug=os.environ.get('DEBUG', 'false').lower() == 'true')\n")
@@ -256,17 +256,42 @@ class ExecApprovalManager:
logger.debug("Expired approval request: %s", rid)
# ── Module-level singleton accessor ──────────────────────────────────
# ── Manager accessor (non-singleton) ─────────────────────────────────
_global_manager: Optional[ExecApprovalManager] = None
# Default manager for CLI/backward compatibility
_default_manager: Optional[ExecApprovalManager] = None
_manager_lock = threading.Lock()
def get_exec_approval_manager(ttl: float = 300.0) -> ExecApprovalManager:
"""Get or create the global :class:`ExecApprovalManager` singleton."""
global _global_manager
if _global_manager is None:
def get_default_exec_approval_manager(ttl: float = 300.0) -> ExecApprovalManager:
"""Get the default exec approval manager (for CLI and backward compatibility).
Args:
ttl: Seconds before approval requests auto-expire (default 5 min).
Returns:
The default ExecApprovalManager instance.
"""
global _default_manager
if _default_manager is None:
with _manager_lock:
if _global_manager is None:
_global_manager = ExecApprovalManager(ttl=ttl)
return _global_manager
if _default_manager is None:
_default_manager = ExecApprovalManager(ttl=ttl)
return _default_manager
def get_exec_approval_manager(ttl: float = 300.0) -> ExecApprovalManager:
"""Backward compatibility alias for get_default_exec_approval_manager."""
return get_default_exec_approval_manager(ttl)
def create_exec_approval_manager(ttl: float = 300.0) -> ExecApprovalManager:
"""Create a new exec approval manager instance (for per-agent use).
Args:
ttl: Seconds before approval requests auto-expire (default 5 min).
Returns:
A new ExecApprovalManager instance.
"""
return ExecApprovalManager(ttl=ttl)
@@ -1,49 +1,33 @@
# Lazy availability checks using find_spec (no actual import)
# This avoids the ~3200ms crewai import at CLI startup
"""Lazy access to autogen_tools, with `find_spec` startup probes for crewai/autogen."""
import importlib.util
from praisonai.auto import _load_optional
CREWAI_AVAILABLE = importlib.util.find_spec("crewai") is not None
AUTOGEN_AVAILABLE = importlib.util.find_spec("autogen") is not None
PRAISONAI_TOOLS_AVAILABLE = False
PRAISONAI_TOOLS_PACKAGE_AVAILABLE = importlib.util.find_spec("praisonai_tools") is not None
def _load_autogen_tools():
# Load the autogen_tools module unconditionally since it can work with just praisonai_tools
import importlib
return importlib.import_module(__name__ + ".autogen_tools")
# Guard against recursive imports
_importing_autogen_tools = False
_autogen_tools_module = None
# Lazy import helper - only imports when actually needed
def _get_autogen_tools():
"""Lazy import autogen_tools only when needed."""
global PRAISONAI_TOOLS_AVAILABLE, _importing_autogen_tools, _autogen_tools_module
# Return cached module if already imported
if _autogen_tools_module is not None:
return _autogen_tools_module
# Prevent recursive import
if _importing_autogen_tools:
return None
if CREWAI_AVAILABLE or AUTOGEN_AVAILABLE:
try:
_importing_autogen_tools = True
from . import autogen_tools
_autogen_tools_module = autogen_tools
PRAISONAI_TOOLS_AVAILABLE = True
return autogen_tools
except ImportError:
pass
finally:
_importing_autogen_tools = False
return None
"""Thread-safe, single-shot lazy import. Negative result is cached too."""
return _load_optional("inbuilt_autogen_tools", _load_autogen_tools)
# For backward compatibility, provide __getattr__ for lazy access
def _praisonai_tools_available() -> bool:
"""Read-only accessor — never mutate this from inside a function."""
return PRAISONAI_TOOLS_PACKAGE_AVAILABLE or _get_autogen_tools() is not None
# Backward-compat: keep the constant, computed lazily on attribute access.
def __getattr__(name):
"""Lazy load autogen_tools exports on demand."""
# Avoid recursion during import
if _importing_autogen_tools:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
if name == "PRAISONAI_TOOLS_AVAILABLE":
return _praisonai_tools_available()
tools_module = _get_autogen_tools()
if tools_module and hasattr(tools_module, name):
if tools_module is not None and hasattr(tools_module, name):
return getattr(tools_module, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+22 -14
View File
@@ -83,8 +83,9 @@ class InMemoryJobStore(JobStore):
def _get_lock(self) -> asyncio.Lock:
"""Get the asyncio lock, creating it lazily if needed.
This deferred creation is required for Python 3.9 compatibility
where asyncio.Lock() calls get_event_loop() at creation time.
Deferred creation avoids touching the event loop at __init__ time,
which keeps the store safe to instantiate from sync contexts on
Python 3.9 (where asyncio.Lock() implicitly calls get_event_loop()).
"""
if self.__lock is None:
self.__lock = asyncio.Lock()
@@ -125,14 +126,14 @@ class InMemoryJobStore(JobStore):
async def get(self, job_id: str) -> Optional[Job]:
"""Get a job by ID."""
return self._jobs.get(job_id)
async with self._get_lock():
return self._jobs.get(job_id)
async def get_by_idempotency_key(self, key: str) -> Optional[Job]:
"""Get a job by idempotency key."""
job_id = self._idempotency_keys.get(key)
if job_id:
return self._jobs.get(job_id)
return None
async with self._get_lock():
job_id = self._idempotency_keys.get(key)
return self._jobs.get(job_id) if job_id else None
async def list_jobs(
self,
@@ -142,9 +143,10 @@ class InMemoryJobStore(JobStore):
offset: int = 0
) -> List[Job]:
"""List jobs with optional filters."""
jobs = list(self._jobs.values())
async with self._get_lock():
jobs = list(self._jobs.values())
# Apply filters
# Apply filters outside the lock - values() snapshot is already taken
if status:
jobs = [j for j in jobs if j.status == status]
if session_id:
@@ -162,7 +164,8 @@ class InMemoryJobStore(JobStore):
session_id: Optional[str] = None
) -> int:
"""Count jobs matching filters."""
jobs = list(self._jobs.values())
async with self._get_lock():
jobs = list(self._jobs.values())
if status:
jobs = [j for j in jobs if j.status == status]
@@ -201,15 +204,20 @@ class InMemoryJobStore(JobStore):
return len(to_remove)
def get_stats(self) -> Dict[str, Any]:
async def get_stats(self) -> Dict[str, Any]:
"""Get store statistics."""
async with self._get_lock():
# Take a snapshot to avoid RuntimeError if the dict is mutated concurrently
jobs_snapshot = list(self._jobs.values())
idempotency_count = len(self._idempotency_keys)
status_counts = {}
for job in self._jobs.values():
for job in jobs_snapshot:
status_counts[job.status.value] = status_counts.get(job.status.value, 0) + 1
return {
"total_jobs": len(self._jobs),
"idempotency_keys": len(self._idempotency_keys),
"total_jobs": len(jobs_snapshot),
"idempotency_keys": idempotency_count,
"max_jobs": self._max_jobs,
"status_counts": status_counts
}
+124 -182
View File
@@ -22,6 +22,7 @@ Example:
from typing import Any, Callable, Dict, List, Optional, Type, Union
import threading
from .._registry import PluginRegistry
# Type aliases
@@ -30,171 +31,46 @@ ProviderFactory = Callable[[str, Optional[Dict[str, Any]]], Any]
ProviderType = Union[ProviderClass, ProviderFactory]
class LLMProviderRegistry:
class LLMProviderRegistry(PluginRegistry[ProviderType]):
"""
Registry for LLM providers.
Manages registration and resolution of LLM providers by name.
Supports lazy loading, aliases, and isolated instances.
Thread-safe for concurrent operations.
Now inherits from PluginRegistry to eliminate duplication.
"""
_instance: Optional["LLMProviderRegistry"] = None
_instance_lock = threading.Lock()
def __init__(self):
"""Initialize an empty registry."""
self._providers: Dict[str, ProviderType] = {}
self._aliases: Dict[str, str] = {} # alias -> canonical name
self._lock = threading.RLock() # RLock for re-entrant calls
# Register built-in providers during initialization
_register_builtin_providers(self)
@classmethod
def get_instance(cls) -> "LLMProviderRegistry":
"""
Get the singleton registry instance.
"""Initialize LLM provider registry."""
# Get built-in provider loaders
builtins = _get_builtin_provider_loaders()
Returns:
LLMProviderRegistry: The singleton registry instance
"""
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
# Initialize parent with entry points and builtins
super().__init__(
entry_point_group="praisonai.llm_providers",
builtins=builtins
)
# Register aliases for built-in providers
self._register_builtin_aliases()
def register(
self,
name: str,
provider: ProviderType,
*,
override: bool = False,
aliases: Optional[List[str]] = None
) -> None:
"""
Register a provider by name.
def _register_builtin_aliases(self) -> None:
"""Register aliases for built-in providers."""
aliases_map = [
("openai", ("oai",)),
("anthropic", ("claude",)),
("google", ("gemini", "google_genai")),
]
Args:
name: Provider name (e.g., 'cloudflare', 'ollama')
provider: Provider class or factory function
override: Allow overwriting existing registration
aliases: Additional names that resolve to this provider
Raises:
ValueError: If name is already registered (unless override=True)
"""
with self._lock:
normalized_name = name.lower()
# Check for existing registration
if normalized_name in self._providers and not override:
raise ValueError(
f"Provider '{name}' is already registered. "
f"Use override=True to replace it."
)
# Check if this canonical name conflicts with existing alias
if normalized_name in self._aliases and not override:
existing_target = self._aliases[normalized_name]
raise ValueError(
f"Cannot register provider '{name}' - it conflicts with existing alias "
f"(currently points to '{existing_target}'). Use override=True to replace it."
)
# If override=True, clean up any existing alias before registering canonical name
if override and normalized_name in self._aliases:
del self._aliases[normalized_name]
self._providers[normalized_name] = provider
# Register aliases
if aliases:
for alias in aliases:
normalized_alias = alias.lower()
# Check collision with existing provider name
if normalized_alias in self._providers and not override:
raise ValueError(
f"Alias '{alias}' conflicts with existing provider name. "
f"Use override=True to replace it."
)
# Check collision with existing alias
if normalized_alias in self._aliases and not override:
existing_target = self._aliases[normalized_alias]
raise ValueError(
f"Alias '{alias}' is already registered (points to '{existing_target}'). "
f"Use override=True to replace it."
)
self._aliases[normalized_alias] = normalized_name
for name, aliases in aliases_map:
if name in self._items: # Only add aliases if the provider loaded
for alias in aliases:
self._aliases[alias.lower()] = name.lower()
def unregister(self, name: str) -> bool:
"""
Unregister a provider by name.
Args:
name: Provider name to unregister
Returns:
True if provider was unregistered, False if not found
"""
with self._lock:
normalized_name = name.lower()
# Check if it's an alias
if normalized_name in self._aliases:
del self._aliases[normalized_name]
return True
# Check if it's a canonical name
if normalized_name in self._providers:
# Remove all aliases pointing to this provider
aliases_to_remove = [
alias for alias, canonical in self._aliases.items()
if canonical == normalized_name
]
for alias in aliases_to_remove:
del self._aliases[alias]
del self._providers[normalized_name]
return True
return False
def has(self, name: str) -> bool:
"""
Check if a provider is registered.
Args:
name: Provider name to check
Returns:
True if provider is registered
"""
with self._lock:
normalized_name = name.lower()
return normalized_name in self._providers or normalized_name in self._aliases
def list(self) -> List[str]:
"""
List all registered provider names (canonical names only).
Returns:
List of provider names
"""
with self._lock:
return list(self._providers.keys())
def list_all(self) -> List[str]:
"""
List all names including aliases.
Returns:
List of all registered names and aliases
"""
with self._lock:
return list(self._providers.keys()) + list(self._aliases.keys())
def resolve(
def resolve_provider(
self,
name: str,
model_id: str,
@@ -214,23 +90,69 @@ class LLMProviderRegistry:
Raises:
ValueError: If provider not found
"""
with self._lock:
normalized_name = name.lower()
# Resolve alias to canonical name
canonical_name = self._aliases.get(normalized_name, normalized_name)
provider = self._providers.get(canonical_name)
if provider is None:
available = list(self._providers.keys()) # Don't call self.list() to avoid double-locking
raise ValueError(
f"Unknown provider: '{name}'. "
f"Available providers: {', '.join(available) if available else 'none'}. "
f"Register a custom provider with register_llm_provider('{name}', YourProviderClass)."
)
provider_cls = self.resolve(name)
# Create instance outside the lock
return provider(model_id, config)
return provider_cls(model_id, config)
def register_provider(
self,
name: str,
provider: ProviderType,
*,
override: bool = False,
aliases: Optional[List[str]] = None
) -> None:
"""
Register a provider by name with override validation.
Args:
name: Provider name (e.g., 'cloudflare', 'ollama')
provider: Provider class or factory function
override: Allow overwriting existing registration
aliases: Additional names that resolve to this provider
Raises:
ValueError: If name is already registered (unless override=True)
"""
# Check for existing registration if override is False
if not override and self.is_available(name):
raise ValueError(
f"Provider '{name}' is already registered. "
f"Use override=True to replace it."
)
# Check alias conflicts if override is False
if not override and aliases:
for alias in aliases:
if self.is_available(alias):
raise ValueError(
f"Alias '{alias}' is already registered. "
f"Use override=True to replace it."
)
# If override=True, unregister existing entries first
if override:
self.unregister(name)
if aliases:
for alias in aliases:
self.unregister(alias)
# Use parent class registration
self.register(name, provider, aliases=aliases)
# Inherit unregister, is_available, list_names from parent
# Add backwards compatibility aliases
def has(self, name: str) -> bool:
"""Check if a provider is registered (alias for is_available)."""
return self.is_available(name)
def list(self) -> List[str]:
"""List provider names (alias for list_names)."""
return self.list_names()
def list_all(self) -> List[str]:
"""List all names including aliases (alias for list_all_names)."""
return self.list_all_names()
def get(self, name: str) -> Optional[ProviderType]:
"""
@@ -242,30 +164,40 @@ class LLMProviderRegistry:
Returns:
Provider class/factory or None
"""
with self._lock:
normalized_name = name.lower()
canonical_name = self._aliases.get(normalized_name, normalized_name)
return self._providers.get(canonical_name)
try:
return self.resolve(name)
except ValueError:
return None
# ============================================================================
# Default Registry Singleton
# Default Registry Instance (No Singleton)
# ============================================================================
# Default module-level registry instance
_default_llm_registry: Optional[LLMProviderRegistry] = None
_default_llm_lock = threading.Lock()
def get_default_llm_registry() -> LLMProviderRegistry:
"""
Get the default global LLM provider registry.
This is the registry used by create_llm_provider() when no custom registry
is specified. Uses the thread-safe singleton pattern.
is specified. Uses lazy initialization pattern.
"""
return LLMProviderRegistry.get_instance()
global _default_llm_registry
if _default_llm_registry is None:
with _default_llm_lock:
if _default_llm_registry is None:
_default_llm_registry = LLMProviderRegistry()
return _default_llm_registry
def _register_builtin_providers(registry: LLMProviderRegistry) -> None:
def _get_builtin_provider_loaders() -> Dict[str, Callable[[], ProviderType]]:
"""
Register built-in providers to a registry.
Get built-in provider loaders.
Returns a dict of name -> loader function that creates provider factories.
Uses lazy loading to avoid importing heavy dependencies at module load time.
"""
# Built-in adapter that wraps LiteLLM so create_llm_provider works out of the box.
@@ -324,13 +256,22 @@ def _register_builtin_providers(registry: LLMProviderRegistry) -> None:
return _LiteLLMProvider(model_id, cfg)
return factory
# Build loader dict for the canonical PluginRegistry
loaders: Dict[str, Callable[[], ProviderType]] = {}
# Cover the providers parse_model_string() already special-cases.
for name, aliases in [
("openai", ("oai",)),
("anthropic", ("claude",)),
("google", ("gemini", "google_genai")),
]:
registry.register(name, _make_litellm_factory(name), aliases=list(aliases))
# Use local function to capture name correctly in closure
def make_loader(provider_name):
return lambda: _make_litellm_factory(provider_name)
loaders[name] = make_loader(name)
# Note: aliases will be registered separately via PluginRegistry.register()
return loaders
def register_llm_provider(
@@ -354,7 +295,7 @@ def register_llm_provider(
register_llm_provider("cloudflare", CloudflareProvider)
"""
get_default_llm_registry().register(name, provider, override=override, aliases=aliases)
get_default_llm_registry().register_provider(name, provider, override=override, aliases=aliases)
def unregister_llm_provider(name: str) -> bool:
@@ -456,12 +397,12 @@ def create_llm_provider(
name = input_value["name"]
model_id = input_value.get("model_id", "default")
provider_config = input_value.get("config") or config
return reg.resolve(name, model_id, provider_config)
return reg.resolve_provider(name, model_id, provider_config)
# Case 3: String - parse and resolve
if isinstance(input_value, str):
parsed = parse_model_string(input_value)
return reg.resolve(parsed["provider_id"], parsed["model_id"], config)
return reg.resolve_provider(parsed["provider_id"], parsed["model_id"], config)
raise ValueError(
f"Invalid provider input. Expected string, provider instance, or spec dict. "
@@ -471,5 +412,6 @@ def create_llm_provider(
def _reset_default_registry() -> None:
"""Reset the default registry (mainly for testing)."""
with LLMProviderRegistry._instance_lock:
LLMProviderRegistry._instance = None
global _default_llm_registry
with _default_llm_lock:
_default_llm_registry = None
@@ -21,6 +21,31 @@ from ..registry import register_tool
logger = logging.getLogger(__name__)
def _resolve_cwd_yaml_path(file_path: str) -> "Path":
"""Resolve a YAML path strictly inside the current working directory."""
from pathlib import Path
if not isinstance(file_path, str) or not file_path:
raise ValueError("file_path must be a non-empty string")
if (
"/" in file_path
or "\\" in file_path
or "\x00" in file_path
or file_path.startswith(".")
or file_path in ("..", ".")
):
raise ValueError(f"invalid file_path: {file_path!r}")
if not file_path.endswith((".yaml", ".yml")):
raise ValueError("file_path must be a .yaml or .yml file")
base = Path.cwd().resolve()
candidate = (base / file_path).resolve()
try:
candidate.relative_to(base)
except ValueError as exc:
raise ValueError(f"invalid file_path: {file_path!r}") from exc
return candidate
def register_cli_tools() -> None:
"""Register CLI-based MCP tools."""
@@ -44,7 +69,8 @@ def register_cli_tools() -> None:
"""Validate a workflow YAML file."""
try:
import yaml
with open(file_path, 'r') as f:
yaml_path = _resolve_cwd_yaml_path(file_path)
with open(yaml_path, 'r') as f:
config = yaml.safe_load(f)
required = ["framework", "topic"]
@@ -64,9 +90,10 @@ def register_cli_tools() -> None:
def workflow_show(file_path: str) -> str:
"""Show workflow configuration."""
try:
with open(file_path, 'r') as f:
content = f.read()
return content
yaml_path = _resolve_cwd_yaml_path(file_path)
return yaml_path.read_text()
except ValueError as e:
return f"Error: {e}"
except FileNotFoundError:
return f"File not found: {file_path}"
except Exception as e:
@@ -417,7 +444,8 @@ def register_cli_tools() -> None:
"""Validate deployment configuration."""
try:
import yaml
with open(config_path, 'r') as f:
yaml_path = _resolve_cwd_yaml_path(config_path)
with open(yaml_path, 'r') as f:
config = yaml.safe_load(f)
required = ["name", "type"]
+77 -91
View File
@@ -14,11 +14,12 @@ except ImportError:
from importlib_metadata import entry_points
import logging
from .._registry import PluginRegistry
logger = logging.getLogger(__name__)
class StoreRegistry:
class StoreRegistry(PluginRegistry[Callable[..., Any]]):
"""Registry for persistence store factories."""
def __init__(self, kind: str, entry_point_group: str):
@@ -30,12 +31,14 @@ class StoreRegistry:
entry_point_group: The entry point group for external registrations
"""
self._kind = kind
self._factories: Dict[str, Callable[..., Any]] = {}
self._aliases: Dict[str, str] = {}
self._lock = threading.Lock()
self._register_entry_points(entry_point_group)
# Initialize parent with entry points
super().__init__(entry_point_group=entry_point_group)
# Load built-in stores based on kind
self._register_builtin_stores()
def register(self, name: str, factory: Callable, *, aliases=()) -> None:
def register_store(self, name: str, factory: Callable, *, aliases=()) -> None:
"""
Register a store factory.
@@ -44,12 +47,16 @@ class StoreRegistry:
factory: Factory function that creates store instances
aliases: Optional list of alias names for this backend
"""
with self._lock:
normalized_name = name.lower()
self._factories[normalized_name] = factory
for alias in aliases:
normalized_alias = alias.lower()
self._aliases[normalized_alias] = normalized_name
# Wrap the factory in a loader function for PluginRegistry
def factory_loader():
return factory
# Use parent's register method with aliases
super().register(name, factory_loader, aliases=list(aliases))
def register(self, name: str, factory: Callable, *, aliases=()) -> None:
"""Backwards compatibility alias for register_store."""
self.register_store(name, factory, aliases=aliases)
def create(self, name: str, **kwargs) -> Any:
"""
@@ -65,15 +72,11 @@ class StoreRegistry:
Raises:
ValueError: If backend is not registered
"""
normalized_name = name.lower()
with self._lock:
canonical = self._aliases.get(normalized_name, normalized_name)
factory = self._factories.get(canonical)
if factory is None:
# Get available backends for error message inside lock
available_backends = sorted(self._factories.keys())
if factory is None:
try:
factory_loader = self.resolve(name)
factory = factory_loader()
except ValueError:
available_backends = self.list_names()
raise ValueError(
f"Unknown {self._kind} backend: {name}. "
f"Registered: {available_backends}"
@@ -82,32 +85,20 @@ class StoreRegistry:
return factory(**kwargs)
def list_registered(self) -> list[str]:
"""List all registered backend names."""
with self._lock:
return sorted(self._factories.keys())
"""List all registered backend names (alias for list_names)."""
return self.list_names()
def list_aliases(self) -> Dict[str, str]:
"""List all aliases and their target backends."""
with self._lock:
return dict(self._aliases)
def _register_entry_points(self, group: str) -> None:
"""Register backends from entry points."""
try:
for ep in entry_points(group=group):
try:
self.register(ep.name, ep.load())
except Exception:
logger.warning(
"Failed to load %s backend %r from entry point",
self._kind, ep.name, exc_info=True
)
except Exception:
# entry_points() may fail in some environments
pass
def _register_builtin_stores(self) -> None:
"""Register built-in stores based on kind."""
if self._kind == "conversation":
_register_builtin_conversation_stores(self)
elif self._kind == "knowledge":
_register_builtin_knowledge_stores(self)
elif self._kind == "state":
_register_builtin_state_stores(self)
def _register_builtin_conversation_stores():
def _register_builtin_conversation_stores(registry: StoreRegistry):
"""Register built-in conversation stores with lazy imports."""
def _postgres(url=None, **kwargs):
@@ -155,24 +146,24 @@ def _register_builtin_conversation_stores():
return TursoConversationStore(url=url, **kwargs)
# Register all backends with aliases
CONVERSATION_STORES.register("postgres", _postgres,
registry.register("postgres", _postgres,
aliases=("neon", "cockroachdb", "crdb", "cockroach", "xata"))
CONVERSATION_STORES.register("async_postgres", _async_postgres,
registry.register("async_postgres", _async_postgres,
aliases=("asyncpg", "postgres_async"))
CONVERSATION_STORES.register("mysql", _mysql)
CONVERSATION_STORES.register("async_mysql", _async_mysql,
registry.register("mysql", _mysql)
registry.register("async_mysql", _async_mysql,
aliases=("aiomysql", "mysql_async"))
CONVERSATION_STORES.register("sqlite", _sqlite)
CONVERSATION_STORES.register("async_sqlite", _async_sqlite,
registry.register("sqlite", _sqlite)
registry.register("async_sqlite", _async_sqlite,
aliases=("aiosqlite", "sqlite_async"))
CONVERSATION_STORES.register("json", _json)
CONVERSATION_STORES.register("singlestore", _singlestore)
CONVERSATION_STORES.register("supabase", _supabase)
CONVERSATION_STORES.register("surrealdb", _surrealdb)
CONVERSATION_STORES.register("turso", _turso, aliases=("libsql",))
registry.register("json", _json)
registry.register("singlestore", _singlestore)
registry.register("supabase", _supabase)
registry.register("surrealdb", _surrealdb)
registry.register("turso", _turso, aliases=("libsql",))
def _register_builtin_knowledge_stores():
def _register_builtin_knowledge_stores(registry: StoreRegistry):
"""Register built-in knowledge stores with lazy imports."""
def _chroma(url=None, path=None, **kwargs):
@@ -259,37 +250,37 @@ def _register_builtin_knowledge_stores():
from .knowledge.cosmosdb_vector import CosmosDBVectorKnowledgeStore
return CosmosDBVectorKnowledgeStore(url=url, **kwargs)
KNOWLEDGE_STORES.register("chroma", _chroma, aliases=("chromadb",))
KNOWLEDGE_STORES.register("qdrant", _qdrant)
KNOWLEDGE_STORES.register("pinecone", _pinecone)
KNOWLEDGE_STORES.register("weaviate", _weaviate)
KNOWLEDGE_STORES.register("lancedb", _lancedb)
KNOWLEDGE_STORES.register("milvus", _milvus)
KNOWLEDGE_STORES.register("pgvector", _pgvector)
KNOWLEDGE_STORES.register("redis", _redis_vector)
KNOWLEDGE_STORES.register("cassandra", _cassandra)
KNOWLEDGE_STORES.register("clickhouse", _clickhouse)
KNOWLEDGE_STORES.register("mongodb_vector", _mongodb_vector,
registry.register("chroma", _chroma, aliases=("chromadb",))
registry.register("qdrant", _qdrant)
registry.register("pinecone", _pinecone)
registry.register("weaviate", _weaviate)
registry.register("lancedb", _lancedb)
registry.register("milvus", _milvus)
registry.register("pgvector", _pgvector)
registry.register("redis", _redis_vector)
registry.register("cassandra", _cassandra)
registry.register("clickhouse", _clickhouse)
registry.register("mongodb_vector", _mongodb_vector,
aliases=("mongodb_atlas", "mongo_vector"))
# Register missing backends that were supported in old factory
KNOWLEDGE_STORES.register("couchbase", _couchbase)
KNOWLEDGE_STORES.register("singlestore_vector", _singlestore_vector,
registry.register("couchbase", _couchbase)
registry.register("singlestore_vector", _singlestore_vector,
aliases=("singlestore_v",))
KNOWLEDGE_STORES.register("surrealdb_vector", _surrealdb_vector,
registry.register("surrealdb_vector", _surrealdb_vector,
aliases=("surrealdb_v",))
KNOWLEDGE_STORES.register("upstash_vector", _upstash_vector,
registry.register("upstash_vector", _upstash_vector,
aliases=("upstash_v",))
KNOWLEDGE_STORES.register("lightrag", _lightrag)
KNOWLEDGE_STORES.register("langchain", _langchain_adapter,
registry.register("lightrag", _lightrag)
registry.register("langchain", _langchain_adapter,
aliases=("langchain_adapter",))
KNOWLEDGE_STORES.register("llamaindex", _llamaindex_adapter,
registry.register("llamaindex", _llamaindex_adapter,
aliases=("llama_index", "llamaindex_adapter"))
KNOWLEDGE_STORES.register("cosmosdb", _cosmosdb_vector,
registry.register("cosmosdb", _cosmosdb_vector,
aliases=("cosmos", "azure_cosmos", "cosmosdb_vector"))
def _register_builtin_state_stores():
def _register_builtin_state_stores(registry: StoreRegistry):
"""Register built-in state stores with lazy imports."""
def _redis(url=None, **kwargs):
@@ -330,23 +321,18 @@ def _register_builtin_state_stores():
raise ValueError("GCS state store requires 'bucket_name' option")
return GCSStateStore(bucket_name=bucket, **kwargs)
STATE_STORES.register("redis", _redis)
STATE_STORES.register("dynamodb", _dynamodb)
STATE_STORES.register("firestore", _firestore)
STATE_STORES.register("mongodb", _mongodb)
STATE_STORES.register("async_mongodb", _async_mongodb,
registry.register("redis", _redis)
registry.register("dynamodb", _dynamodb)
registry.register("firestore", _firestore)
registry.register("mongodb", _mongodb)
registry.register("async_mongodb", _async_mongodb,
aliases=("motor", "mongodb_async"))
STATE_STORES.register("upstash", _upstash)
STATE_STORES.register("memory", _memory)
STATE_STORES.register("gcs", _gcs)
registry.register("upstash", _upstash)
registry.register("memory", _memory)
registry.register("gcs", _gcs)
# Create global registry instances
# Create global registry instances - built-in stores are registered automatically via __init__
CONVERSATION_STORES = StoreRegistry("conversation", "praisonai.conversation_stores")
KNOWLEDGE_STORES = StoreRegistry("knowledge", "praisonai.knowledge_stores")
STATE_STORES = StoreRegistry("state", "praisonai.state_stores")
# Register built-in stores
_register_builtin_conversation_stores()
_register_builtin_knowledge_stores()
_register_builtin_state_stores()
STATE_STORES = StoreRegistry("state", "praisonai.state_stores")
File diff suppressed because it is too large. Load diff
@@ -6,12 +6,12 @@ at regular intervals, enabling 24/7 autonomous agent operations.
"""
import threading
import time
import logging
from datetime import datetime
from typing import Optional, Dict, Any, Callable
from .base import ScheduleParser, PraisonAgentExecutor
from .shared import backoff_delay
logger = logging.getLogger(__name__)
@@ -292,9 +292,11 @@ class AgentScheduler:
logger.error(f"Agent execution failed on attempt {attempt + 1}: {e}")
if attempt < max_retries - 1:
wait_time = 30 * (attempt + 1) # Exponential backoff
wait_time = backoff_delay(attempt, initial=30.0, cap=300.0)
logger.debug(f"Waiting {wait_time}s before retry...")
time.sleep(wait_time)
if self._stop_event.wait(wait_time):
# stop() was called during backoff — exit immediately
return
if not success:
self._failure_count += 1
+24 -12
View File
@@ -7,6 +7,7 @@ for both builtin and user tools.
"""
import logging
import threading
from typing import Dict, Callable, List, Optional, Any
import inspect
@@ -19,45 +20,54 @@ class ToolRegistry:
def __init__(self):
self._functions: Dict[str, Callable] = {}
self._autogen_adapters: Dict[str, Callable] = {}
self._lock = threading.Lock()
def register_function(self, name: str, func: Callable) -> None:
"""Register a function tool."""
if not callable(func):
raise ValueError(f"Tool {name} must be callable")
self._functions[name] = func
with self._lock:
self._functions[name] = func
logger.debug(f"Registered function tool: {name}")
def register_autogen_adapter(self, tool_type_name: str, adapter: Callable) -> None:
"""Register an AutoGen-specific tool adapter."""
if not callable(adapter):
raise ValueError(f"AutoGen adapter for {tool_type_name} must be callable")
self._autogen_adapters[tool_type_name] = adapter
with self._lock:
self._autogen_adapters[tool_type_name] = adapter
logger.debug(f"Registered AutoGen adapter: {tool_type_name}")
def get_function(self, name: str) -> Optional[Callable]:
"""Get a function tool by name."""
return self._functions.get(name)
with self._lock:
return self._functions.get(name)
def get_autogen_adapter(self, tool_type_name: str) -> Optional[Callable]:
"""Get an AutoGen adapter by tool type name."""
return self._autogen_adapters.get(tool_type_name)
with self._lock:
return self._autogen_adapters.get(tool_type_name)
def list_functions(self) -> List[str]:
"""List all registered function tool names."""
return list(self._functions.keys())
with self._lock:
return list(self._functions.keys())
def list_autogen_adapters(self) -> List[str]:
"""List all registered AutoGen adapter names."""
return list(self._autogen_adapters.keys())
with self._lock:
return list(self._autogen_adapters.keys())
def get_functions_dict(self) -> Dict[str, Callable]:
"""Get a copy of all registered functions."""
return dict(self._functions)
with self._lock:
return dict(self._functions)
def clear(self) -> None:
"""Clear all registered tools."""
self._functions.clear()
self._autogen_adapters.clear()
with self._lock:
self._functions.clear()
self._autogen_adapters.clear()
logger.debug("Cleared tool registry")
def register_from_module(self, module: Any) -> List[str]:
@@ -75,7 +85,7 @@ class ToolRegistry:
if (not name.startswith('_') and
callable(obj) and
not inspect.isclass(obj)):
self.register_function(name, obj)
self.register_function(name, obj) # This already acquires the lock
registered.append(name)
logger.debug(f"Registered {len(registered)} functions from module: {registered}")
@@ -107,8 +117,10 @@ class ToolRegistry:
def __len__(self) -> int:
"""Return total number of registered tools."""
return len(self._functions) + len(self._autogen_adapters)
with self._lock:
return len(self._functions) + len(self._autogen_adapters)
def __contains__(self, name: str) -> bool:
"""Check if a tool function is registered."""
return name in self._functions
with self._lock:
return name in self._functions
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.6.38"
__version__ = "4.6.40"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.6.38",
"praisonaiagents>=1.6.40",
"python-dotenv>=0.19.0",
"litellm>=1.83.14,<2",
"PyYAML>=6.0",
+13 -5
View File
@@ -129,7 +129,12 @@ def temp_directory(tmp_path):
@pytest.fixture(autouse=True)
def setup_test_environment(request):
"""Setup test environment before each test."""
"""Setup test environment before each test.
For non-live unit/mock tests, fills missing provider API keys with placeholders
so imports and guardrails do not crash. Keys already present in the environment
(e.g. from ~/.bashrc or CI secrets) are never overwritten.
"""
# Only set test API keys for non-real tests
# Real tests (marked with @pytest.mark.real) should use actual environment variables
is_real_test = False
@@ -155,19 +160,22 @@ def setup_test_environment(request):
original_values = {}
if not is_real_test:
# Set test environment variables only for mock tests
# Placeholder keys only where unset — preserves keys from the parent shell
# (e.g. ~/.bashrc / ~/.zshrc) so local agentic unit tests can call real APIs.
test_keys = {
'OPENAI_API_KEY': 'test-key',
'ANTHROPIC_API_KEY': 'test-key',
'ANTHROPIC_API_KEY': 'test-key',
'GOOGLE_API_KEY': 'test-key',
'XAI_API_KEY': 'test-key',
'GROQ_API_KEY': 'test-key',
'COHERE_API_KEY': 'test-key',
}
for key, value in test_keys.items():
original_values[key] = os.environ.get(key)
os.environ[key] = value
# Only set placeholder when the key is absent (preserves explicit empty strings)
if key not in os.environ:
os.environ[key] = value
yield
@@ -1,6 +1,10 @@
#!/usr/bin/env python3
from __future__ import annotations
"""N4 — Real agentic smoke test for inbound DLQ.
This is not a pytest test file - it's an integration smoke test.
Scenario:
1. Real Agent (anthropic/claude-haiku-4-5 or gemini/2.5-flash).
2. First chat attempt fails (we patch agent.chat to raise once).
@@ -11,11 +15,12 @@ Scenario:
Run::
cd /Users/praison/worktrees/n4-dlq
PYTHONPATH=src/praisonai-agents:src/praisonai python scripts/smoke_n4_real.py
cd /path/to/PraisonAI
PYTHONPATH=src/praisonai-agents:src/praisonai python src/praisonai/tests/integration/smoke_n4_real.py
"""
from __future__ import annotations
# Prevent pytest from treating this as a test file
__test__ = False
import asyncio
import os
@@ -1,6 +1,10 @@
#!/usr/bin/env python3
from __future__ import annotations
"""W1 real agentic smoke test (high-level BotOS API, no manual mgr).
This is not a pytest test file - it's an integration smoke test.
Uses the user-facing API only — ``Bot``, ``BotOS``, ``InMemoryIdentityResolver`` —
and exercises the same cross-platform-continuity scenario as
``smoke_w1_real.py`` but without touching ``BotSessionManager`` directly.
@@ -13,10 +17,11 @@ wire-up from ``BotOS(identity_resolver=...)`` → ``Bot._identity_resolver``
Run::
# From the repository root:
PYTHONPATH=src/praisonai-agents:src/praisonai python scripts/smoke_w1_botos_real.py
PYTHONPATH=src/praisonai-agents:src/praisonai python src/praisonai/tests/integration/smoke_w1_botos_real.py
"""
from __future__ import annotations
# Prevent pytest from treating this as a test file
__test__ = False
import asyncio
import os
@@ -1,13 +1,18 @@
#!/usr/bin/env python3
from __future__ import annotations
"""W1 real agentic smoke test (bypasses pytest conftest gating).
This is not a pytest test file - it's an integration smoke test.
Runs::
cd src/praisonai
python ../../scripts/smoke_w1_real.py
cd /path/to/PraisonAI
PYTHONPATH=src/praisonai-agents:src/praisonai python src/praisonai/tests/integration/smoke_w1_real.py
"""
from __future__ import annotations
# Prevent pytest from treating this as a test file
__test__ = False
import asyncio
import os
@@ -1,6 +1,10 @@
#!/usr/bin/env python3
from __future__ import annotations
"""W1 deep-validation smoke test.
This is not a pytest test file - it's an integration smoke test.
Exercises every robustness dimension of W1 with REAL LLM calls:
T1. 3-platform unification (telegram → discord → slack, one user)
@@ -12,11 +16,12 @@ Exercises every robustness dimension of W1 with REAL LLM calls:
Run::
cd /Users/praison/worktrees/hermes-parity
PYTHONPATH=src/praisonai-agents:src/praisonai python scripts/smoke_w1_robust.py
cd /path/to/PraisonAI
PYTHONPATH=src/praisonai-agents:src/praisonai python src/praisonai/tests/integration/smoke_w1_robust.py
"""
from __future__ import annotations
# Prevent pytest from treating this as a test file
__test__ = False
import asyncio
import os
@@ -22,14 +22,17 @@ class TestAgentMailPlatformRegistry:
from praisonai.bots._registry import _BUILTIN_PLATFORMS
assert "agentmail" in _BUILTIN_PLATFORMS
assert _BUILTIN_PLATFORMS["agentmail"] == ("praisonai.bots.agentmail", "AgentMailBot")
loader = _BUILTIN_PLATFORMS["agentmail"]
assert callable(loader)
assert loader.__name__ == "_agentmail_loader"
def test_resolve_adapter_returns_agentmail_bot(self):
"""resolve_adapter('agentmail') should return AgentMailBot class."""
from praisonai.bots import AgentMailBot
from praisonai.bots._registry import resolve_adapter
cls = resolve_adapter("agentmail")
assert cls.__name__ == "AgentMailBot"
assert cls is AgentMailBot
def test_list_platforms_includes_agentmail(self):
"""list_platforms() should include 'agentmail'."""
@@ -24,14 +24,17 @@ class TestEmailPlatformRegistry:
from praisonai.bots._registry import _BUILTIN_PLATFORMS
assert "email" in _BUILTIN_PLATFORMS
assert _BUILTIN_PLATFORMS["email"] == ("praisonai.bots.email", "EmailBot")
loader = _BUILTIN_PLATFORMS["email"]
assert callable(loader)
assert loader.__name__ == "_email_loader"
def test_resolve_adapter_returns_email_bot(self):
"""Verify resolve_adapter('email') returns EmailBot class."""
from praisonai.bots import EmailBot
from praisonai.bots._registry import resolve_adapter
EmailBot = resolve_adapter("email")
assert EmailBot.__name__ == "EmailBot"
cls = resolve_adapter("email")
assert cls is EmailBot
def test_list_platforms_includes_email(self):
"""Verify list_platforms() includes email."""
@@ -0,0 +1,23 @@
"""write_file must constrain paths when workspace is omitted."""
from __future__ import annotations
import os
from praisonai.code.tools.write_file import write_file
def test_write_file_without_workspace_stays_in_cwd(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
ok = write_file("out.txt", "hello", workspace=None)
assert ok["success"] is True
assert (tmp_path / "out.txt").read_text() == "hello"
def test_write_file_without_workspace_blocks_escape(tmp_path, monkeypatch):
project = tmp_path / "project"
project.mkdir()
monkeypatch.chdir(project)
result = write_file("../outside.txt", "nope", workspace=None)
assert result["success"] is False
assert "outside" in result["error"].lower()
@@ -0,0 +1,19 @@
"""Generated deploy API servers must enable auth by default."""
from praisonai.deploy.api import generate_api_server_code
from praisonai.deploy.models import APIConfig
def test_generate_api_server_auth_enabled_by_default():
code = generate_api_server_code("agents.yaml", APIConfig())
assert "AUTH_ENABLED" in code
assert "'enabled'" in code or '"enabled"' in code
assert "check_auth" in code
assert "compare_digest" in code
def test_generate_api_server_respects_disabled_config():
code = generate_api_server_code(
"agents.yaml", APIConfig(auth_enabled=False)
)
assert "'disabled'" in code or '"disabled"' in code
@@ -1,5 +1,7 @@
"""Unit tests for jobs store."""
import asyncio
import threading
import pytest
from praisonai.jobs.models import Job, JobStatus
@@ -237,3 +239,74 @@ class TestInMemoryJobStore:
assert "status_counts" in stats
assert stats["status_counts"]["queued"] == 1
assert stats["status_counts"]["running"] == 1
class TestInMemoryJobStoreConcurrency:
"""Concurrency tests for InMemoryJobStore."""
@pytest.mark.asyncio
async def test_concurrent_saves_no_data_loss(self):
"""Concurrent save() calls must not lose jobs."""
store = InMemoryJobStore(max_jobs=1000)
jobs = [Job(prompt=f"Job {i}") for i in range(50)]
await asyncio.gather(*[store.save(job) for job in jobs])
count = await store.count()
assert count == 50
@pytest.mark.asyncio
async def test_concurrent_reads_while_writing(self):
"""list_jobs() and count() must not raise while saves are in flight."""
store = InMemoryJobStore(max_jobs=1000)
errors = []
async def writer():
for i in range(20):
await store.save(Job(prompt=f"job {i}"))
async def reader():
for _ in range(30):
try:
await store.list_jobs()
await store.count()
except Exception as e:
errors.append(e)
await asyncio.gather(writer(), reader(), reader())
assert errors == [], f"Concurrent read/write raised: {errors}"
@pytest.mark.asyncio
async def test_get_stats_consistent_snapshot(self):
"""get_stats() must not raise RuntimeError with concurrent mutations."""
store = InMemoryJobStore(max_jobs=1000)
errors = []
async def writer():
for i in range(30):
await store.save(Job(prompt=f"job {i}"))
def stats_reader():
for _ in range(50):
try:
store.get_stats()
except RuntimeError as e:
errors.append(e)
# Run stats reader in a thread while async writer runs
t = threading.Thread(target=stats_reader)
t.start()
await writer()
t.join()
assert errors == [], f"get_stats raised RuntimeError: {errors}"
@pytest.mark.asyncio
async def test_get_stats_returns_correct_total(self):
"""get_stats() total_jobs must match actual job count."""
store = InMemoryJobStore(max_jobs=100)
for i in range(5):
await store.save(Job(prompt=f"Job {i}"))
stats = store.get_stats()
assert stats["total_jobs"] == 5
@@ -0,0 +1,21 @@
"""MCP CLI tools must not read arbitrary filesystem paths."""
from __future__ import annotations
import pytest
def test_resolve_cwd_yaml_rejects_traversal(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
from praisonai.mcp_server.adapters import cli_tools
(tmp_path / "workflow.yaml").write_text("framework: test\ntopic: t\n")
path = cli_tools._resolve_cwd_yaml_path("workflow.yaml")
assert path.name == "workflow.yaml"
with pytest.raises(ValueError):
cli_tools._resolve_cwd_yaml_path("../../etc/passwd")
with pytest.raises(ValueError):
cli_tools._resolve_cwd_yaml_path("/etc/passwd")
@@ -169,8 +169,7 @@ class TestAgentSchedulerExecution:
with pytest.raises(Exception, match="Agent error"):
scheduler.execute_once()
@patch('time.sleep')
def test_execute_with_retry_success_first_try(self, mock_sleep):
def test_execute_with_retry_success_first_try(self):
"""Test _execute_with_retry() succeeds on first try."""
mock_agent = Mock()
mock_agent.start = Mock(return_value="Success")
@@ -180,10 +179,8 @@ class TestAgentSchedulerExecution:
assert scheduler._success_count == 1
assert scheduler._failure_count == 0
mock_sleep.assert_not_called()
@patch('time.sleep')
def test_execute_with_retry_success_on_retry(self, mock_sleep):
def test_execute_with_retry_success_on_retry(self):
"""Test _execute_with_retry() succeeds on retry."""
mock_agent = Mock()
mock_agent.start = Mock(side_effect=[
@@ -192,37 +189,56 @@ class TestAgentSchedulerExecution:
])
scheduler = AgentScheduler(mock_agent, "Test task")
# Mock Event.wait() to return False immediately (not stopped, backoff skipped)
scheduler._stop_event.wait = Mock(return_value=False)
scheduler._execute_with_retry(max_retries=3)
assert scheduler._success_count == 1
assert scheduler._failure_count == 0
assert mock_agent.start.call_count == 2
@patch('time.sleep')
def test_execute_with_retry_all_fail(self, mock_sleep):
def test_execute_with_retry_all_fail(self):
"""Test _execute_with_retry() when all retries fail."""
mock_agent = Mock()
mock_agent.start = Mock(side_effect=Exception("Always fails"))
scheduler = AgentScheduler(mock_agent, "Test task")
# Mock Event.wait() to return False immediately (not stopped, backoff skipped)
scheduler._stop_event.wait = Mock(return_value=False)
scheduler._execute_with_retry(max_retries=3)
assert scheduler._success_count == 0
assert scheduler._failure_count == 1
assert mock_agent.start.call_count == 3
@patch('time.sleep')
def test_execute_with_retry_exponential_backoff(self, mock_sleep):
"""Test _execute_with_retry() uses exponential backoff."""
def test_execute_with_retry_backoff_uses_stop_event(self):
"""Test _execute_with_retry() uses Event.wait() for backoff, not time.sleep()."""
mock_agent = Mock()
mock_agent.start = Mock(side_effect=Exception("Always fails"))
scheduler = AgentScheduler(mock_agent, "Test task")
scheduler._stop_event.wait = Mock(return_value=False)
scheduler._execute_with_retry(max_retries=3)
# Should sleep 30s, then 60s (exponential backoff)
assert mock_sleep.call_count == 2
mock_sleep.assert_has_calls([call(30), call(60)])
# Should call Event.wait() twice (between attempt 1->2 and 2->3)
assert scheduler._stop_event.wait.call_count == 2
# Each wait should be called with a positive backoff delay
for call_args in scheduler._stop_event.wait.call_args_list:
wait_time = call_args[0][0]
assert wait_time > 0, "backoff delay must be positive"
def test_execute_with_retry_stops_immediately_on_stop_event(self):
"""Test that _execute_with_retry() exits when stop() is called during backoff."""
mock_agent = Mock()
mock_agent.start = Mock(side_effect=Exception("Always fails"))
scheduler = AgentScheduler(mock_agent, "Test task")
# Mock Event.wait() to return True (stop was signaled)
scheduler._stop_event.wait = Mock(return_value=True)
scheduler._execute_with_retry(max_retries=3)
# Should stop after first failure + backoff signal, not complete all retries
assert mock_agent.start.call_count == 1
class TestAgentSchedulerCallbacks:
@@ -239,14 +255,15 @@ class TestAgentSchedulerCallbacks:
on_success.assert_called_once_with("Success result")
@patch('time.sleep')
def test_on_failure_callback_invoked(self, mock_sleep):
def test_on_failure_callback_invoked(self):
"""Test on_failure callback is invoked on failure."""
mock_agent = Mock()
mock_agent.start = Mock(side_effect=Exception("Error"))
on_failure = Mock()
scheduler = AgentScheduler(mock_agent, "Test task", on_failure=on_failure)
# Mock Event.wait() to return False immediately (not stopped, backoff skipped)
scheduler._stop_event.wait = Mock(return_value=False)
scheduler._execute_with_retry(max_retries=2)
on_failure.assert_called_once()
@@ -0,0 +1,17 @@
"""load_tools_from_module must use gated safe loader."""
from __future__ import annotations
from unittest.mock import patch
import pytest
def test_load_tools_from_module_returns_empty_when_blocked():
from praisonai.agents_generator import AgentsGenerator
gen = object.__new__(AgentsGenerator)
with patch(
"praisonai._safe_loader.load_user_module", return_value=None
):
assert gen.load_tools_from_module("/tmp/evil_tools.py") == {}
@@ -10,10 +10,13 @@ class TestPraisonAISessionDataStore:
def test_init_with_default_store(self):
"""Test initialization with default hierarchical store."""
with patch('praisonai.ui._aiui_datastore.get_hierarchical_session_store') as mock_get_store:
import praisonai.ui._aiui_datastore as _ds
with patch("praisonaiagents.session.get_hierarchical_session_store") as mock_get_store, \
patch.object(_ds, "_impl_cls", None):
mock_store = Mock()
mock_get_store.return_value = mock_store
adapter = PraisonAISessionDataStore()
assert adapter._store == mock_store
@@ -231,4 +234,4 @@ class TestPraisonAISessionDataStore:
# Removed test_import_fallback - was a no-op test that passed regardless of behavior
# After Blocker 1 fix, imports now fail loudly instead of falling back silently
# After Blocker 1 fix, imports now fail loudly instead of falling back silently
@@ -114,46 +114,70 @@ class TestSafeStringSubstitution:
class TestThreadSafety:
"""Test thread-safe patterns added in the wrapper layer."""
def test_get_openai_client_returns_same_instance_for_same_key(self):
"""Multiple threads requesting the same key must get the same client object."""
def test_get_openai_client_returns_same_instance_per_generator(self):
"""Multiple threads on one generator must get the same client object."""
import threading
import types
import sys
import praisonai.auto as auto
import unittest.mock as mock
auto._openai_client = None
auto._openai_client_key = None
class DummyOpenAI:
def __init__(self, *args, **kwargs):
pass
def close(self):
pass
generator = auto.BaseAutoGenerator(config_list=[{
"model": "gpt-4o-mini",
"api_key": "test-thread-key",
"base_url": None,
}])
results = []
errors = []
def call_client():
try:
client = auto._get_openai_client(api_key="test-thread-key")
client = generator._get_openai_client()
results.append(id(client))
except Exception as exc:
errors.append(exc)
threads = [threading.Thread(target=call_client) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
with mock.patch.dict(sys.modules, {"openai": types.SimpleNamespace(OpenAI=DummyOpenAI)}):
threads = [threading.Thread(target=call_client) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors, f"Unexpected errors: {errors}"
assert len(results) == 8
# All threads must receive the exact same cached instance
assert len(set(results)) == 1, "Got different client instances across threads"
def test_get_openai_client_rebuilds_on_key_change(self):
"""Changing the (api_key, base_url) pair must produce a new client."""
def test_get_openai_client_is_not_shared_across_generators(self):
"""Different generator instances must not share a client."""
import types
import sys
import praisonai.auto as auto
import unittest.mock as mock
auto._openai_client = None
auto._openai_client_key = None
class DummyOpenAI:
def __init__(self, *args, **kwargs):
pass
client_a = auto._get_openai_client(api_key="key-a")
client_b = auto._get_openai_client(api_key="key-b")
def close(self):
pass
assert id(client_a) != id(client_b), "Different keys must yield different clients"
generator_a = auto.BaseAutoGenerator(config_list=[{"model": "gpt-4o-mini", "api_key": "key-a", "base_url": None}])
generator_b = auto.BaseAutoGenerator(config_list=[{"model": "gpt-4o-mini", "api_key": "key-b", "base_url": None}])
with mock.patch.dict(sys.modules, {"openai": types.SimpleNamespace(OpenAI=DummyOpenAI)}):
client_a = generator_a._get_openai_client()
client_b = generator_b._get_openai_client()
assert id(client_a) != id(client_b), "Different generators must own distinct clients"
def test_get_openai_client_no_root_logger_mutation(self):
"""Importing auto must not add any new handlers to the root logger."""
@@ -220,4 +244,3 @@ class TestThreadSafety:
assert main_mod._typer_commands_cache is None, (
"Cache must remain None after failure to allow retries"
)
@@ -0,0 +1,40 @@
"""Call server agent API must not fail open without CALL_SERVER_TOKEN."""
from __future__ import annotations
import importlib
import os
import pytest
pytest.importorskip("fastapi")
@pytest.mark.asyncio
async def test_verify_token_requires_token_by_default(monkeypatch):
monkeypatch.delenv("CALL_SERVER_TOKEN", raising=False)
monkeypatch.delenv("PRAISONAI_CALL_AUTH", raising=False)
mod = importlib.import_module("praisonai.api.agent_invoke")
importlib.reload(mod)
class _Req:
query_params = {}
with pytest.raises(Exception) as exc:
await mod.verify_token(_Req(), authorization=None)
assert "CALL_SERVER_TOKEN" in str(exc.value)
@pytest.mark.asyncio
async def test_verify_token_optout(monkeypatch):
monkeypatch.delenv("CALL_SERVER_TOKEN", raising=False)
monkeypatch.setenv("PRAISONAI_CALL_AUTH", "disabled")
mod = importlib.import_module("praisonai.api.agent_invoke")
importlib.reload(mod)
class _Req:
query_params = {}
await mod.verify_token(_Req(), authorization=None)
@@ -13,7 +13,6 @@ from unittest.mock import patch, MagicMock
try:
from praisonai.framework_adapters.validators import assert_framework_available
from praisonai.framework_adapters.registry import FrameworkAdapterRegistry
except ImportError as e:
pytest.skip(f"Could not import framework_adapters: {e}", allow_module_level=True)
@@ -22,7 +21,7 @@ class TestAssertFrameworkAvailableRaises:
"""Tests that ImportError is raised for unavailable frameworks."""
def test_raises_import_error_for_missing_framework(self):
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
mock_registry = MagicMock()
mock_registry.is_available.return_value = False
mock_get.return_value = mock_registry
@@ -31,7 +30,7 @@ class TestAssertFrameworkAvailableRaises:
assert_framework_available("crewai")
def test_error_message_contains_framework_name(self):
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
mock_registry = MagicMock()
mock_registry.is_available.return_value = False
mock_get.return_value = mock_registry
@@ -40,7 +39,7 @@ class TestAssertFrameworkAvailableRaises:
assert_framework_available("crewai")
def test_error_message_contains_install_hint(self):
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
mock_registry = MagicMock()
mock_registry.is_available.return_value = False
mock_get.return_value = mock_registry
@@ -49,7 +48,7 @@ class TestAssertFrameworkAvailableRaises:
assert_framework_available("crewai")
def test_crewai_hint_mentions_praisonai_extra(self):
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
mock_registry = MagicMock()
mock_registry.is_available.return_value = False
mock_get.return_value = mock_registry
@@ -60,7 +59,7 @@ class TestAssertFrameworkAvailableRaises:
assert "praisonai[crewai]" in error_msg or "pip install crewai" in error_msg
def test_autogen_hint_mentions_pyautogen(self):
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
mock_registry = MagicMock()
mock_registry.is_available.return_value = False
mock_get.return_value = mock_registry
@@ -70,7 +69,7 @@ class TestAssertFrameworkAvailableRaises:
assert "autogen" in str(exc_info.value).lower()
def test_unknown_framework_generic_hint(self):
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
mock_registry = MagicMock()
mock_registry.is_available.return_value = False
mock_get.return_value = mock_registry
@@ -84,7 +83,7 @@ class TestAssertFrameworkAvailableSucceeds:
"""Tests that no exception is raised for available frameworks."""
def test_no_error_when_framework_available(self):
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
mock_registry = MagicMock()
mock_registry.is_available.return_value = True
mock_get.return_value = mock_registry
@@ -93,7 +92,7 @@ class TestAssertFrameworkAvailableSucceeds:
assert_framework_available("crewai")
def test_returns_none_for_available_framework(self):
with patch.object(FrameworkAdapterRegistry, "get_instance") as mock_get:
with patch("praisonai.framework_adapters.validators.get_default_registry") as mock_get:
mock_registry = MagicMock()
mock_registry.is_available.return_value = True
mock_get.return_value = mock_registry
@@ -0,0 +1,81 @@
import os
from types import SimpleNamespace
import pytest
from tests import conftest as test_conftest
PROVIDER_API_KEYS = (
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"XAI_API_KEY",
"GROQ_API_KEY",
"COHERE_API_KEY",
)
class MockRequest:
def __init__(self, markers=None, fspath=__file__):
markers = markers or []
self.node = SimpleNamespace(iter_markers=lambda: markers)
self.fspath = fspath
def _run_fixture(request):
"""Execute the fixture generator directly with a synthetic request object."""
fixture_impl = test_conftest.setup_test_environment.__wrapped__
fixture_gen = fixture_impl(request)
next(fixture_gen)
return fixture_gen
def _teardown_fixture(fixture_gen):
with pytest.raises(StopIteration):
next(fixture_gen)
def test_setup_environment_preserves_existing_provider_key_and_restores_missing_keys():
with pytest.MonkeyPatch.context() as mp:
mp.setenv("OPENAI_API_KEY", "real-openai-key")
mp.delenv("ANTHROPIC_API_KEY", raising=False)
fixture_gen = _run_fixture(MockRequest())
assert os.environ["OPENAI_API_KEY"] == "real-openai-key"
assert os.environ["ANTHROPIC_API_KEY"] == "test-key"
_teardown_fixture(fixture_gen)
assert os.environ["OPENAI_API_KEY"] == "real-openai-key"
assert "ANTHROPIC_API_KEY" not in os.environ
def test_setup_environment_fills_placeholders_only_when_keys_are_missing():
with pytest.MonkeyPatch.context() as mp:
for key in PROVIDER_API_KEYS:
mp.delenv(key, raising=False)
fixture_gen = _run_fixture(MockRequest())
for key in PROVIDER_API_KEYS:
assert os.environ[key] == "test-key"
_teardown_fixture(fixture_gen)
for key in PROVIDER_API_KEYS:
assert key not in os.environ
def test_setup_environment_skips_placeholder_keys_for_provider_marked_tests():
marker = SimpleNamespace(name="provider_openai")
with pytest.MonkeyPatch.context() as mp:
mp.delenv("OPENAI_API_KEY", raising=False)
fixture_gen = _run_fixture(MockRequest(markers=[marker]))
assert "OPENAI_API_KEY" not in os.environ
_teardown_fixture(fixture_gen)
@@ -0,0 +1,161 @@
"""Unit tests for ToolRegistry, focusing on thread-safety."""
import threading
import pytest
from praisonai.tool_registry import ToolRegistry
class TestToolRegistryBasic:
"""Basic functionality tests for ToolRegistry."""
def test_register_and_get_function(self):
registry = ToolRegistry()
func = lambda x: x
registry.register_function("my_func", func)
assert registry.get_function("my_func") is func
def test_register_non_callable_raises(self):
registry = ToolRegistry()
with pytest.raises(ValueError):
registry.register_function("bad", "not_callable")
def test_get_missing_function_returns_none(self):
registry = ToolRegistry()
assert registry.get_function("missing") is None
def test_register_and_get_autogen_adapter(self):
registry = ToolRegistry()
adapter = lambda: None
registry.register_autogen_adapter("MyTool", adapter)
assert registry.get_autogen_adapter("MyTool") is adapter
def test_list_functions(self):
registry = ToolRegistry()
registry.register_function("a", lambda: None)
registry.register_function("b", lambda: None)
names = registry.list_functions()
assert sorted(names) == ["a", "b"]
def test_list_autogen_adapters(self):
registry = ToolRegistry()
registry.register_autogen_adapter("T1", lambda: None)
registry.register_autogen_adapter("T2", lambda: None)
names = registry.list_autogen_adapters()
assert sorted(names) == ["T1", "T2"]
def test_get_functions_dict_returns_copy(self):
registry = ToolRegistry()
f = lambda: None
registry.register_function("f", f)
d = registry.get_functions_dict()
assert d == {"f": f}
# Mutating the returned dict does not affect the registry
d["extra"] = lambda: None
assert "extra" not in registry.get_functions_dict()
def test_clear(self):
registry = ToolRegistry()
registry.register_function("f", lambda: None)
registry.register_autogen_adapter("A", lambda: None)
registry.clear()
assert len(registry) == 0
def test_len(self):
registry = ToolRegistry()
assert len(registry) == 0
registry.register_function("f", lambda: None)
assert len(registry) == 1
registry.register_autogen_adapter("A", lambda: None)
assert len(registry) == 2
def test_contains(self):
registry = ToolRegistry()
registry.register_function("f", lambda: None)
assert "f" in registry
assert "missing" not in registry
class TestToolRegistryThreadSafety:
"""Thread-safety tests for ToolRegistry."""
def test_concurrent_writes_no_error(self):
"""Concurrent register_function calls must not raise."""
registry = ToolRegistry()
errors = []
def writer(n):
try:
for i in range(50):
registry.register_function(f"func_{n}_{i}", lambda: None)
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=writer, args=(t,)) for t in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"Concurrent writes raised: {errors}"
assert len(registry) == 250 # 5 threads * 50 registrations
def test_concurrent_reads_writes_no_error(self):
"""Concurrent reads and writes must not raise RuntimeError."""
registry = ToolRegistry()
# Pre-populate
for i in range(20):
registry.register_function(f"pre_{i}", lambda: None)
errors = []
def writer():
try:
for i in range(30):
registry.register_function(f"w_{i}", lambda: None)
except Exception as e:
errors.append(e)
def reader():
try:
for _ in range(100):
registry.list_functions()
registry.get_functions_dict()
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=writer) for _ in range(3)]
threads += [threading.Thread(target=reader) for _ in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"Concurrent read/write raised: {errors}"
def test_concurrent_clear_and_register(self):
"""clear() racing with register_function() must not corrupt state."""
registry = ToolRegistry()
errors = []
def writer():
try:
for i in range(50):
registry.register_function(f"w_{i}", lambda: None)
except Exception as e:
errors.append(e)
def clearer():
try:
for _ in range(20):
registry.clear()
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=writer) for _ in range(3)]
threads += [threading.Thread(target=clearer) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"Concurrent clear/register raised: {errors}"
+1 -1
View File
@@ -4841,7 +4841,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.38"
version = "1.6.40"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },