fixup: security (0.0.0.0→127.0.0.1), ImportError guards, orphan chainlit cleanup

Addresses PR #1525 reviewer feedback (CodeRabbit + Gemini + @claude review):

Security + error handling (cli/main.py):
- Bind realtime UI to 127.0.0.1 instead of 0.0.0.0 (2 sites)
- Wrap both _launch_aiui_app calls in try/except ImportError with
  install hint (restores the guard the original CHAINLIT_AVAILABLE
  check used to provide)

Orphan chainlit file cleanup:
- rm ui/colab.py (module-level 'import chainlit' — callers deleted)
- rm -r chat/ (chat/__init__.py wraps run_chainlit; chat/app.py gone)
- rm ui/_auth.py (chainlit password_auth_callback — callers deleted)
- rm ui/_pairing.py (chainlit pairing banner — callers deleted)
- Trim ui/_external_agents.py: drop chainlit_switches,
  load_external_agent_settings_from_chainlit,
  save_external_agent_settings_to_chainlit (keep aiui helpers)

Stale test cleanup:
- rm tests/unit/ui/test_ui_bind_aware_creds.py (tested deleted _auth.py)
- rm tests/integration/ui/test_ui_pairing_approval.py (tested deleted _pairing.py)
- rm tests/unit/test_ui_external_agents_helper.py (tested deleted helper)
- rm tests/integration/test_ui_external_agents.py (tested deleted helper)
- Strip obsolete test_chainlit_app_config from test_tools_and_ui.py

Cosmetic:
- serve.py help text: 'Chainlit web interface' → 'aiui web interface'

Validation without chainlit installed:
- pip uninstall chainlit
- 44/44 unit tests pass (cli/test_chat_flag + test_serve_unified + test_tools_and_ui)
- All CLI imports clean

Remaining chainlit refs are in docstrings/comments only (historical
context) + the intentionally-kept --ui chainlit deprecation alias.
This commit is contained in:
Cascade Bot committed 2026-04-23 00:21:33 +01:00
1 parent 648291e3e3
commit 682a9486fd
12 files changed
+19 -1785

No files matched your search

-106
View File
@@ -1,106 +0,0 @@
"""
PraisonAI Chat Module
This module provides the chat UI integration for PraisonAI agents.
It uses the PraisonAI Chat (based on Chainlit) for the frontend.
"""
from typing import Optional, Any
__all__ = ["start_chat_server", "ChatConfig"]
class ChatConfig:
"""Configuration for the PraisonAI Chat server."""
def __init__(
self,
host: str = "0.0.0.0",
port: int = 8000,
debug: bool = False,
auth_enabled: bool = False,
session_id: Optional[str] = None,
):
self.host = host
self.port = port
self.debug = debug
self.auth_enabled = auth_enabled
self.session_id = session_id
def start_chat_server(
agent: Optional[Any] = None,
agents: Optional[list] = None,
config: Optional[ChatConfig] = None,
port: int = 8000,
host: str = "0.0.0.0",
debug: bool = False,
) -> None:
"""
Start the PraisonAI Chat server.
Args:
agent: A single PraisonAI agent to use in the chat.
agents: A list of PraisonAI agents for multi-agent chat.
config: ChatConfig object with server settings.
port: Port to run the server on (default: 8000).
host: Host to bind to (default: 0.0.0.0).
debug: Enable debug mode (default: False).
Example:
>>> from praisonaiagents import Agent
>>> from praisonai.chat import start_chat_server
>>>
>>> agent = Agent(name="Assistant", instructions="You are helpful.")
>>> start_chat_server(agent=agent, port=8000)
"""
# Lazy import to avoid loading chainlit unless needed
try:
from chainlit.cli import run_chainlit
except ImportError:
raise ImportError(
"PraisonAI Chat requires the 'praisonai-chat' package. "
"Install it with: pip install praisonai-chat"
)
if config is None:
config = ChatConfig(host=host, port=port, debug=debug)
# Store agents in a way accessible to the chainlit app
import os
os.environ["PRAISONAI_CHAT_MODE"] = "true"
if agent is not None:
# Single agent mode
_register_agent(agent)
elif agents is not None:
# Multi-agent mode
for a in agents:
_register_agent(a)
# Start the chainlit server
from pathlib import Path
# Use the built-in app file
app_file = Path(__file__).parent / "app.py"
# Set environment variables for chainlit
os.environ["CHAINLIT_HOST"] = config.host
os.environ["CHAINLIT_PORT"] = str(config.port)
run_chainlit(str(app_file))
# Global registry for agents
_REGISTERED_AGENTS: dict = {}
def _register_agent(agent: Any) -> None:
"""Register an agent for use in the chat UI."""
agent_name = getattr(agent, "name", None) or getattr(agent, "role", "Agent")
_REGISTERED_AGENTS[agent_name] = agent
def get_registered_agents() -> dict:
"""Get all registered agents."""
return _REGISTERED_AGENTS
@@ -10,7 +10,7 @@ Server Types:
- mcp: MCP server (Model Context Protocol)
- acp: ACP server (Agent Client Protocol for IDEs)
- lsp: Language Server Protocol
- ui: Web UI (Chainlit)
- ui: Web UI (aiui)
- rag: RAG query server
- registry: Package registry server
- docs: Documentation preview server
@@ -116,7 +116,7 @@ Start any PraisonAI server with: praisonai serve <type>
[green]mcp[/green] MCP server for Claude/Cursor (port 8080)
[green]acp[/green] Agent Client Protocol for IDEs (STDIO)
[green]lsp[/green] Language Server Protocol (STDIO)
[green]ui[/green] Chainlit web interface (port 8082)
[green]ui[/green] aiui web interface (port 8082)
[green]rag[/green] RAG query server (port 9000)
[green]registry[/green] Package registry server (port 7777)
[green]docs[/green] Documentation preview (port 3000)
@@ -351,7 +351,7 @@ def serve_ui(
port: int = typer.Option(8082, "--port", "-p", help="Port to bind to"),
ui_type: str = typer.Option("agents", "--type", "-t", help="UI type: agents, chat, code, realtime"),
):
"""Start Chainlit web UI server.
"""Start aiui web UI server.
Examples:
praisonai serve ui
+14 -5
View File
@@ -557,11 +557,15 @@ class PraisonAI:
return
# chat and code commands are now terminal-native (handled by Typer commands)
# They no longer open Chainlit browser UI
if getattr(args, 'realtime', False):
from praisonai.cli.commands.ui import _launch_aiui_app
_launch_aiui_app("ui_realtime", "ui_realtime", 8085, "0.0.0.0", None, False, "Realtime Voice")
try:
from praisonai.cli.commands.ui import _launch_aiui_app
_launch_aiui_app("ui_realtime", "ui_realtime", 8085, "127.0.0.1", None, False, "Realtime Voice")
except ImportError:
print("\033[91mERROR: Realtime UI is not installed.\033[0m")
print('Install with: pip install "praisonai[ui]"')
sys.exit(1)
return
if getattr(args, 'call', False):
@@ -1218,8 +1222,13 @@ class PraisonAI:
sys.exit(0)
elif args.command == 'realtime':
from praisonai.cli.commands.ui import _launch_aiui_app
_launch_aiui_app("ui_realtime", "ui_realtime", 8085, "0.0.0.0", None, False, "Realtime Voice")
try:
from praisonai.cli.commands.ui import _launch_aiui_app
_launch_aiui_app("ui_realtime", "ui_realtime", 8085, "127.0.0.1", None, False, "Realtime Voice")
except ImportError:
print("\033[91mERROR: Realtime UI is not installed.\033[0m")
print('Install with: pip install "praisonai[ui]"')
sys.exit(1)
sys.exit(0)
elif args.command == 'train':
-80
View File
@@ -1,80 +0,0 @@
"""
Chainlit UI authentication helper.
Provides bind-aware auth for Chainlit apps - consolidates duplicated
password auth callbacks across UI modules.
"""
from __future__ import annotations
import os
import logging
from typing import Optional
from praisonaiagents.gateway.protocols import AuthMode, is_loopback, resolve_auth_mode
logger = logging.getLogger(__name__)
class UIStartupError(Exception):
"""Raised when UI cannot start due to security configuration."""
pass
def register_password_auth(app, *, bind_host: str) -> None:
"""Register password authentication for Chainlit app with bind-aware security.
Args:
app: Chainlit app instance (unused but kept for consistency)
bind_host: Host/IP that the UI server is bound to
Raises:
UIStartupError: If using default credentials on external interface
"""
import chainlit as cl # lazy — chainlit is in [ui] extra only
# Get credentials from environment
expected_username = os.getenv("CHAINLIT_USERNAME", "admin")
expected_password = os.getenv("CHAINLIT_PASSWORD", "admin")
# Check if using default credentials
using_defaults = (expected_username == "admin" and expected_password == "admin")
# Resolve auth mode based on bind interface
auth_mode = resolve_auth_mode(bind_host)
# Allow default credentials only on loopback
if using_defaults and auth_mode != "local":
# Check for escape hatch
allow_defaults = os.getenv("PRAISONAI_ALLOW_DEFAULT_CREDS", "").lower() in ("1", "true", "yes")
if not allow_defaults:
raise UIStartupError(
f"Cannot bind to {bind_host} with default admin/admin credentials.\n"
f"Fix: export CHAINLIT_USERNAME=myuser CHAINLIT_PASSWORD=mypass\n"
f"Lab: export PRAISONAI_ALLOW_DEFAULT_CREDS=1 (demo only)"
)
else:
logger.warning(
f"⚠️ Using default admin/admin credentials on external interface {bind_host}. "
f"This is UNSAFE for production. Set CHAINLIT_USERNAME and CHAINLIT_PASSWORD."
)
elif using_defaults and auth_mode == "local":
logger.warning(
f"⚠️ Using default admin/admin credentials on loopback {bind_host}. "
f"Set CHAINLIT_USERNAME and CHAINLIT_PASSWORD environment variables for production."
)
# Register the password auth callback
@cl.password_auth_callback
def auth_callback(username: str, password: str) -> Optional["cl.User"]:
"""Password authentication callback."""
logger.debug("Auth attempt received")
if (username, password) == (expected_username, expected_password):
logger.info("Login successful")
return cl.User(identifier=username, metadata={"role": "admin", "provider": "credentials"})
else:
logger.warning("Login failed")
return None
# Log the registration
auth_status = "permissive" if auth_mode == "local" else "strict"
logger.info(f"Registered password auth for {bind_host} (mode: {auth_status})")
+2 -68
View File
@@ -2,12 +2,12 @@
Single source of truth for:
- Listing installed external agents (lazy, cached)
- Rendering Chainlit Switch widgets / aiui settings entries
- Rendering aiui settings entries
- Building the tools list from enabled agents
"""
from functools import lru_cache
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Dict, List
# Map of UI toggle id → (integration class path, pretty label)
EXTERNAL_AGENTS: Dict[str, Dict[str, str]] = {
@@ -54,16 +54,6 @@ def external_agent_tools(settings: Dict[str, Any], workspace: str = ".") -> list
return tools
def chainlit_switches(current_settings: Dict[str, bool]):
"""Return Chainlit Switch widgets for installed external agents only."""
from chainlit.input_widget import Switch
return [
Switch(id=toggle_id, label=EXTERNAL_AGENTS[toggle_id]["label"],
initial=current_settings.get(toggle_id, False))
for toggle_id in installed_external_agents()
]
def aiui_settings_entries() -> Dict[str, Any]:
"""Return aiui settings entries for installed external agents."""
settings = {}
@@ -83,59 +73,3 @@ def _parse_setting_bool(value: Any) -> bool:
if value is None:
return False
return str(value).strip().lower() in {"true", "1", "yes", "on"}
def load_external_agent_settings_from_chainlit(
load_setting_fn: Optional[Callable[[str], Any]] = None,
) -> Dict[str, bool]:
"""Load external agent settings from Chainlit session and persistent storage.
Args:
load_setting_fn: Optional callback used to load persisted settings by key.
If omitted, falls back to importing ``praisonai.ui.chat.load_setting``
for backward compatibility.
"""
import chainlit as cl
settings = {toggle_id: False for toggle_id in EXTERNAL_AGENTS}
loader = load_setting_fn
# Try to load from persistent storage (if load_setting is available)
if loader is None:
try:
# Backward-compatible fallback for callers that don't pass a loader
from praisonai.ui.chat import load_setting as loader # type: ignore
except ImportError:
loader = None
if loader is not None:
legacy_claude = _parse_setting_bool(loader("claude_code_enabled"))
# Load all current toggles from persistent storage first
for toggle_id in EXTERNAL_AGENTS:
persistent_value = loader(toggle_id)
if persistent_value: # non-empty string means explicitly stored
settings[toggle_id] = _parse_setting_bool(persistent_value)
# Apply legacy migration only where no explicit value was stored
if legacy_claude and not loader("claude_enabled"):
settings["claude_enabled"] = True
# Load from session (may override persistent settings)
# Check for legacy key in session
if _parse_setting_bool(cl.user_session.get("claude_code_enabled", False)):
settings["claude_enabled"] = True
# Load all current toggles from session
for toggle_id in EXTERNAL_AGENTS:
session_value = cl.user_session.get(toggle_id)
if session_value is not None:
settings[toggle_id] = _parse_setting_bool(session_value)
return settings
def save_external_agent_settings_to_chainlit(settings: Dict[str, bool]):
"""Save external agent settings to Chainlit session."""
import chainlit as cl
for toggle_id, enabled in settings.items():
cl.user_session.set(toggle_id, enabled)
-264
View File
@@ -1,264 +0,0 @@
"""
Chainlit UI components for pairing approval banner.
Provides admin banner functionality for approving pending pairing requests.
"""
from __future__ import annotations
import logging
import os
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
# Gateway client configuration
GATEWAY_HOST = os.environ.get("GATEWAY_HOST", "127.0.0.1")
GATEWAY_PORT = int(os.environ.get("GATEWAY_PORT", "8765"))
GATEWAY_TOKEN = os.environ.get("GATEWAY_AUTH_TOKEN", "")
async def get_pending_pairings() -> List[Dict]:
"""Fetch pending pairing requests from gateway API."""
if not GATEWAY_TOKEN:
logger.warning("No GATEWAY_AUTH_TOKEN set, cannot fetch pending pairings")
return []
try:
import aiohttp
url = f"http://{GATEWAY_HOST}:{GATEWAY_PORT}/api/pairing/pending"
headers = {"Authorization": f"Bearer {GATEWAY_TOKEN}"}
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=headers) as resp:
if resp.status == 200:
data = await resp.json()
return data.get("pending", [])
else:
logger.warning(f"Failed to fetch pending pairings: {resp.status}")
return []
except Exception as e:
logger.error(f"Error fetching pending pairings: {e}")
return []
async def approve_pairing(channel: str, code: str) -> bool:
"""Approve a pairing request via gateway API."""
if not GATEWAY_TOKEN:
logger.warning("No GATEWAY_AUTH_TOKEN set, cannot approve pairing")
return False
try:
import aiohttp
url = f"http://{GATEWAY_HOST}:{GATEWAY_PORT}/api/pairing/approve"
headers = {
"Authorization": f"Bearer {GATEWAY_TOKEN}",
"Content-Type": "application/json"
}
data = {"channel": channel, "code": code}
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, headers=headers, json=data) as resp:
success = resp.status == 200
if not success:
logger.warning(f"Failed to approve pairing: {resp.status}")
return success
except Exception as e:
logger.error(f"Error approving pairing: {e}")
return False
async def deny_pairing(channel: str, code: str) -> bool:
"""Deny a pairing request (logs denial - code will expire naturally)."""
# Note: Currently there's no dedicated deny endpoint in the gateway
# The pending request will expire naturally based on TTL
# This is a UI-only action for admin feedback
logger.info(f"Admin denied pairing request: {channel} code {code}")
return True # Always succeed for UI purposes
async def refresh_pending_banner():
"""Display or update the pending pairing banner for admin users."""
import chainlit as cl
# Check if user is admin
user = cl.user_session.get("user")
if not user or user.metadata.get("role") != "admin":
return
pending = await get_pending_pairings()
if not pending:
# No pending requests - don't show banner
return
# Create approval actions for each pending request
actions = []
for p in pending:
channel = p.get("channel", "unknown")
code = p.get("code", "")
user_name = p.get("user_name", f"User {code}")
age = p.get("age_seconds", 0)
# Format age nicely
if age < 60:
age_str = f"{age}s ago"
elif age < 3600:
age_str = f"{age//60}m ago"
else:
age_str = f"{age//3600}h ago"
actions.append(
cl.Action(
name="approve_pairing",
value=f"{channel}:{code}",
label=f"✅ Approve {user_name} ({channel}) - {age_str}",
description=f"Approve pairing request from {user_name} on {channel}"
)
)
actions.append(
cl.Action(
name="deny_pairing",
value=f"{channel}:{code}",
label=f"❌ Deny {user_name} ({channel})",
description=f"Deny pairing request from {user_name} on {channel}",
)
)
# Display or update banner message with actions
banner_content = f"🔔 **{len(pending)} pending pairing request(s)**\n\nClick to approve:"
# Check if we already have a banner message stored in the session
banner_msg_id = cl.user_session.get("pending_banner_id")
if banner_msg_id:
# Try to update existing message
try:
# Get the existing message and update it
# Note: Chainlit's update functionality depends on the specific version
# For now, send a new message and store its ID
msg = await cl.Message(
content=banner_content,
actions=actions,
author="System"
).send()
cl.user_session.set("pending_banner_id", msg.id)
except Exception as e:
logger.warning(f"Failed to update banner message: {e}")
# Fall back to sending new message
msg = await cl.Message(
content=banner_content,
actions=actions,
author="System"
).send()
cl.user_session.set("pending_banner_id", msg.id)
else:
# Send new banner message and store its ID
msg = await cl.Message(
content=banner_content,
actions=actions,
author="System"
).send()
cl.user_session.set("pending_banner_id", msg.id)
async def on_approve_pairing(action):
"""Handle approval action from banner."""
import chainlit as cl
try:
# Parse channel:code from action value
channel, code = action.value.split(":", 1)
# Show loading message
await cl.Message(
content=f"⏳ Approving pairing for {channel} code {code}...",
author="System"
).send()
# Approve the pairing
success = await approve_pairing(channel, code)
if success:
await cl.Message(
content=f"✅ Successfully approved pairing for {channel} code {code}",
author="System"
).send()
else:
await cl.Message(
content=f"❌ Failed to approve pairing for {channel} code {code}",
author="System"
).send()
# Refresh the banner to update count
await refresh_pending_banner()
except Exception as e:
logger.error(f"Error in approval handler: {e}")
await cl.Message(
content=f"❌ Error processing approval: {str(e)}",
author="System"
).send()
async def on_deny_pairing(action):
"""Handle denial action from banner."""
import chainlit as cl
try:
# Parse channel:code from action value
channel, code = action.value.split(":", 1)
# Show loading message
await cl.Message(
content=f"⏳ Denying pairing for {channel} code {code}...",
author="System"
).send()
# Deny the pairing
success = await deny_pairing(channel, code)
if success:
await cl.Message(
content=f"✅ Successfully denied pairing for {channel} code {code}",
author="System"
).send()
else:
await cl.Message(
content=f"❌ Failed to deny pairing for {channel} code {code}",
author="System"
).send()
# Refresh the banner to update count
await refresh_pending_banner()
except Exception as e:
logger.error(f"Error in denial handler: {e}")
await cl.Message(
content=f"❌ Error processing denial: {str(e)}",
author="System"
).send()
def setup_pairing_callbacks():
"""Setup pairing action callbacks (call this at module level after chainlit import)."""
import chainlit as cl
@cl.action_callback("approve_pairing")
async def _approve_callback(action):
await on_approve_pairing(action)
@cl.action_callback("deny_pairing")
async def _deny_callback(action):
await on_deny_pairing(action)
async def setup_pairing_banner():
"""Setup pairing banner on chat start (call this from @cl.on_chat_start)."""
await refresh_pending_banner()
-474
View File
@@ -1,474 +0,0 @@
from praisonaiagents import Agent, Task, AgentTeam
import os
import importlib
import inspect
import yaml
import logging
from .callbacks import trigger_callback
import asyncio
import chainlit as cl
from queue import Queue
logger = logging.getLogger(__name__)
agent_file = "agents.yaml"
with open(agent_file, 'r') as f:
config = yaml.safe_load(f)
topic = "get from the message content from the chainlit user message"
# Create a message queue
message_queue = Queue()
async def process_message_queue():
"""Process messages in the queue and send them to Chainlit"""
while True:
try:
if not message_queue.empty():
msg_data = message_queue.get()
await cl.Message(**msg_data).send()
await asyncio.sleep(0.1) # Small delay to prevent busy waiting
except Exception as e:
logger.error(f"Error processing message queue: {e}")
def load_tools_from_tools_py():
"""
Imports and returns all contents from tools.py file.
Also adds the tools to the global namespace.
Returns:
list: A list of callable functions with proper formatting
"""
tools_list = []
try:
# Try to import tools.py from current directory
spec = importlib.util.spec_from_file_location("tools", "tools.py")
logger.info(f"Spec: {spec}")
if spec is None:
logger.info("tools.py not found in current directory")
return tools_list
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Get all module attributes except private ones and classes
for name, obj in inspect.getmembers(module):
if (not name.startswith('_') and
callable(obj) and
not inspect.isclass(obj)):
# Add the function to global namespace
globals()[name] = obj
# Format the tool as an OpenAI function
tool = {
"type": "function",
"function": {
"name": name,
"description": obj.__doc__ or f"Function to {name.replace('_', ' ')}",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query to look up information about"
}
},
"required": ["query"]
}
}
}
# Add formatted tool to tools list
tools_list.append(tool)
logger.info(f"Loaded and globalized tool function: {name}")
logger.info(f"Loaded {len(tools_list)} tool functions from tools.py")
logger.info(f"Tools list: {tools_list}")
except Exception as e:
logger.warning(f"Error loading tools from tools.py: {e}")
return tools_list
async def step_callback(step_details):
"""Callback for agent steps"""
logger.info(f"[CALLBACK DEBUG] Step callback triggered with details: {step_details}")
try:
# Queue message for agent response
if step_details.get("response"):
message_queue.put({
"content": f"Agent Response: {step_details.get('response')}",
"author": step_details.get("agent_name", "Agent")
})
logger.info("[CALLBACK DEBUG] Queued agent response message")
# Queue message for tool usage
if step_details.get("tool_name"):
message_queue.put({
"content": f"🛠️ Using tool: {step_details.get('tool_name')}",
"author": "System"
})
logger.info("[CALLBACK DEBUG] Queued tool usage message")
except Exception as e:
logger.error(f"[CALLBACK DEBUG] Error in step callback: {str(e)}", exc_info=True)
async def task_callback(task_output):
"""Callback for task completion"""
logger.info(f"[CALLBACK DEBUG] Task callback triggered with output: {task_output}")
try:
# Create message content
if hasattr(task_output, 'raw'):
content = task_output.raw
elif hasattr(task_output, 'content'):
content = task_output.content
else:
content = str(task_output)
# Queue the message
message_queue.put({
"content": f"Task Output: {content}",
"author": "Task"
})
logger.info("[CALLBACK DEBUG] Queued task completion message")
except Exception as e:
logger.error(f"[CALLBACK DEBUG] Error in task callback: {str(e)}", exc_info=True)
async def step_callback_wrapper(step_details):
logger.info(f"[CALLBACK DEBUG] Step callback wrapper triggered with details: {step_details}")
try:
# Check if we have a Chainlit context
if not cl.context.context_var.get():
logger.warning("[CALLBACK DEBUG] No Chainlit context available in wrapper")
return
logger.info("[CALLBACK DEBUG] Chainlit context found in wrapper")
# Create a message for the agent's response
if step_details.get("response"):
logger.info(f"[CALLBACK DEBUG] Sending agent response from wrapper: {step_details.get('response')}")
try:
await cl.Message(
content=f"{role_name}: {step_details.get('response')}",
author=role_name,
).send()
logger.info("[CALLBACK DEBUG] Successfully sent agent response message from wrapper")
except Exception as e:
logger.error(f"[CALLBACK DEBUG] Error sending agent response message from wrapper: {str(e)}")
# Create a message for any tool usage
if step_details.get("tool_name"):
logger.info(f"[CALLBACK DEBUG] Sending tool usage from wrapper: {step_details.get('tool_name')}")
try:
await cl.Message(
content=f"🛠️ {role_name} is using tool: {step_details.get('tool_name')}",
author="System",
).send()
logger.info("[CALLBACK DEBUG] Successfully sent tool usage message from wrapper")
except Exception as e:
logger.error(f"[CALLBACK DEBUG] Error sending tool usage message from wrapper: {str(e)}")
# Create a message for any thoughts or reasoning
if step_details.get("thought"):
logger.info(f"[CALLBACK DEBUG] Sending thought from wrapper: {step_details.get('thought')}")
try:
await cl.Message(
content=f"💭 {role_name}'s thought: {step_details.get('thought')}",
author=role_name,
).send()
logger.info("[CALLBACK DEBUG] Successfully sent thought message from wrapper")
except Exception as e:
logger.error(f"[CALLBACK DEBUG] Error sending thought message from wrapper: {str(e)}")
except Exception as e:
logger.error(f"[CALLBACK DEBUG] Error in step callback wrapper: {str(e)}", exc_info=True)
try:
await cl.Message(
content=f"Error in step callback: {str(e)}",
author="System",
).send()
except Exception as send_error:
logger.error(f"[CALLBACK DEBUG] Error sending error message: {str(send_error)}")
async def task_callback_wrapper(task_output):
logger.info(f"[CALLBACK DEBUG] Task callback wrapper triggered with output type: {type(task_output)}")
try:
# Check if we have a Chainlit context
if not cl.context.context_var.get():
logger.warning("[CALLBACK DEBUG] No Chainlit context available in task wrapper")
return
logger.info("[CALLBACK DEBUG] Chainlit context found in task wrapper")
# Create a message for task completion
if hasattr(task_output, 'raw'):
content = task_output.raw
logger.info("[CALLBACK DEBUG] Using raw output")
elif hasattr(task_output, 'content'):
content = task_output.content
logger.info("[CALLBACK DEBUG] Using content output")
else:
content = str(task_output)
logger.info("[CALLBACK DEBUG] Using string representation of output")
logger.info(f"[CALLBACK DEBUG] Sending task completion message from wrapper: {content[:100]}...")
try:
await cl.Message(
content=f"✅ {role_name} completed task:\n{content}",
author=role_name,
).send()
logger.info("[CALLBACK DEBUG] Successfully sent task completion message from wrapper")
except Exception as e:
logger.error(f"[CALLBACK DEBUG] Error sending task completion message from wrapper: {str(e)}")
# If there are any additional task details
if hasattr(task_output, 'details'):
logger.info("[CALLBACK DEBUG] Task has additional details")
try:
await cl.Message(
content=f"📝 Additional details:\n{task_output.details}",
author=role_name,
).send()
logger.info("[CALLBACK DEBUG] Successfully sent additional details message")
except Exception as e:
logger.error(f"[CALLBACK DEBUG] Error sending additional details message: {str(e)}")
except Exception as e:
logger.error(f"[CALLBACK DEBUG] Error in task callback wrapper: {str(e)}", exc_info=True)
try:
await cl.Message(
content=f"Error in task callback: {str(e)}",
author="System",
).send()
except Exception as send_error:
logger.error(f"[CALLBACK DEBUG] Error sending error message: {str(send_error)}")
def sync_task_callback_wrapper(task_output):
logger.info("[CALLBACK DEBUG] Sync task callback wrapper triggered")
try:
# Create a new event loop for this thread if there isn't one
try:
loop = asyncio.get_event_loop()
logger.info("[CALLBACK DEBUG] Got existing event loop")
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
logger.info("[CALLBACK DEBUG] Created new event loop")
if loop.is_running():
# If loop is running, schedule the callback
logger.info("[CALLBACK DEBUG] Loop is running, scheduling callback")
asyncio.run_coroutine_threadsafe(
task_callback_wrapper(task_output),
loop
)
else:
# If loop is not running, run it directly
logger.info("[CALLBACK DEBUG] Loop is not running, running callback directly")
loop.run_until_complete(task_callback_wrapper(task_output))
except Exception as e:
logger.error(f"[CALLBACK DEBUG] Error in sync task callback: {str(e)}", exc_info=True)
def sync_step_callback_wrapper(step_details):
logger.info("[CALLBACK DEBUG] Sync step callback wrapper triggered")
try:
# Create a new event loop for this thread if there isn't one
try:
loop = asyncio.get_event_loop()
logger.info("[CALLBACK DEBUG] Got existing event loop")
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
logger.info("[CALLBACK DEBUG] Created new event loop")
if loop.is_running():
# If loop is running, schedule the callback
logger.info("[CALLBACK DEBUG] Loop is running, scheduling callback")
asyncio.run_coroutine_threadsafe(
step_callback_wrapper(step_details),
loop
)
else:
# If loop is not running, run it directly
logger.info("[CALLBACK DEBUG] Loop is not running, running callback directly")
loop.run_until_complete(step_callback_wrapper(step_details))
except Exception as e:
logger.error(f"[CALLBACK DEBUG] Error in sync step callback: {str(e)}", exc_info=True)
async def ui_run_praisonai(config, topic, tools_dict):
"""Run PraisonAI with the given configuration and topic."""
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
agents = {}
tasks = []
tasks_dict = {}
try:
# Start message queue processor
queue_processor = asyncio.create_task(process_message_queue())
# Create agents for each role
for role, details in config['roles'].items():
# Format the role name and other details
role_name = details.get('name', role).format(topic=topic)
role_filled = details.get('role', role).format(topic=topic)
goal_filled = details['goal'].format(topic=topic)
backstory_filled = details['backstory'].format(topic=topic)
# Test message to verify Chainlit is working
await cl.Message(
content=f"[DEBUG] Creating agent: {role_name}",
author="System"
).send()
# Create a sync wrapper for the step callback
def step_callback_sync(step_details):
try:
# Create a new event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Add agent name to step details
step_details["agent_name"] = role_name
# Run the callback
loop.run_until_complete(step_callback(step_details))
loop.close()
except Exception as e:
logger.error(f"[CALLBACK DEBUG] Error in step callback: {str(e)}", exc_info=True)
agent = Agent(
name=role_name,
role=role_filled,
goal=goal_filled,
backstory=backstory_filled,
llm=details.get('llm', 'gpt-5-nano'),
verbose=True,
allow_delegation=details.get('allow_delegation', False),
max_iter=details.get('max_iter', 15),
max_rpm=details.get('max_rpm'),
max_execution_time=details.get('max_execution_time'),
cache=details.get('cache', True),
step_callback=step_callback_sync
)
agents[role] = agent
# Create tasks for each role
for role, details in config['roles'].items():
agent = agents[role]
tools_list = []
# Get tools for this role
for tool_name in details.get('tools', []):
if tool_name in tools_dict:
tool_func = tools_dict[tool_name]
tools_list.append(tool_func)
# Create tasks for the agent
for task_name, task_details in details.get('tasks', {}).items():
description_filled = task_details['description'].format(topic=topic)
expected_output_filled = task_details['expected_output'].format(topic=topic)
# Test message to verify task creation
await cl.Message(
content=f"[DEBUG] Created task: {task_name} for agent {role_name}",
author="System"
).send()
# Create a sync wrapper for the task callback
def task_callback_sync(task_output):
try:
# Create a new event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Run the callback
loop.run_until_complete(task_callback(task_output))
loop.close()
except Exception as e:
logger.error(f"[CALLBACK DEBUG] Error in task callback: {str(e)}", exc_info=True)
task = Task(
description=description_filled,
expected_output=expected_output_filled,
agent=agent,
tools=tools_list,
async_execution=True,
context=[],
config=task_details.get('config', {}),
output_json=task_details.get('output_json'),
output_pydantic=task_details.get('output_pydantic'),
output_file=task_details.get('output_file', ""),
callback=task_callback_sync,
create_directory=task_details.get('create_directory', False)
)
tasks.append(task)
tasks_dict[task_name] = task
# Set up task contexts
for role, details in config['roles'].items():
for task_name, task_details in details.get('tasks', {}).items():
task = tasks_dict[task_name]
context_tasks = [tasks_dict[ctx] for ctx in task_details.get('context', [])
if ctx in tasks_dict]
task.context = context_tasks
# Send the start message
await cl.Message(
content="Starting PraisonAI agents execution...",
author="System"
).send()
# Create and run the PraisonAI agents
if config.get('process') == 'hierarchical':
crew = AgentTeam(
agents=list(agents.values()),
tasks=tasks,
verbose=True,
process="hierarchical",
manager_llm=config.get('manager_llm', 'gpt-5-nano')
)
else:
crew = AgentTeam(
agents=list(agents.values()),
tasks=tasks,
verbose=2
)
# Store the crew in the user session
cl.user_session.set("crew", crew)
# Run the agents in a separate thread
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, crew.start)
logger.debug(f"[CALLBACK DEBUG] Result: {response}")
# Convert response to string if it's not already
if hasattr(response, 'raw'):
result = response.raw
elif hasattr(response, 'content'):
result = response.content
else:
result = str(response)
# Send the completion message
await cl.Message(
content="PraisonAI agents execution completed.",
author="System"
).send()
# After getting the response, wait a bit for remaining messages
await asyncio.sleep(1) # Give time for final messages to be processed
queue_processor.cancel() # Stop the queue processor
return result
except Exception as e:
error_msg = f"Error in ui_run_praisonai: {str(e)}"
logger.error(error_msg, exc_info=True)
await cl.Message(
content=error_msg,
author="System"
).send()
raise
@@ -1,196 +0,0 @@
"""
Integration tests for external agents UI functionality.
Tests the shared helper and UI integration for external agents (Claude/Gemini/Codex/Cursor).
"""
import pytest
import shutil
from unittest.mock import patch, MagicMock
import sys
import os
# Mark most tests as unit tests to bypass provider gating
# Only TestRealAgenticIntegration is truly integration-level
pytestmark = pytest.mark.unit
# Add src path for imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../"))
from praisonai.ui._external_agents import (
EXTERNAL_AGENTS,
installed_external_agents,
external_agent_tools,
chainlit_switches,
aiui_settings_entries,
)
class TestExternalAgentsHelper:
"""Test the shared external agents helper."""
def test_external_agents_config(self):
"""Test that EXTERNAL_AGENTS is properly configured."""
assert isinstance(EXTERNAL_AGENTS, dict)
assert "claude_enabled" in EXTERNAL_AGENTS
assert "gemini_enabled" in EXTERNAL_AGENTS
assert "codex_enabled" in EXTERNAL_AGENTS
assert "cursor_enabled" in EXTERNAL_AGENTS
# Check structure of each agent config
for toggle_id, config in EXTERNAL_AGENTS.items():
assert "module" in config
assert "cls" in config
assert "label" in config
assert "cli" in config
@patch('shutil.which')
def test_installed_external_agents_caching(self, mock_which):
"""Test that installed_external_agents properly caches results."""
# Clear cache
from praisonai.ui._external_agents import installed_external_agents
installed_external_agents.cache_clear()
# Mock some CLIs as available
def mock_which_side_effect(cli):
return "/usr/bin/claude" if cli == "claude" else None
mock_which.side_effect = mock_which_side_effect
# First call should check all CLIs
result1 = installed_external_agents()
assert result1 == ["claude_enabled"]
assert mock_which.call_count == 4 # Called for each CLI
# Second call should use cache
mock_which.reset_mock()
result2 = installed_external_agents()
assert result2 == ["claude_enabled"]
assert mock_which.call_count == 0 # Not called due to cache
@patch('importlib.import_module')
def test_external_agent_tools(self, mock_import):
"""Test external_agent_tools builds tools correctly."""
# Mock integration classes
mock_integration = MagicMock()
mock_integration.is_available = True
mock_tool_func = MagicMock()
mock_integration.as_tool.return_value = mock_tool_func
mock_module = MagicMock()
mock_module.ClaudeCodeIntegration.return_value = mock_integration
mock_import.return_value = mock_module
settings = {"claude_enabled": True, "gemini_enabled": False}
tools = external_agent_tools(settings)
assert len(tools) == 1
assert tools[0] == mock_tool_func
mock_import.assert_called_once_with("praisonai.integrations.claude_code")
mock_integration.as_tool.assert_called_once()
def test_external_agent_tools_unavailable_integration(self):
"""Test external_agent_tools handles missing integrations gracefully."""
settings = {"nonexistent_enabled": True}
tools = external_agent_tools(settings)
assert tools == []
@patch('chainlit.input_widget.Switch')
@patch('praisonai.ui._external_agents.installed_external_agents')
def test_chainlit_switches(self, mock_installed, mock_switch):
"""Test chainlit_switches generates correct switches."""
mock_installed.return_value = ["claude_enabled", "gemini_enabled"]
mock_switch_instance = MagicMock()
mock_switch.return_value = mock_switch_instance
current_settings = {"claude_enabled": True, "gemini_enabled": False}
switches = chainlit_switches(current_settings)
assert len(switches) == 2
assert all(s == mock_switch_instance for s in switches)
assert mock_switch.call_count == 2
@patch('praisonai.ui._external_agents.installed_external_agents')
def test_aiui_settings_entries(self, mock_installed):
"""Test aiui_settings_entries generates correct settings."""
mock_installed.return_value = ["claude_enabled", "gemini_enabled"]
settings = aiui_settings_entries()
assert isinstance(settings, dict)
assert "claude_enabled" in settings
assert "gemini_enabled" in settings
assert settings["claude_enabled"]["type"] == "checkbox"
assert settings["claude_enabled"]["default"] is False
class TestBackwardCompatibility:
"""Test backward compatibility with legacy settings."""
def test_claude_code_enabled_legacy_support(self):
"""Test that claude_code_enabled is mapped to claude_enabled."""
# This would require mocking chainlit session which is complex
# In practice, this is tested by the load_external_agent_settings_from_chainlit function
# which handles the legacy mapping
pass
class TestAvailabilityGating:
"""Test that unavailable CLIs are properly hidden."""
@patch('shutil.which')
def test_only_available_agents_shown(self, mock_which):
"""Test that only available external agents are shown in UI."""
# Clear cache
from praisonai.ui._external_agents import installed_external_agents
installed_external_agents.cache_clear()
# Mock only Claude as available
mock_which.side_effect = lambda cli: "/usr/bin/claude" if cli == "claude" else None
available = installed_external_agents()
assert available == ["claude_enabled"]
# Verify all CLIs were checked
expected_calls = [cli_config["cli"] for cli_config in EXTERNAL_AGENTS.values()]
actual_calls = [call[0][0] for call in mock_which.call_args_list]
assert set(actual_calls) == set(expected_calls)
@pytest.mark.integration
@pytest.mark.provider_google # Only this class needs provider gating
class TestRealAgenticIntegration:
"""Real agentic tests - requires actual integrations to be available."""
@pytest.mark.skipif(not shutil.which("echo"), reason="echo command not available")
def test_external_agent_tools_real_execution(self):
"""Test that external agent tools can be created and are callable."""
# This is a simplified test using echo command to simulate external CLI
# In a real test environment with Claude Code installed, this would test actual execution
settings = {"claude_enabled": False} # Disable to avoid actual execution
tools = external_agent_tools(settings)
# Should return empty list when all disabled
assert tools == []
# Test with mock available integration would go here if Claude Code was installed
def test_settings_to_tools_mapping_consistency(self):
"""Test that settings keys map consistently to integration modules."""
for toggle_id, config in EXTERNAL_AGENTS.items():
# Verify the toggle_id pattern is consistent
assert toggle_id.endswith("_enabled")
# Verify module name is valid identifier
module_name = config["module"]
assert module_name.replace("_", "").isalnum()
# Verify class name follows PascalCase Integration pattern
class_name = config["cls"]
assert class_name.endswith("Integration")
assert class_name[0].isupper()
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -1,364 +0,0 @@
"""
Integration tests for UI pairing approval.
Tests the end-to-end flow: pending request → banner shown → approve action → PairingStore.is_approved()==True
"""
import pytest
import asyncio
import tempfile
import os
from unittest.mock import Mock, AsyncMock, patch
# Skip if chainlit is not available (optional [ui] extra)
pytest.importorskip("chainlit", reason="chainlit is an optional [ui] extra")
from praisonai.gateway.pairing import PairingStore
from praisonai.ui._pairing import (
get_pending_pairings,
approve_pairing,
refresh_pending_banner,
setup_pairing_banner,
)
@pytest.fixture
def temp_pairing_store():
"""Create a temporary PairingStore for testing."""
with tempfile.TemporaryDirectory() as temp_dir:
store = PairingStore(store_dir=temp_dir)
yield store
@pytest.fixture
def mock_gateway_server(temp_pairing_store):
"""Mock gateway server with actual PairingStore."""
server = Mock()
server.pairing_store = temp_pairing_store
return server
@pytest.fixture
def mock_chainlit_user_admin():
"""Mock Chainlit user session with admin role."""
with patch("chainlit.user_session") as mock_session:
mock_user = Mock()
mock_user.metadata = {"role": "admin"}
mock_session.get.return_value = mock_user
yield mock_session
@pytest.fixture
def mock_chainlit_user_regular():
"""Mock Chainlit user session with regular role."""
with patch("chainlit.user_session") as mock_session:
mock_user = Mock()
mock_user.metadata = {"role": "user"}
mock_session.get.return_value = mock_user
yield mock_session
@pytest.fixture
def mock_chainlit_message():
"""Mock Chainlit Message class."""
with patch("chainlit.Message") as mock_message_class:
mock_message = Mock()
mock_message.send = AsyncMock()
mock_message_class.return_value = mock_message
yield mock_message_class
@pytest.fixture
def mock_chainlit_action():
"""Mock Chainlit Action class."""
with patch("chainlit.Action") as mock_action_class:
yield mock_action_class
class TestGetPendingPairings:
"""Test the get_pending_pairings function."""
@patch("praisonai.ui._pairing.GATEWAY_TOKEN", "test-token")
@patch("aiohttp.ClientSession")
async def test_get_pending_success(self, mock_session):
"""Test successful retrieval of pending pairings."""
# Mock successful HTTP response
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"pending": [
{
"channel": "telegram",
"code": "ABCD1234",
"user_id": "ABCD1234",
"user_name": "User ABCD1234",
"age_seconds": 30
}
]
})
mock_session_instance = AsyncMock()
mock_session_instance.__aenter__ = AsyncMock(return_value=mock_session_instance)
mock_session_instance.__aexit__ = AsyncMock(return_value=None)
mock_session_instance.get = AsyncMock()
mock_session_instance.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session_instance.get.return_value.__aexit__ = AsyncMock(return_value=None)
mock_session.return_value = mock_session_instance
result = await get_pending_pairings()
assert len(result) == 1
assert result[0]["channel"] == "telegram"
assert result[0]["code"] == "ABCD1234"
@patch("praisonai.ui._pairing.GATEWAY_TOKEN", "")
async def test_get_pending_no_token(self, caplog):
"""Test get_pending_pairings with no token."""
result = await get_pending_pairings()
assert result == []
assert "No GATEWAY_AUTH_TOKEN set" in caplog.text
@patch("praisonai.ui._pairing.GATEWAY_TOKEN", "test-token")
@patch("aiohttp.ClientSession")
async def test_get_pending_http_error(self, mock_session):
"""Test get_pending_pairings with HTTP error."""
# Mock HTTP error response
mock_response = AsyncMock()
mock_response.status = 401
mock_session_instance = AsyncMock()
mock_session_instance.__aenter__ = AsyncMock(return_value=mock_session_instance)
mock_session_instance.__aexit__ = AsyncMock(return_value=None)
mock_session_instance.get = AsyncMock()
mock_session_instance.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session_instance.get.return_value.__aexit__ = AsyncMock(return_value=None)
mock_session.return_value = mock_session_instance
result = await get_pending_pairings()
assert result == []
class TestApprovePairing:
"""Test the approve_pairing function."""
@patch("praisonai.ui._pairing.GATEWAY_TOKEN", "test-token")
@patch("aiohttp.ClientSession")
async def test_approve_success(self, mock_session):
"""Test successful pairing approval."""
# Mock successful HTTP response
mock_response = AsyncMock()
mock_response.status = 200
mock_session_instance = AsyncMock()
mock_session_instance.__aenter__ = AsyncMock(return_value=mock_session_instance)
mock_session_instance.__aexit__ = AsyncMock(return_value=None)
mock_session_instance.post = AsyncMock()
mock_session_instance.post.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session_instance.post.return_value.__aexit__ = AsyncMock(return_value=None)
mock_session.return_value = mock_session_instance
result = await approve_pairing("telegram", "ABCD1234")
assert result is True
@patch("praisonai.ui._pairing.GATEWAY_TOKEN", "")
async def test_approve_no_token(self):
"""Test approve_pairing with no token."""
result = await approve_pairing("telegram", "ABCD1234")
assert result is False
@patch("praisonai.ui._pairing.GATEWAY_TOKEN", "test-token")
@patch("aiohttp.ClientSession")
async def test_approve_http_error(self, mock_session):
"""Test approve_pairing with HTTP error."""
# Mock HTTP error response
mock_response = AsyncMock()
mock_response.status = 404
mock_session_instance = AsyncMock()
mock_session_instance.__aenter__ = AsyncMock(return_value=mock_session_instance)
mock_session_instance.__aexit__ = AsyncMock(return_value=None)
mock_session_instance.post = AsyncMock()
mock_session_instance.post.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session_instance.post.return_value.__aexit__ = AsyncMock(return_value=None)
mock_session.return_value = mock_session_instance
result = await approve_pairing("telegram", "ABCD1234")
assert result is False
class TestRefreshPendingBanner:
"""Test the refresh_pending_banner function."""
async def test_banner_not_shown_for_non_admin(
self,
mock_chainlit_user_regular,
mock_chainlit_message
):
"""Test that banner is not shown for non-admin users."""
await refresh_pending_banner()
# Message should not be sent for non-admin
mock_chainlit_message.assert_not_called()
async def test_banner_not_shown_with_no_pending(
self,
mock_chainlit_user_admin,
mock_chainlit_message
):
"""Test that banner is not shown when no pending requests."""
with patch("praisonai.ui._pairing.get_pending_pairings", return_value=[]):
await refresh_pending_banner()
# Message should not be sent when no pending
mock_chainlit_message.assert_not_called()
async def test_banner_shown_for_admin_with_pending(
self,
mock_chainlit_user_admin,
mock_chainlit_message,
mock_chainlit_action
):
"""Test that banner is shown for admin with pending requests."""
mock_pending = [
{
"channel": "telegram",
"code": "ABCD1234",
"user_name": "User ABCD1234",
"age_seconds": 30
}
]
with patch("praisonai.ui._pairing.get_pending_pairings", return_value=mock_pending):
await refresh_pending_banner()
# Message should be created and sent
mock_chainlit_message.assert_called_once()
mock_message_call = mock_chainlit_message.call_args
# Check message content
assert "🔔" in mock_message_call[1]["content"]
assert "1 pending pairing request(s)" in mock_message_call[1]["content"]
# Verify action contract
actions = mock_message_call[1]["actions"]
assert len(actions) == 2 # Should have both approve and deny actions
# Check approve action
approve_action = actions[0]
assert approve_action.name == "approve_pairing"
assert approve_action.value == "telegram:ABCD1234"
assert "✅ Approve" in approve_action.label
# Check deny action
deny_action = actions[1]
assert deny_action.name == "deny_pairing"
assert deny_action.value == "telegram:ABCD1234"
assert "❌ Deny" in deny_action.label
async def test_banner_multiple_pending(
self,
mock_chainlit_user_admin,
mock_chainlit_message,
mock_chainlit_action
):
"""Test banner with multiple pending requests."""
mock_pending = [
{
"channel": "telegram",
"code": "ABCD1234",
"user_name": "User ABCD1234",
"age_seconds": 30
},
{
"channel": "slack",
"code": "EFGH5678",
"user_name": "User EFGH5678",
"age_seconds": 120
}
]
with patch("praisonai.ui._pairing.get_pending_pairings", return_value=mock_pending):
await refresh_pending_banner()
# Message should be created with 2 actions
mock_chainlit_message.assert_called_once()
mock_message_call = mock_chainlit_message.call_args
assert "2 pending pairing request(s)" in mock_message_call[1]["content"]
# Should have 4 actions total (2 pending × 2 actions each)
actions = mock_message_call[1]["actions"]
assert len(actions) == 4
# Verify first pairing actions
assert actions[0].name == "approve_pairing"
assert actions[0].value == "telegram:ABCD1234"
assert actions[1].name == "deny_pairing"
assert actions[1].value == "telegram:ABCD1234"
# Verify second pairing actions
assert actions[2].name == "approve_pairing"
assert actions[2].value == "slack:EFGH5678"
assert actions[3].name == "deny_pairing"
assert actions[3].value == "slack:EFGH5678"
class TestIntegrationFlow:
"""Test the complete integration flow."""
async def test_end_to_end_pairing_flow(self, temp_pairing_store):
"""Test complete flow: generate code → pending request → approve → is_paired."""
# 1. Generate a pairing code
code = temp_pairing_store.generate_code(channel_type="telegram")
assert len(code) == 8
# 2. Verify it shows up in pending list
pending = temp_pairing_store.list_pending()
assert len(pending) == 1
assert pending[0]["code"] == code
assert pending[0]["channel"] == "telegram"
# 3. Approve the pairing
success = temp_pairing_store.approve("telegram", code, user_id="12345")
assert success is True
# 4. Verify it's now paired
assert temp_pairing_store.is_paired("12345", "telegram") is True
# 5. Verify no longer in pending
pending_after = temp_pairing_store.list_pending()
assert len(pending_after) == 0
async def test_invalid_code_approval_flow(self, temp_pairing_store):
"""Test approval flow with invalid code."""
# Try to approve non-existent code
success = temp_pairing_store.approve("telegram", "INVALID", user_id="12345")
assert success is False
# Verify not paired
assert temp_pairing_store.is_paired("12345", "telegram") is False
async def test_expired_code_flow(self, temp_pairing_store):
"""Test that expired codes are cleaned up."""
# Generate a code with very short TTL
store_short_ttl = PairingStore(
store_dir=temp_pairing_store._dir,
code_ttl=0.1 # 100ms TTL
)
code = store_short_ttl.generate_code(channel_type="telegram")
# Wait for expiration
await asyncio.sleep(0.2)
# Try to approve expired code
success = store_short_ttl.approve("telegram", code, user_id="12345")
assert success is False
# Verify not paired
assert store_short_ttl.is_paired("12345", "telegram") is False
@@ -193,21 +193,6 @@ class TestUIIntegration:
assert streamlit_config["page_icon"] == "🤖"
assert streamlit_config["layout"] == "wide"
def test_chainlit_app_config(self):
"""Test Chainlit app configuration."""
chainlit_config = {
"name": "PraisonAI Agent",
"description": "Interact with PraisonAI agents",
"author": "PraisonAI Team",
"tags": ["ai", "agents", "chat"],
"public": False,
"authentication": True
}
assert chainlit_config["name"] == "PraisonAI Agent"
assert chainlit_config["authentication"] is True
assert "ai" in chainlit_config["tags"]
def test_ui_agent_wrapper(self, sample_agent_config):
"""Test UI agent wrapper functionality."""
class UIAgentWrapper:
@@ -1,42 +0,0 @@
import sys
import types
from praisonai.ui import _external_agents as ext
class _FakeUserSession:
def __init__(self, values):
self._values = values
def get(self, key, default=None):
return self._values.get(key, default)
def test_load_external_agent_settings_uses_explicit_loader(monkeypatch):
fake_chainlit = types.SimpleNamespace(user_session=_FakeUserSession({}))
monkeypatch.setitem(sys.modules, "chainlit", fake_chainlit)
values = {
"claude_code_enabled": "true",
"claude_enabled": "false",
"gemini_enabled": "true",
"codex_enabled": "1",
}
settings = ext.load_external_agent_settings_from_chainlit(lambda key: values.get(key))
assert set(settings.keys()) == set(ext.EXTERNAL_AGENTS.keys())
assert settings["claude_enabled"] is False
assert settings["gemini_enabled"] is True
assert settings["codex_enabled"] is True
def test_load_external_agent_settings_session_overrides_persistent(monkeypatch):
fake_chainlit = types.SimpleNamespace(
user_session=_FakeUserSession({"gemini_enabled": True, "claude_code_enabled": False})
)
monkeypatch.setitem(sys.modules, "chainlit", fake_chainlit)
values = {"gemini_enabled": "false"}
settings = ext.load_external_agent_settings_from_chainlit(lambda key: values.get(key))
assert settings["gemini_enabled"] is True
@@ -1,168 +0,0 @@
"""
Unit tests for UI bind-aware credential enforcement.
Tests that UI refuses default admin/admin credentials on external interfaces.
"""
import os
import pytest
from unittest.mock import patch, MagicMock
pytest.importorskip("chainlit", reason="chainlit is an optional [ui] extra")
from praisonai.ui._auth import register_password_auth, UIStartupError
class TestUIAuthValidation:
"""Test UI authentication validation logic."""
@patch.dict(os.environ, {
"CHAINLIT_USERNAME": "admin",
"CHAINLIT_PASSWORD": "admin",
"PRAISONAI_ALLOW_DEFAULT_CREDS": ""
})
def test_default_creds_on_loopback_warns_only(self):
"""Test default credentials on loopback show warning but allow start."""
mock_app = MagicMock()
# Should not raise exception on loopback
register_password_auth(mock_app, bind_host="127.0.0.1")
register_password_auth(mock_app, bind_host="localhost")
register_password_auth(mock_app, bind_host="::1")
@patch.dict(os.environ, {
"CHAINLIT_USERNAME": "admin",
"CHAINLIT_PASSWORD": "admin",
"PRAISONAI_ALLOW_DEFAULT_CREDS": ""
})
def test_default_creds_on_external_blocks_start(self):
"""Test default credentials on external interface block start."""
mock_app = MagicMock()
with pytest.raises(UIStartupError) as exc_info:
register_password_auth(mock_app, bind_host="0.0.0.0")
assert "Cannot bind to 0.0.0.0 with default admin/admin credentials" in str(exc_info.value)
assert "CHAINLIT_USERNAME" in str(exc_info.value)
assert "CHAINLIT_PASSWORD" in str(exc_info.value)
# Test other external interfaces
external_hosts = ["192.168.1.1", "10.0.0.1", "8.8.8.8"]
for host in external_hosts:
with pytest.raises(UIStartupError):
register_password_auth(mock_app, bind_host=host)
@patch.dict(os.environ, {
"CHAINLIT_USERNAME": "admin",
"CHAINLIT_PASSWORD": "admin",
"PRAISONAI_ALLOW_DEFAULT_CREDS": "1"
})
def test_escape_hatch_allows_default_creds(self):
"""Test escape hatch allows default credentials on external interface."""
mock_app = MagicMock()
# Should not raise with escape hatch
register_password_auth(mock_app, bind_host="0.0.0.0")
register_password_auth(mock_app, bind_host="192.168.1.1")
@patch.dict(os.environ, {
"CHAINLIT_USERNAME": "myuser",
"CHAINLIT_PASSWORD": "mypass"
})
def test_custom_creds_always_allowed(self):
"""Test custom credentials are always allowed."""
mock_app = MagicMock()
# Should not raise on any interface with custom creds
register_password_auth(mock_app, bind_host="127.0.0.1")
register_password_auth(mock_app, bind_host="0.0.0.0")
register_password_auth(mock_app, bind_host="192.168.1.1")
@patch.dict(os.environ, {
"CHAINLIT_USERNAME": "admin",
"CHAINLIT_PASSWORD": "different"
})
def test_non_default_admin_allowed(self):
"""Test non-default admin credentials are allowed."""
mock_app = MagicMock()
# Should not raise - only username is default
register_password_auth(mock_app, bind_host="0.0.0.0")
@patch.dict(os.environ, {
"CHAINLIT_USERNAME": "different",
"CHAINLIT_PASSWORD": "admin"
})
def test_non_default_password_allowed(self):
"""Test non-default password with admin username is allowed."""
mock_app = MagicMock()
# Should not raise - only password is default
register_password_auth(mock_app, bind_host="0.0.0.0")
class TestChainlitAuthCallback:
"""Test the actual Chainlit auth callback behavior."""
def test_auth_callback_registration(self):
"""Test that auth callback gets registered with Chainlit."""
mock_app = MagicMock()
with patch('chainlit.password_auth_callback') as mock_decorator:
mock_decorator.return_value = lambda f: f # Mock decorator
register_password_auth(mock_app, bind_host="127.0.0.1")
# Verify decorator was called
mock_decorator.assert_called_once()
class TestUISecurityScenarios:
"""Test real-world UI security scenarios."""
@patch.dict(os.environ, {
"CHAINLIT_USERNAME": "admin",
"CHAINLIT_PASSWORD": "admin"
})
def test_local_development_scenario(self):
"""Test local development scenario (default creds, loopback)."""
mock_app = MagicMock()
# Should warn but allow (typical development scenario)
register_password_auth(mock_app, bind_host="127.0.0.1")
@patch.dict(os.environ, {
"CHAINLIT_USERNAME": "admin",
"CHAINLIT_PASSWORD": "admin",
"PRAISONAI_ALLOW_DEFAULT_CREDS": ""
})
def test_accidental_production_deploy_blocked(self):
"""Test accidental production deploy with default creds is blocked."""
mock_app = MagicMock()
# Should block (prevents shipping with admin/admin to LAN/internet)
with pytest.raises(UIStartupError):
register_password_auth(mock_app, bind_host="0.0.0.0")
@patch.dict(os.environ, {
"CHAINLIT_USERNAME": "prod-user",
"CHAINLIT_PASSWORD": "secure-pass-123"
})
def test_secure_production_deploy_allowed(self):
"""Test secure production deploy with custom creds is allowed."""
mock_app = MagicMock()
# Should allow (secure production deployment)
register_password_auth(mock_app, bind_host="0.0.0.0")
@patch.dict(os.environ, {
"CHAINLIT_USERNAME": "admin",
"CHAINLIT_PASSWORD": "admin",
"PRAISONAI_ALLOW_DEFAULT_CREDS": "1"
})
def test_lab_demo_escape_hatch(self):
"""Test lab/demo escape hatch scenario."""
mock_app = MagicMock()
# Should allow with warning (demo/lab scenario)
register_password_auth(mock_app, bind_host="0.0.0.0")