From 59329fab693cb4eb4c27a712a3ce5b625cd2893d Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Wed, 12 Aug 2026 21:16:15 +0800 Subject: [PATCH 01/16] feat: isolate sandbox session outputs and enforce tenant writes --- backend/app/api/activity.py | 7 +- backend/app/api/admin.py | 16 +- backend/app/api/advanced.py | 11 +- backend/app/api/agent_credentials.py | 9 +- backend/app/api/agentbay_control.py | 18 +- backend/app/api/agents.py | 31 +- backend/app/api/atlassian.py | 9 +- backend/app/api/chat_sessions.py | 16 +- backend/app/api/dingtalk.py | 9 +- backend/app/api/directory.py | 19 +- backend/app/api/discord_bot.py | 11 +- backend/app/api/enterprise.py | 77 ++- backend/app/api/feishu.py | 11 +- backend/app/api/files.py | 29 +- backend/app/api/focus.py | 7 +- backend/app/api/gateway.py | 11 +- backend/app/api/google_workspace.py | 5 +- backend/app/api/groups.py | 64 +- backend/app/api/messages.py | 5 +- backend/app/api/notification.py | 12 +- backend/app/api/onboarding.py | 9 +- backend/app/api/organization.py | 5 +- backend/app/api/pages.py | 5 +- backend/app/api/relationships.py | 19 +- backend/app/api/schedules.py | 13 +- backend/app/api/slack.py | 11 +- backend/app/api/sso.py | 9 +- backend/app/api/tasks.py | 13 +- backend/app/api/teams.py | 11 +- backend/app/api/tenants.py | 27 +- backend/app/api/tools.py | 37 +- backend/app/api/users.py | 7 +- backend/app/api/wechat.py | 11 +- backend/app/api/wecom.py | 17 +- backend/app/api/whatsapp.py | 13 +- backend/app/dao/agent_access_dao.py | 12 +- backend/app/dao/base.py | 24 + backend/app/dao/chat_message_dao.py | 29 +- backend/app/dao/chat_session_dao.py | 18 + backend/app/dao/user_dao.py | 6 +- .../services/agent_runtime/a2a_completion.py | 13 +- .../app/services/agent_runtime/a2a_runtime.py | 13 +- .../app/services/agent_runtime/chat_intake.py | 3 +- .../app/services/agent_runtime/delivery.py | 3 +- .../agent_runtime/trigger_completion.py | 7 +- backend/app/services/agent_tools.py | 320 ++++++++- backend/app/services/autonomy_service.py | 10 +- .../app/services/builtin_tool_definitions.py | 12 + backend/app/services/sandbox/config.py | 6 +- .../app/services/sandbox/execution_lease.py | 110 +++ .../sandbox/local/subprocess_backend.py | 523 ++++++++++++-- .../app/services/sandbox/workspace_policy.py | 65 ++ backend/app/services/workspace_locking.py | 21 +- .../backfill_chat_message_tenant_id.py | 100 +++ .../test_agent_runtime_a2a_completion.py | 1 + .../test_agent_runtime_trigger_completion.py | 1 + .../test_agent_tools_storage_workspace.py | 28 + .../tests/test_api_database_dependencies.py | 17 + backend/tests/test_base_dao.py | 37 +- backend/tests/test_llm_model_tenant_scope.py | 21 +- .../tests/test_sandbox_execution_policy.py | 159 +++++ .../tests/test_sandbox_subprocess_backend.py | 260 ++++++- .../design.md | 638 ++++++++++++++++++ .../spec.md | 311 +++++++++ 64 files changed, 2920 insertions(+), 432 deletions(-) create mode 100644 backend/app/services/sandbox/execution_lease.py create mode 100644 backend/app/services/sandbox/workspace_policy.py create mode 100644 backend/scripts/backfill_chat_message_tenant_id.py create mode 100644 backend/tests/test_api_database_dependencies.py create mode 100644 backend/tests/test_sandbox_execution_policy.py create mode 100644 docs/features/v1.12.0/001-session-isolated-sandbox-output/design.md create mode 100644 docs/features/v1.12.0/001-session-isolated-sandbox-output/spec.md diff --git a/backend/app/api/activity.py b/backend/app/api/activity.py index 354e29d58..53286364a 100644 --- a/backend/app/api/activity.py +++ b/backend/app/api/activity.py @@ -1,4 +1,3 @@ -from typing import Any """Activity log API — view agent work history.""" import uuid @@ -19,7 +18,7 @@ async def get_agent_activity( agent_id: uuid.UUID, limit: int = Query(50, le=200), current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get recent activity logs for an agent.""" await check_agent_access(db, current_user, agent_id) @@ -45,7 +44,7 @@ async def get_agent_activity( async def list_conversations( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List all conversation partners for this agent (web users + other agents).""" await check_agent_access(db, current_user, agent_id) @@ -59,7 +58,7 @@ async def get_conversation_messages( conv_id: str, limit: int = Query(100, le=500), current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get messages for a specific conversation.""" await check_agent_access(db, current_user, agent_id) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 691deb2a3..51ac40ba0 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -70,7 +70,7 @@ class PlatformSettingsUpdate(BaseModel): @router.get("/companies", response_model=list[CompanyStats]) async def list_companies( current_user: User = Depends(require_role("platform_admin")), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List all companies with stats.""" tenants = await query_dao.execute(db, select(Tenant).order_by(Tenant.created_at.desc())) @@ -143,7 +143,7 @@ async def list_companies( async def create_company( data: CompanyCreateRequest, current_user: User = Depends(require_role("platform_admin")), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Create a new company and generate an admin invitation code (max_uses=1).""" import re @@ -184,7 +184,7 @@ async def create_company( async def toggle_company( company_id: uuid.UUID, current_user: User = Depends(require_role("platform_admin")), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Enable or disable a company.""" result = await query_dao.execute(db, select(Tenant).where(Tenant.id == company_id)) @@ -221,7 +221,7 @@ async def get_platform_timeseries( start_date: datetime, end_date: datetime, current_user: User = Depends(require_role("platform_admin")), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get daily platform metrics within a date range. @@ -386,7 +386,7 @@ async def get_platform_timeseries( @router.get("/metrics/leaderboards") async def get_platform_leaderboards( current_user: User = Depends(require_role("platform_admin")), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get Top 20 token consuming companies and agents.""" # Top 20 Companies by total tokens @@ -438,7 +438,7 @@ async def get_platform_leaderboards( @router.get("/metrics/enhanced") async def get_enhanced_metrics( current_user: User = Depends(require_role("platform_admin")), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Enhanced platform metrics: retention, avg tokens/session, channel distribution, tool categories, and churn warnings. @@ -589,7 +589,7 @@ async def get_enhanced_metrics( @router.get("/platform-settings", response_model=PlatformSettingsOut) async def get_platform_settings( current_user: User = Depends(require_role("platform_admin")), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get platform-level settings.""" settings: dict[str, bool] = {} @@ -610,7 +610,7 @@ async def get_platform_settings( async def update_platform_settings( data: PlatformSettingsUpdate, current_user: User = Depends(require_role("platform_admin")), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Update platform-level settings.""" updates = data.model_dump(exclude_unset=True) diff --git a/backend/app/api/advanced.py b/backend/app/api/advanced.py index 3cdc29801..295e39bb5 100644 --- a/backend/app/api/advanced.py +++ b/backend/app/api/advanced.py @@ -1,4 +1,3 @@ -from typing import Any """Agent collaboration and template market API routes.""" import uuid @@ -37,7 +36,7 @@ class InterAgentMessage(BaseModel): async def list_collaborators( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List agents that can collaborate with this agent.""" await check_agent_access(db, current_user, agent_id) @@ -49,7 +48,7 @@ async def delegate_task( agent_id: uuid.UUID, data: DelegateRequest, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Delegate a task from one agent to another.""" await check_agent_access(db, current_user, agent_id) @@ -67,7 +66,7 @@ async def send_inter_agent_message( agent_id: uuid.UUID, data: InterAgentMessage, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Send a message between agents.""" await check_agent_access(db, current_user, agent_id) @@ -164,7 +163,7 @@ async def handover_agent( agent_id: uuid.UUID, data: HandoverRequest, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Transfer ownership of a digital employee to another user.""" from app.models.audit import AuditLog @@ -206,7 +205,7 @@ async def handover_agent( async def get_agent_metrics( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get observability metrics for an agent.""" agent, _access = await check_agent_access(db, current_user, agent_id) diff --git a/backend/app/api/agent_credentials.py b/backend/app/api/agent_credentials.py index 706e1b05d..2c11d27ea 100644 --- a/backend/app/api/agent_credentials.py +++ b/backend/app/api/agent_credentials.py @@ -1,4 +1,3 @@ -from typing import Any """Agent Credentials CRUD API routes. Provides endpoints for managing encrypted session cookies @@ -53,7 +52,7 @@ def _to_response(cred: AgentCredential) -> dict: async def list_credentials( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List all credentials for an agent (sensitive data excluded).""" # Verify the user has manage-level access to this agent @@ -73,7 +72,7 @@ async def create_credential( agent_id: uuid.UUID, data: AgentCredentialCreate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Create a new credential for an agent. @@ -122,7 +121,7 @@ async def update_credential( credential_id: uuid.UUID, data: AgentCredentialUpdate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Update an existing credential. @@ -178,7 +177,7 @@ async def delete_credential( agent_id: uuid.UUID, credential_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Delete a credential.""" _agent, access_level = await check_agent_access(db, current_user, agent_id) diff --git a/backend/app/api/agentbay_control.py b/backend/app/api/agentbay_control.py index 413860b1c..5a7daa0f2 100644 --- a/backend/app/api/agentbay_control.py +++ b/backend/app/api/agentbay_control.py @@ -14,7 +14,7 @@ import time import uuid from datetime import datetime, timezone -from typing import Any, Optional +from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel @@ -626,7 +626,7 @@ async def control_current_url( agent_id: uuid.UUID, data: CurrentUrlRequest, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get the current page URL from the active browser session via CDP. @@ -676,7 +676,7 @@ async def control_click( agent_id: uuid.UUID, data: ClickRequest, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Forward a mouse click to the AgentBay session. @@ -709,7 +709,7 @@ async def control_type( agent_id: uuid.UUID, data: TypeRequest, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Forward text input to the AgentBay session.""" _agent, _access = await check_agent_access(db, current_user, agent_id) @@ -736,7 +736,7 @@ async def control_press_keys( agent_id: uuid.UUID, data: PressKeysRequest, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Forward keyboard key presses to the AgentBay session.""" _agent, _access = await check_agent_access(db, current_user, agent_id) @@ -763,7 +763,7 @@ async def control_drag( agent_id: uuid.UUID, data: DragRequest, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Simulate a human-like mouse drag in the AgentBay session. @@ -799,7 +799,7 @@ async def control_screenshot( agent_id: uuid.UUID, data: ScreenshotRequest, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get an immediate screenshot from the AgentBay session. @@ -846,7 +846,7 @@ async def control_lock( agent_id: uuid.UUID, data: LockRequest, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Enter Take Control mode — locks the session against automatic tool execution. @@ -888,7 +888,7 @@ async def control_unlock( agent_id: uuid.UUID, data: UnlockRequest, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Exit Take Control mode — unlock session and optionally export cookies. diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 8a8cc1cf8..e7da1a80d 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -1,4 +1,3 @@ -from typing import Any """Agent (Digital Employee) API routes.""" import hashlib @@ -137,7 +136,7 @@ def _serialize_agent_out(agent: Agent, unread_count: int = 0) -> AgentOut: @router.get("/templates") async def list_templates( current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List all available agent templates.""" from app.models.agent import AgentTemplate @@ -196,7 +195,7 @@ async def _agents_to_out( @router.get("/", response_model=list[AgentOut]) async def list_agents( current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List all agents the current user has access to.""" stmt = build_visible_agents_query( @@ -391,7 +390,7 @@ async def create_agent( data: AgentCreate, background_tasks: BackgroundTasks, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Create a new digital employee (any authenticated user).""" # Check agent creation quota @@ -574,7 +573,7 @@ async def create_agent( async def get_agent( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get agent details.""" agent, access_level = await check_agent_access(db, current_user, agent_id) @@ -608,7 +607,7 @@ async def get_agent( async def get_agent_permissions( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get agent permission scope.""" agent, access_level = await check_agent_access(db, current_user, agent_id) @@ -706,7 +705,7 @@ async def update_agent_permissions( agent_id: uuid.UUID, data: dict, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Update agent permission scope (owner or platform_admin only).""" agent, access_level = await check_agent_access(db, current_user, agent_id) @@ -804,7 +803,7 @@ async def get_agent_permission_candidates( agent_id: uuid.UUID, search: str | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Return org members that can be granted custom access. @@ -885,7 +884,7 @@ async def update_agent( agent_id: uuid.UUID, data: AgentUpdate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Update agent settings (creator or admin).""" agent, _access = await check_agent_access(db, current_user, agent_id) @@ -1001,7 +1000,7 @@ async def update_agent( async def delete_agent( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Logically delete an Agent while retaining its history and Workspace.""" agent, _access = await check_agent_access( @@ -1092,7 +1091,7 @@ async def delete_agent( async def start_agent( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Start an agent's container.""" agent, access_level = await check_agent_access(db, current_user, agent_id) @@ -1110,7 +1109,7 @@ async def start_agent( async def stop_agent( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Stop an agent's container.""" agent, access_level = await check_agent_access(db, current_user, agent_id) @@ -1132,7 +1131,7 @@ async def list_agent_approvals( agent_id: uuid.UUID, status_filter: str | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List approval requests for a specific agent. Only creator or admin can view.""" agent, _access = await check_agent_access(db, current_user, agent_id) @@ -1171,7 +1170,7 @@ async def resolve_agent_approval( approval_id: uuid.UUID, data: dict, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Approve or reject a pending approval for a specific agent.""" agent, _access = await check_agent_access(db, current_user, agent_id) @@ -1199,7 +1198,7 @@ async def resolve_agent_approval( async def generate_or_reset_api_key( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Generate or regenerate API key for an OpenClaw agent.""" agent, _access = await check_agent_access(db, current_user, agent_id) @@ -1219,7 +1218,7 @@ async def generate_or_reset_api_key( async def list_gateway_messages( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List recent gateway messages for an OpenClaw agent.""" agent, _access = await check_agent_access(db, current_user, agent_id) diff --git a/backend/app/api/atlassian.py b/backend/app/api/atlassian.py index fe0517b02..dc0bef29a 100644 --- a/backend/app/api/atlassian.py +++ b/backend/app/api/atlassian.py @@ -1,4 +1,3 @@ -from typing import Any """Atlassian Rovo MCP Channel API routes. Provides per-agent Atlassian integration configuration. @@ -32,7 +31,7 @@ async def configure_atlassian_channel( agent_id: uuid.UUID, data: dict, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Configure Atlassian Rovo MCP for an agent. @@ -91,7 +90,7 @@ async def configure_atlassian_channel( async def get_atlassian_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): await check_agent_access(db, current_user, agent_id) result = await query_dao.execute(db, @@ -110,7 +109,7 @@ async def get_atlassian_channel( async def delete_atlassian_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): agent, _ = await check_agent_access(db, current_user, agent_id) if not is_agent_creator(current_user, agent): @@ -132,7 +131,7 @@ async def delete_atlassian_channel( async def test_atlassian_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Test connectivity to Atlassian Rovo MCP and list available tools.""" await check_agent_access(db, current_user, agent_id) diff --git a/backend/app/api/chat_sessions.py b/backend/app/api/chat_sessions.py index 52694a900..5f83a741d 100644 --- a/backend/app/api/chat_sessions.py +++ b/backend/app/api/chat_sessions.py @@ -6,7 +6,7 @@ import re import uuid from datetime import UTC, datetime -from typing import Any, Annotated, Literal +from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, ConfigDict, Field @@ -219,7 +219,7 @@ async def list_sessions( agent_id: uuid.UUID, scope: Annotated[str, Query(description="'mine' or 'all'")] = "mine", current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List active sessions on the legacy Agent session surface.""" agent, tenant_id = await _check_direct_agent_access(db, current_user, agent_id) @@ -361,7 +361,7 @@ async def create_session( agent_id: uuid.UUID, body: CreateSessionIn = CreateSessionIn(), current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Create a direct session for the active current-tenant User.""" _, tenant_id = await _check_direct_agent_access(db, current_user, agent_id) @@ -403,7 +403,7 @@ async def get_session_runtime_state( agent_id: uuid.UUID, session_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ) -> SessionRuntimeStateOut: """Return the one exact Direct Chat lane holder, if one exists.""" _agent, tenant_id = await _check_direct_agent_access( @@ -567,7 +567,7 @@ async def reconcile_direct_tool_execution( execution_id: uuid.UUID, body: ReconcileToolExecutionIn, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ) -> ReconcileToolExecutionOut: """Settle a Direct Chat unknown receipt before the user resumes its Run.""" agent, tenant_id = await _check_direct_agent_access(db, current_user, agent_id) @@ -670,7 +670,7 @@ async def rename_session( session_id: uuid.UUID, body: PatchSessionIn, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Rename one active direct session.""" agent, tenant_id = await _check_direct_agent_access(db, current_user, agent_id) @@ -696,7 +696,7 @@ async def delete_session( agent_id: uuid.UUID, session_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Soft-delete a direct session and cancel only its foreground collaboration.""" agent, tenant_id = await _check_direct_agent_access(db, current_user, agent_id) @@ -796,7 +796,7 @@ async def get_session_messages( Query(description="Cursor '|' for the first excluded position"), ] = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Return associated session messages by authoritative `(created_at, id)` position.""" agent, tenant_id = await _check_direct_agent_access(db, current_user, agent_id) diff --git a/backend/app/api/dingtalk.py b/backend/app/api/dingtalk.py index 75449df56..5160c6e33 100644 --- a/backend/app/api/dingtalk.py +++ b/backend/app/api/dingtalk.py @@ -1,4 +1,3 @@ -from typing import Any """DingTalk Channel API routes. Provides Config CRUD and message handling for DingTalk bots using Stream mode. @@ -32,7 +31,7 @@ async def configure_dingtalk_channel( agent_id: uuid.UUID, data: dict, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Configure DingTalk bot for an agent. Fields: app_key, app_secret, agent_id (optional).""" agent, _ = await check_agent_access(db, current_user, agent_id) @@ -100,7 +99,7 @@ async def configure_dingtalk_channel( async def get_dingtalk_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): await check_agent_access(db, current_user, agent_id) result = await db.execute( @@ -119,7 +118,7 @@ async def get_dingtalk_channel( async def delete_dingtalk_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): agent, _ = await check_agent_access(db, current_user, agent_id) if not is_agent_creator(current_user, agent): @@ -272,7 +271,7 @@ async def process_dingtalk_message( async def dingtalk_callback( authCode: str, # DingTalk uses authCode parameter state: str = None, - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Callback for DingTalk OAuth2 login.""" from app.models.identity import SSOScanSession diff --git a/backend/app/api/directory.py b/backend/app/api/directory.py index 7aff3b5e6..3ee062a44 100644 --- a/backend/app/api/directory.py +++ b/backend/app/api/directory.py @@ -1,4 +1,3 @@ -from typing import Any """Read-only agent directory API.""" import uuid @@ -56,7 +55,7 @@ async def get_agent_directory( limit: int = 50, offset: int = 0, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Return the people and agents the source agent can currently contact.""" await check_agent_access(db, current_user, agent_id) @@ -79,7 +78,7 @@ async def get_agent_directory( async def get_custom_directory_humans( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Return explicitly authorized human members in a custom Directory.""" agent = await _require_custom_directory_manager(db, current_user, agent_id) @@ -127,7 +126,7 @@ async def get_custom_directory_human_candidates( limit: int = 50, offset: int = 0, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Return paginated human candidates that can be added to a custom Directory.""" _validate_pagination(limit, offset) @@ -184,7 +183,7 @@ async def add_custom_directory_human( agent_id: uuid.UUID, payload: CustomHumanDirectoryIn, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Add a human platform user to a custom Directory with use access.""" agent = await _require_custom_directory_manager(db, current_user, agent_id) @@ -216,7 +215,7 @@ async def remove_custom_directory_human( agent_id: uuid.UUID, user_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Remove a use-level human from a custom Directory.""" await _require_custom_directory_manager(db, current_user, agent_id) @@ -243,7 +242,7 @@ async def remove_custom_directory_human( async def get_custom_directory_agents( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Return explicitly linked digital employees in a custom Directory.""" await _require_custom_directory_manager(db, current_user, agent_id) @@ -275,7 +274,7 @@ async def get_custom_directory_agent_candidates( limit: int = 50, offset: int = 0, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Return paginated digital employee candidates for a custom Directory.""" _validate_pagination(limit, offset) @@ -321,7 +320,7 @@ async def add_custom_directory_agent( agent_id: uuid.UUID, payload: CustomAgentDirectoryIn, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Add a digital employee to a custom Directory.""" agent = await _require_custom_directory_manager(db, current_user, agent_id) @@ -361,7 +360,7 @@ async def remove_custom_directory_agent( agent_id: uuid.UUID, target_agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Remove a digital employee from a custom Directory.""" await _require_custom_directory_manager(db, current_user, agent_id) diff --git a/backend/app/api/discord_bot.py b/backend/app/api/discord_bot.py index 4c37bd05e..ad00c5490 100644 --- a/backend/app/api/discord_bot.py +++ b/backend/app/api/discord_bot.py @@ -1,4 +1,3 @@ -from typing import Any """Discord Bot Channel API routes (slash command interactions).""" import uuid @@ -28,7 +27,7 @@ async def configure_discord_channel( agent_id: uuid.UUID, data: dict, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Configure Discord bot for an agent. @@ -98,7 +97,7 @@ async def configure_discord_channel( async def get_discord_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): await check_agent_access(db, current_user, agent_id) result = await db.execute( @@ -114,7 +113,7 @@ async def get_discord_channel( @router.get("/agents/{agent_id}/discord-channel/webhook-url") -async def get_discord_webhook_url(agent_id: uuid.UUID, request: Request, db: Any = None): +async def get_discord_webhook_url(agent_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)): from app.services.platform_service import platform_service public_base = await platform_service.get_public_base_url(db, request) return {"webhook_url": f"{public_base}/api/channel/discord/{agent_id}/webhook"} @@ -124,7 +123,7 @@ async def get_discord_webhook_url(agent_id: uuid.UUID, request: Request, db: Any async def delete_discord_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): agent, _ = await check_agent_access(db, current_user, agent_id) if not is_agent_creator(current_user, agent): @@ -199,7 +198,7 @@ def _verify_discord_signature(public_key: str, body: bytes, headers: dict) -> bo async def discord_interaction_webhook( agent_id: uuid.UUID, request: Request, - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Handle Discord Interaction webhooks (PING + slash commands).""" body_bytes = await request.body() diff --git a/backend/app/api/enterprise.py b/backend/app/api/enterprise.py index a4783c10a..6ff3407e0 100644 --- a/backend/app/api/enterprise.py +++ b/backend/app/api/enterprise.py @@ -1,4 +1,3 @@ -from typing import Any """Enterprise management API routes: LLM pool, enterprise info, approvals, audit logs.""" import uuid @@ -115,7 +114,7 @@ class CheckEmailRequest(BaseModel): @router.post("/check-email-exists") async def check_email_exists( data: CheckEmailRequest, - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Public endpoint — check if an email address is already registered on this platform. @@ -388,7 +387,7 @@ async def test_llm_model( async def list_llm_models( tenant_id: str | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List LLM models scoped to the selected tenant.""" tid = _llm_management_tenant_id(current_user, tenant_id) @@ -398,7 +397,7 @@ async def list_llm_models( .order_by(LLMModel.created_at.desc()) ) if tid: - query = query.where(LLMModel.tenant_id == uuid.UUID(tid)) + query = query.where(LLMModel.tenant_id == tid) result = await db.execute(query) models = [] for m in result.scalars().all(): @@ -415,7 +414,7 @@ async def add_llm_model( data: LLMModelCreate, tenant_id: str | None = None, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Add a new LLM model to the tenant's pool (admin).""" tid = _llm_management_tenant_id(current_user, tenant_id) @@ -452,7 +451,7 @@ async def add_llm_model( async def set_default_llm_model( model_id: uuid.UUID, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Mark this model as the tenant's default for new agents.""" result = await db.execute(_llm_model_scope(model_id, current_user)) @@ -501,7 +500,7 @@ async def set_default_llm_model( async def remove_llm_model( model_id: uuid.UUID, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Logically delete an LLM model while retaining every historical reference.""" query = select(LLMModel).where(LLMModel.id == model_id) @@ -536,7 +535,7 @@ async def update_llm_model( model_id: uuid.UUID, data: LLMModelUpdate, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Update an existing LLM model in the pool (admin).""" result = await db.execute(_llm_model_scope(model_id, current_user)) @@ -590,7 +589,7 @@ async def update_llm_model( @router.get("/info", response_model=list[EnterpriseInfoOut]) async def list_enterprise_info( current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List enterprise information entries for current tenant.""" if not current_user.tenant_id: @@ -608,7 +607,7 @@ async def update_enterprise_info( info_type: str, data: EnterpriseInfoUpdate, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Create or update enterprise information for current tenant. Triggers sync to tenant agents.""" if not current_user.tenant_id: @@ -629,7 +628,7 @@ async def list_approvals( tenant_id: str | None = None, status_filter: str | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List approval requests scoped to a tenant.""" query = select(ApprovalRequest) @@ -670,7 +669,7 @@ async def resolve_approval( approval_id: uuid.UUID, data: ApprovalAction, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Approve or reject a pending approval request.""" try: @@ -690,7 +689,7 @@ async def list_audit_logs( tenant_id: str | None = None, limit: int = 50, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List audit logs scoped to a tenant (admin only).""" query = select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit) @@ -711,7 +710,7 @@ async def list_audit_logs( async def get_enterprise_stats( tenant_id: str | None = None, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get enterprise dashboard statistics, optionally scoped to a tenant.""" # Determine which tenant to filter by @@ -771,7 +770,7 @@ class TenantQuotaUpdate(BaseModel): @router.get("/tenant-quotas") async def get_tenant_quotas( current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get tenant quota defaults and heartbeat settings.""" if not current_user.tenant_id: @@ -797,7 +796,7 @@ async def get_tenant_quotas( async def update_tenant_quotas( data: TenantQuotaUpdate, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Update tenant quota defaults (admin only). Enforces heartbeat floor on existing agents.""" if not current_user.tenant_id: @@ -854,7 +853,7 @@ class TestEmailRequest(BaseModel): async def send_test_email_endpoint( data: TestEmailRequest, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Send a test email to verify SMTP configuration (admin only).""" import smtplib @@ -890,7 +889,7 @@ async def send_test_email_endpoint( @router.get("/email-templates") async def get_email_templates_endpoint( current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get email templates (current values + available variables per scenario).""" from app.services.system_email_service import ( @@ -915,7 +914,7 @@ class EmailTemplatesUpdate(BaseModel): async def update_email_templates_endpoint( data: EmailTemplatesUpdate, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Save email templates (admin only).""" from app.services.system_email_service import EMAIL_TEMPLATE_VARIABLES @@ -1033,7 +1032,7 @@ async def _runtime_model_settings_payload(db: AsyncSession, *, tenant_id: uuid.U async def get_runtime_model_settings( tenant_id: str | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Return the selected tenant's eligible Group Runtime model choices.""" resolved_tenant_id = _runtime_settings_tenant_id(current_user, tenant_id) @@ -1045,7 +1044,7 @@ async def update_runtime_model_settings( data: RuntimeModelSettingsUpdate, tenant_id: str | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Persist tenant-scoped Group Runtime models, effective immediately.""" resolved_tenant_id = _runtime_settings_tenant_id(current_user, tenant_id) @@ -1086,7 +1085,7 @@ async def update_runtime_model_settings( @router.get("/system-settings/notification_bar/public") async def get_notification_bar_public( - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Public (no auth) endpoint to read the notification bar config.""" result = await db.execute( @@ -1106,7 +1105,7 @@ async def get_notification_bar_public( async def get_system_setting( key: str, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get a system setting by key.""" _require_system_setting_access(key, current_user) @@ -1122,7 +1121,7 @@ async def update_system_setting( key: str, data: SettingUpdate, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Create or update a system setting.""" _require_system_setting_access(key, current_user) @@ -1240,7 +1239,7 @@ async def list_identity_providers( tenant_id: str | None = None, global_only: bool = False, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List identity providers configured for the tenant.""" # Authorization: non-platform admins can only see their own tenant's providers @@ -1394,7 +1393,7 @@ def _identity_provider_response(provider: IdentityProvider, sso_domain: str | No async def create_identity_provider( data: IdentityProviderCreate, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Create a new identity provider (Admin only).""" from app.services.auth_registry import auth_provider_registry @@ -1445,7 +1444,7 @@ async def create_identity_provider( async def create_oauth2_provider( data: IdentityProviderOAuth2Create, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Create a new OAuth2 identity provider with simplified fields (app_id, app_secret, authorize_url, etc.).""" from app.services.auth_registry import auth_provider_registry @@ -1511,7 +1510,7 @@ async def update_oauth2_provider( provider_id: uuid.UUID, data: OAuth2ConfigUpdate, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Update an OAuth2 identity provider with simplified fields.""" from app.services.auth_registry import auth_provider_registry @@ -1579,7 +1578,7 @@ async def update_identity_provider( provider_id: uuid.UUID, data: IdentityProviderUpdate, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Update an existing identity provider.""" from app.services.auth_registry import auth_provider_registry @@ -1636,7 +1635,7 @@ async def update_identity_provider( async def delete_identity_provider( provider_id: uuid.UUID, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Delete an identity provider.""" result = await db.execute(select(IdentityProvider).where(IdentityProvider.id == provider_id)) @@ -1675,7 +1674,7 @@ async def list_org_departments( tenant_id: str | None = None, provider_id: str | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List all departments, optionally filtered by tenant or provider.""" # Tenant isolation rules: @@ -1742,7 +1741,7 @@ async def list_org_members( tenant_id: str | None = None, provider_id: str | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List org members, optionally filtered by department, search, tenant, or provider.""" # Tenant isolation rules: @@ -1825,7 +1824,7 @@ async def list_org_members( async def trigger_org_sync( provider_id: str | None = None, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Manually trigger org structure sync from a specific identity provider.""" from app.services.org_sync_service import org_sync_service @@ -1859,7 +1858,7 @@ async def wecom_org_sync_verify( timestamp: str = "", nonce: str = "", echostr: str = "", - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Handle WeCom receive-message-server URL verification for the org sync app. @@ -2004,7 +2003,7 @@ async def _ensure_invitation_email_enabled(db: AsyncSession) -> None: async def create_invitation_codes( data: InvitationCodeCreate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Batch-create invitation codes for the current user's company.""" _require_tenant_admin(current_user) @@ -2033,7 +2032,7 @@ async def invite_users( data: UserInviteRequest, background_tasks: BackgroundTasks, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Batch-invite users via email to the current user's company.""" _require_tenant_admin(current_user) @@ -2099,7 +2098,7 @@ async def list_invitation_codes( page_size: int = 20, search: str = "", current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List invitation codes for the current user's company.""" _require_tenant_admin(current_user) @@ -2143,7 +2142,7 @@ async def list_invitation_codes( @router.get("/invitation-codes/export") async def export_invitation_codes_csv( current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Export invitation codes for the current user's company as CSV.""" _require_tenant_admin(current_user) @@ -2182,7 +2181,7 @@ async def export_invitation_codes_csv( async def deactivate_invitation_code( code_id: str, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Deactivate an invitation code (must belong to current user's company).""" _require_tenant_admin(current_user) diff --git a/backend/app/api/feishu.py b/backend/app/api/feishu.py index 06016a3b6..a24bd9e86 100644 --- a/backend/app/api/feishu.py +++ b/backend/app/api/feishu.py @@ -1,4 +1,3 @@ -from typing import Any """Feishu OAuth and Channel API routes.""" import hashlib @@ -87,7 +86,7 @@ def _verify_and_decode_feishu_callback( async def feishu_oauth_callback( code: str, state: str = None, - db: Any = None + db: AsyncSession = Depends(get_db) ): """Handle Feishu OAuth callback — exchange code for user session.""" # Parse state if it's a UUID (session ID) or other context @@ -181,7 +180,7 @@ async def configure_channel( agent_id: uuid.UUID, data: ChannelConfigCreate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Configure Feishu bot credentials for a digital employee (wizard step 5).""" agent, _access = await check_agent_access(db, current_user, agent_id) @@ -241,7 +240,7 @@ async def configure_channel( async def get_channel_config( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get Feishu channel configuration for an agent.""" await check_agent_access(db, current_user, agent_id) @@ -256,7 +255,7 @@ async def get_channel_config( @router.get("/agents/{agent_id}/channel/webhook-url") -async def get_webhook_url(agent_id: uuid.UUID, request: Request, db: Any = None): +async def get_webhook_url(agent_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)): """Get the webhook URL for this agent's Feishu bot.""" from app.services.platform_service import platform_service public_base = await platform_service.get_public_base_url(db, request) @@ -267,7 +266,7 @@ async def get_webhook_url(agent_id: uuid.UUID, request: Request, db: Any = None) async def delete_channel_config( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Remove Feishu bot configuration for an agent.""" agent, _access = await check_agent_access(db, current_user, agent_id) diff --git a/backend/app/api/files.py b/backend/app/api/files.py index 1d3265cf3..faf130ed7 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -1,4 +1,3 @@ -from typing import Any """File management API routes for agent workspaces.""" import asyncio @@ -227,7 +226,7 @@ async def list_files( agent_id: uuid.UUID, path: str = "", current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List files and directories in an agent's file system.""" await check_agent_access(db, current_user, agent_id) @@ -293,7 +292,7 @@ async def read_file( agent_id: uuid.UUID, path: str, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Read the content of a file.""" await check_agent_access(db, current_user, agent_id) @@ -432,7 +431,7 @@ async def preview_file( agent_id: uuid.UUID, path: str, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Return a browser-friendly preview payload for Workspace files.""" await check_agent_access(db, current_user, agent_id) @@ -563,7 +562,7 @@ async def download_file( token: str = "", inline: bool = False, credentials: HTTPAuthorizationCredentials | None = Depends(HTTPBearer(auto_error=False)), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Download / serve a file from the agent workspace (browser-friendly). @@ -624,7 +623,7 @@ async def write_file( path: str, data: FileWrite, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Write content to a file (create or overwrite).""" await check_agent_access(db, current_user, agent_id) @@ -669,7 +668,7 @@ async def lock_file( agent_id: uuid.UUID, data: FileLockBody, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Acquire or refresh a short-lived human editing lock for a file.""" await check_agent_access(db, current_user, agent_id) @@ -691,7 +690,7 @@ async def unlock_file( agent_id: uuid.UUID, path: str, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Release the current user's edit lock for a file.""" await check_agent_access(db, current_user, agent_id) @@ -705,7 +704,7 @@ async def get_file_revisions( agent_id: uuid.UUID, path: str, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List version history for the currently opened Workspace file.""" await check_agent_access(db, current_user, agent_id) @@ -736,7 +735,7 @@ async def restore_file_revision( agent_id: uuid.UUID, data: RestoreRevisionBody, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Restore a file to a previous revision's after-content.""" await check_agent_access(db, current_user, agent_id) @@ -776,7 +775,7 @@ async def delete_file( path: str, expected_version_token: str | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Delete a file.""" await _require_agent_file_delete_access(db, current_user, agent_id) @@ -816,7 +815,7 @@ async def import_skill_to_agent( agent_id: uuid.UUID, body: ImportSkillBody, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Import a global skill into this agent's skills/ workspace folder. @@ -866,7 +865,7 @@ async def upload_file_to_workspace( file: UploadFileType = FastFile(...), path: str = "workspace/knowledge_base", current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Upload a binary file to agent workspace.""" await check_agent_access(db, current_user, agent_id) @@ -1079,7 +1078,7 @@ async def agent_import_from_clawhub( agent_id: uuid.UUID, body: ClawhubImportBody, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Import a skill from ClawHub directly into this agent's skills/ workspace.""" await check_agent_access(db, current_user, agent_id) @@ -1134,7 +1133,7 @@ async def agent_import_from_url( agent_id: uuid.UUID, body: UrlImportBody, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Import a skill from a GitHub URL directly into this agent's skills/ workspace.""" await check_agent_access(db, current_user, agent_id) diff --git a/backend/app/api/focus.py b/backend/app/api/focus.py index 1848f27f2..9e05d6c69 100644 --- a/backend/app/api/focus.py +++ b/backend/app/api/focus.py @@ -1,4 +1,3 @@ -from typing import Any """Structured Focus API for Aware.""" import uuid @@ -48,7 +47,7 @@ async def list_agent_focus( agent_id: uuid.UUID, include_completed: bool = True, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): await check_agent_access(db, current_user, agent_id) return await list_focus_items(agent_id, include_completed=include_completed) @@ -59,7 +58,7 @@ async def upsert_agent_focus( agent_id: uuid.UUID, body: FocusUpsertBody, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): await check_agent_access(db, current_user, agent_id) if body.status not in {"in_progress", "completed"}: @@ -83,7 +82,7 @@ async def complete_agent_focus( agent_id: uuid.UUID, key: str, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): await check_agent_access(db, current_user, agent_id) item = await complete_focus_item(agent_id, key=key) diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py index be1fc3901..23e97cd25 100644 --- a/backend/app/api/gateway.py +++ b/backend/app/api/gateway.py @@ -1,4 +1,3 @@ -from typing import Any """Gateway API for OpenClaw agent communication. OpenClaw agents authenticate via X-Api-Key header and use these endpoints @@ -63,7 +62,7 @@ async def _get_agent_by_key(api_key: str, db: AsyncSession) -> Agent: @router.get("/poll", response_model=GatewayPollResponse) async def poll_messages( x_api_key: str = Header(..., alias="X-Api-Key"), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """OpenClaw agent polls for pending messages. @@ -219,7 +218,7 @@ async def poll_messages( async def report_result( body: GatewayReportRequest, x_api_key: str = Header(None, alias="X-Api-Key"), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """OpenClaw agent reports the result of a processed message.""" if not x_api_key: @@ -344,7 +343,7 @@ async def report_result( @router.post("/heartbeat") async def heartbeat( x_api_key: str = Header(..., alias="X-Api-Key"), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Pure heartbeat ping — keeps the OpenClaw agent marked as online.""" agent = await _get_agent_by_key(x_api_key, db) @@ -360,7 +359,7 @@ async def heartbeat( async def send_message( body: GatewaySendMessageRequest, x_api_key: str = Header(..., alias="X-Api-Key"), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """OpenClaw agent sends a message to a person or another agent. @@ -572,7 +571,7 @@ async def get_setup_guide( agent_id: uuid.UUID, x_api_key: str = Header(..., alias="X-Api-Key"), accept_language: str | None = Header(None, alias="Accept-Language"), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Return the pre-filled Skill file and Heartbeat instruction for this agent.""" agent = await _get_agent_by_key(x_api_key, db) diff --git a/backend/app/api/google_workspace.py b/backend/app/api/google_workspace.py index d0270dd1a..af3ee3010 100644 --- a/backend/app/api/google_workspace.py +++ b/backend/app/api/google_workspace.py @@ -1,4 +1,3 @@ -from typing import Any """Google Workspace OAuth callback routes.""" import uuid @@ -39,7 +38,7 @@ async def get_google_workspace_sync_authorize_url( provider_id: uuid.UUID, request: Request, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): provider = await get_google_provider(db, provider_id) if current_user.role != "platform_admin" and provider.tenant_id != current_user.tenant_id: @@ -195,7 +194,7 @@ async def google_workspace_callback( code: str, state: str | None = None, request: Request = None, - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Unified callback for Google Workspace SSO login and admin authorization.""" parsed_state = parse_google_oauth_state(state) if state else None diff --git a/backend/app/api/groups.py b/backend/app/api/groups.py index ae5f63eb0..8ec2cb233 100644 --- a/backend/app/api/groups.py +++ b/backend/app/api/groups.py @@ -512,7 +512,7 @@ async def _message_outputs( async def create_group( body: CreateGroupIn, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -545,7 +545,7 @@ async def create_group( @router.get("", response_model=list[GroupOut]) async def list_groups( current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -562,7 +562,7 @@ async def list_tenant_member_candidates( participant_type: Annotated[Literal["user", "agent"], Query()], limit: Annotated[int, Query(ge=1, le=100)] = 100, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Candidates for the create-group flow, before any group exists.""" tenant_id = _tenant_id(current_user) @@ -586,7 +586,7 @@ async def list_tenant_member_candidates( async def get_group( group_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -606,7 +606,7 @@ async def patch_group( group_id: uuid.UUID, body: PatchGroupIn, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): if "name" not in body.model_fields_set and "description" not in body.model_fields_set: raise HTTPException(status_code=400, detail="At least one field must be supplied") @@ -639,7 +639,7 @@ async def patch_group( async def delete_group( group_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -666,7 +666,7 @@ async def delete_group( async def list_group_members( group_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -691,7 +691,7 @@ async def list_group_member_candidates( participant_type: Annotated[Literal["user", "agent"], Query()], limit: Annotated[int, Query(ge=1, le=100)] = 100, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -722,7 +722,7 @@ async def invite_group_member( group_id: uuid.UUID, body: InviteGroupMemberIn, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -755,7 +755,7 @@ async def remove_group_member( group_id: uuid.UUID, member_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -784,7 +784,7 @@ async def remove_group_member( async def list_group_sessions( group_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -819,7 +819,7 @@ async def create_group_session( group_id: uuid.UUID, body: CreateGroupSessionIn, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -850,7 +850,7 @@ async def patch_group_session( session_id: uuid.UUID, body: PatchGroupSessionIn, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -884,7 +884,7 @@ async def delete_group_session( group_id: uuid.UUID, session_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -924,7 +924,7 @@ async def mark_group_session_read( session_id: uuid.UUID, body: MarkGroupSessionReadIn, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -963,7 +963,7 @@ async def list_group_messages( Query(description="Cursor '|' for the last seen position"), ] = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -994,7 +994,7 @@ async def create_group_message( body: CreateGroupMessageIn, request: Request, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -1052,7 +1052,7 @@ async def list_active_group_runs( group_id: uuid.UUID, session_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Return exact non-terminal Runs that should animate this group Session.""" tenant_id = _tenant_id(current_user) @@ -1119,7 +1119,7 @@ async def get_group_run_state( session_id: uuid.UUID, run_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -1158,7 +1158,7 @@ async def cancel_group_run( session_id: uuid.UUID, run_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -1203,7 +1203,7 @@ async def cancel_group_run( async def get_group_announcement( group_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -1226,7 +1226,7 @@ async def put_group_announcement( group_id: uuid.UUID, body: GroupTextFileIn, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -1259,7 +1259,7 @@ async def get_group_agent_memory( group_id: uuid.UUID, agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -1284,7 +1284,7 @@ async def put_group_agent_memory( agent_id: uuid.UUID, body: GroupTextFileIn, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -1325,7 +1325,7 @@ async def delete_group_agent_memory( agent_id: uuid.UUID, expected_version_token: Annotated[str | None, Query()] = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -1361,7 +1361,7 @@ async def get_group_session_summary( group_id: uuid.UUID, session_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -1394,7 +1394,7 @@ async def list_group_workspace( group_id: uuid.UUID, path: Annotated[str, Query(max_length=500)] = "", current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -1418,7 +1418,7 @@ async def get_group_workspace_file( group_id: uuid.UUID, path: Annotated[str, Query(min_length=1, max_length=500)], current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -1443,7 +1443,7 @@ async def put_group_workspace_file( body: GroupWorkspaceFileIn, path: Annotated[str, Query(min_length=1, max_length=500)], current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) @@ -1484,7 +1484,7 @@ async def upload_group_workspace_file( expected_version_token: Annotated[str | None, Query()] = None, require_absent: Annotated[bool, Query()] = False, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Upload one group workspace file without converting binary bytes to text.""" tenant_id = _tenant_id(current_user) @@ -1561,7 +1561,7 @@ async def download_group_workspace_file( token: str = "", inline: bool = False, credentials: HTTPAuthorizationCredentials | None = Depends(HTTPBearer(auto_error=False)), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Download a group workspace file with membership authorization.""" current_user = await _download_user(token=token, credentials=credentials, db=db) @@ -1613,7 +1613,7 @@ async def delete_group_workspace_file( path: Annotated[str, Query(min_length=1, max_length=500)], expected_version_token: Annotated[str | None, Query()] = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): tenant_id = _tenant_id(current_user) participant = await _current_participant(db, current_user) diff --git a/backend/app/api/messages.py b/backend/app/api/messages.py index 73356a56a..39f4ea220 100644 --- a/backend/app/api/messages.py +++ b/backend/app/api/messages.py @@ -1,4 +1,3 @@ -from typing import Any """Messages API — inbox, unread count, mark as read. After the Participant abstraction migration, agent-to-agent messages are stored @@ -27,7 +26,7 @@ async def get_inbox( limit: int = Query(50, le=200), current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get agent-to-agent messages for agents the current user manages. @@ -85,7 +84,7 @@ async def get_inbox( @router.get("/messages/unread-count") async def get_unread_count( current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get count of unread agent-to-agent messages for the current user's agents.""" agent_ids_q = await query_dao.execute(db, select(Agent.id).where(Agent.creator_id == current_user.id)) diff --git a/backend/app/api/notification.py b/backend/app/api/notification.py index bd32c89b7..b0d560e30 100644 --- a/backend/app/api/notification.py +++ b/backend/app/api/notification.py @@ -1,7 +1,7 @@ """Notification API — list, count, mark-read, and broadcast.""" import uuid -from typing import Any, Optional +from typing import Optional from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from pydantic import BaseModel, Field @@ -39,7 +39,7 @@ async def list_notifications( unread_only: bool = Query(False), category: Optional[str] = Query(None), current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List notifications for the current user, newest first.""" query = select(Notification).where(Notification.user_id == current_user.id) @@ -69,7 +69,7 @@ async def list_notifications( async def get_unread_count( category: Optional[str] = Query(None), current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get the number of unread notifications for the current user.""" query = select(func.count(Notification.id)).where( @@ -85,7 +85,7 @@ async def get_unread_count( async def mark_read( notification_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Mark a single notification as read.""" await query_dao.execute(db, @@ -100,7 +100,7 @@ async def mark_read( @router.post("/notifications/read-all") async def mark_all_read( current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Mark all notifications as read for the current user.""" await query_dao.execute(db, @@ -125,7 +125,7 @@ async def broadcast_notification( req: BroadcastRequest, background_tasks: BackgroundTasks, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Send a notification to all users and agents in the current tenant. Requires org_admin or platform_admin role.""" diff --git a/backend/app/api/onboarding.py b/backend/app/api/onboarding.py index 03e0df572..291e159b0 100644 --- a/backend/app/api/onboarding.py +++ b/backend/app/api/onboarding.py @@ -1,4 +1,3 @@ -from typing import Any """Company onboarding APIs.""" import uuid @@ -177,7 +176,7 @@ async def _create_personal_assistant( @router.get("/status") async def get_onboarding_status( current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Return onboarding state for the current user/company.""" return _status_payload(await _get_row(db, current_user)) @@ -187,7 +186,7 @@ async def get_onboarding_status( async def start_onboarding( data: OnboardingStartRequest, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Start or resume onboarding for the current user/company.""" row = await _ensure_row(db, current_user, data.entry_mode) @@ -199,7 +198,7 @@ async def start_onboarding( async def create_personal_assistant( data: PersonalAssistantRequest, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Create the user's private assistant and advance onboarding.""" row = await _ensure_row(db, current_user, "join") @@ -228,7 +227,7 @@ async def create_personal_assistant( @router.post("/complete") async def complete_onboarding( current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Mark the current user/company onboarding as completed.""" row = await _get_row(db, current_user) diff --git a/backend/app/api/organization.py b/backend/app/api/organization.py index cc5e09e99..be26cba3b 100644 --- a/backend/app/api/organization.py +++ b/backend/app/api/organization.py @@ -1,4 +1,3 @@ -from typing import Any """Organization management API routes (users only).""" import uuid @@ -29,7 +28,7 @@ def _is_platform_admin(user: User) -> bool: async def list_users( tenant_id: uuid.UUID | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List users, optionally filtered by tenant.""" query = ( @@ -54,7 +53,7 @@ async def admin_update_user( user_id: uuid.UUID, data: UserUpdate, current_user: User = Depends(get_current_admin), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Admin update user profile.""" query = ( diff --git a/backend/app/api/pages.py b/backend/app/api/pages.py index 39d1f1972..af2d350fa 100644 --- a/backend/app/api/pages.py +++ b/backend/app/api/pages.py @@ -1,4 +1,3 @@ -from typing import Any """Public pages API — serves published HTML without authentication.""" import uuid @@ -24,7 +23,7 @@ # ── Public render (NO auth) ──────────────────────────── @public_router.get("/p/{short_id}") -async def render_page(short_id: str, db: Any = None): +async def render_page(short_id: str, db: AsyncSession = Depends(get_db)): """Serve a published HTML page. No authentication required.""" result = await query_dao.execute(db, select(PublishedPage).where(PublishedPage.short_id == short_id) @@ -64,7 +63,7 @@ async def render_page(short_id: str, db: Any = None): async def list_pages( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List published pages for an agent.""" from app.core.permissions import check_agent_access diff --git a/backend/app/api/relationships.py b/backend/app/api/relationships.py index 708b9ccbe..0a43be35b 100644 --- a/backend/app/api/relationships.py +++ b/backend/app/api/relationships.py @@ -1,4 +1,3 @@ -from typing import Any """Legacy agent relationship management API. These endpoints are retained for OKR, gateway, and historical compatibility. @@ -130,7 +129,7 @@ def _dedupe_agent_relationships(items: list[AgentRelationshipIn], agent_id: uuid async def get_relationships( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Legacy: get manually stored human relationship rows for this agent.""" from app.models.identity import IdentityProvider @@ -188,7 +187,7 @@ async def search_human_relationship_candidates( agent_id: uuid.UUID, search: str | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Legacy: search org members that can be stored as relationship rows.""" from app.models.identity import IdentityProvider @@ -298,7 +297,7 @@ async def save_relationships( agent_id: uuid.UUID, data: RelationshipBatchIn, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Legacy: replace all manually stored human relationship rows.""" _agent, access_level = await check_agent_access(db, current_user, agent_id) @@ -380,7 +379,7 @@ async def delete_relationship( agent_id: uuid.UUID, rel_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Delete a single human relationship.""" _agent, access_level = await check_agent_access(db, current_user, agent_id) @@ -406,7 +405,7 @@ async def search_visible_agents( agent_id: uuid.UUID, search: str | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Search manageable agent candidates for relationship creation.""" source_agent, access_level = await check_agent_access(db, current_user, agent_id) @@ -446,7 +445,7 @@ async def search_visible_agents( async def get_agent_relationships( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Legacy: get manually stored agent-to-agent relationship rows.""" await check_agent_access(db, current_user, agent_id) @@ -481,7 +480,7 @@ async def get_agent_relationships( async def get_agent_relationship_candidates( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Legacy: backward-compatible alias for searchable agent candidates.""" return await search_visible_agents( @@ -497,7 +496,7 @@ async def save_agent_relationships( agent_id: uuid.UUID, data: AgentRelationshipBatchIn, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Legacy: replace all manually stored agent-to-agent relationship rows.""" source_agent, access_level = await check_agent_access(db, current_user, agent_id) @@ -542,7 +541,7 @@ async def delete_agent_relationship( agent_id: uuid.UUID, rel_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Legacy: delete a single manually stored agent-to-agent relationship row.""" _agent, access_level = await check_agent_access(db, current_user, agent_id) diff --git a/backend/app/api/schedules.py b/backend/app/api/schedules.py index 8faf6a83b..43f899350 100644 --- a/backend/app/api/schedules.py +++ b/backend/app/api/schedules.py @@ -1,4 +1,3 @@ -from typing import Any """Schedule API — CRUD for agent cron jobs.""" import uuid @@ -56,7 +55,7 @@ class ScheduleOut(BaseModel): async def list_schedules( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List all schedules for an agent.""" await check_agent_access(db, current_user, agent_id) @@ -85,7 +84,7 @@ async def create_schedule( agent_id: uuid.UUID, data: ScheduleCreate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Create a new schedule for an agent.""" agent, _access = await check_agent_access(db, current_user, agent_id) @@ -117,7 +116,7 @@ async def update_schedule( schedule_id: uuid.UUID, data: ScheduleUpdate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Update a schedule.""" agent, _access = await check_agent_access(db, current_user, agent_id) @@ -151,7 +150,7 @@ async def delete_schedule( agent_id: uuid.UUID, schedule_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Delete a schedule.""" agent, _access = await check_agent_access(db, current_user, agent_id) @@ -174,7 +173,7 @@ async def trigger_schedule( agent_id: uuid.UUID, schedule_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Manually trigger a schedule execution.""" agent, _access = await check_agent_access(db, current_user, agent_id) @@ -217,7 +216,7 @@ async def get_schedule_history( agent_id: uuid.UUID, schedule_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get execution history for a schedule from activity logs.""" await check_agent_access(db, current_user, agent_id) diff --git a/backend/app/api/slack.py b/backend/app/api/slack.py index 170290e62..4f8156de7 100644 --- a/backend/app/api/slack.py +++ b/backend/app/api/slack.py @@ -1,4 +1,3 @@ -from typing import Any """Slack Bot Channel API routes.""" import hashlib @@ -35,7 +34,7 @@ async def configure_slack_channel( agent_id: uuid.UUID, data: dict, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Configure Slack bot for an agent. Fields: bot_token, signing_secret.""" agent, _ = await check_agent_access(db, current_user, agent_id) @@ -78,7 +77,7 @@ async def configure_slack_channel( async def get_slack_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): await check_agent_access(db, current_user, agent_id) result = await db.execute( @@ -94,7 +93,7 @@ async def get_slack_channel( @router.get("/agents/{agent_id}/slack-channel/webhook-url") -async def get_slack_webhook_url(agent_id: uuid.UUID, request: Request, db: Any = None): +async def get_slack_webhook_url(agent_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)): from app.services.platform_service import platform_service public_base = await platform_service.get_public_base_url(db, request) return {"webhook_url": f"{public_base}/api/channel/slack/{agent_id}/webhook"} @@ -104,7 +103,7 @@ async def get_slack_webhook_url(agent_id: uuid.UUID, request: Request, db: Any = async def delete_slack_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): agent, _ = await check_agent_access(db, current_user, agent_id) if not is_agent_creator(current_user, agent): @@ -157,7 +156,7 @@ async def _send_slack_messages(bot_token: str, channel: str, text: str) -> None: async def slack_event_webhook( agent_id: uuid.UUID, request: Request, - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Handle Slack Event API callbacks.""" body_bytes = await request.body() diff --git a/backend/app/api/sso.py b/backend/app/api/sso.py index 54006ba2a..5c494d7fc 100644 --- a/backend/app/api/sso.py +++ b/backend/app/api/sso.py @@ -1,4 +1,3 @@ -from typing import Any import uuid from datetime import datetime, timedelta, timezone from urllib.parse import quote @@ -25,7 +24,7 @@ async def create_sso_session( response: Response, tenant_id: uuid.UUID | None = None, - db: Any = None + db: AsyncSession = Depends(get_db) ): """Create a new SSO scan session for QR code login.""" session = SSOScanSession( @@ -50,7 +49,7 @@ async def create_sso_session( async def get_sso_session_status( sid: uuid.UUID, request: Request, - db: Any = None + db: AsyncSession = Depends(get_db) ): """Check the status of an SSO scan session.""" if not is_valid_sso_browser_binding(sid, request.cookies.get(sso_browser_cookie_name(sid))): @@ -95,7 +94,7 @@ async def get_sso_session_status( return response @router.put("/sso/session/{sid}/scan") -async def mark_sso_session_scanned(sid: uuid.UUID, db: Any = None): +async def mark_sso_session_scanned(sid: uuid.UUID, db: AsyncSession = Depends(get_db)): """Optional: Mark session as 'scanned' when the landing page loads on mobile.""" result = await query_dao.execute(db, select(SSOScanSession).where(SSOScanSession.id == sid)) session = result.scalar_one_or_none() @@ -105,7 +104,7 @@ async def mark_sso_session_scanned(sid: uuid.UUID, db: Any = None): return {"status": "ok"} @router.get("/sso/config") -async def get_sso_config(sid: uuid.UUID, request: Request, db: Any = None): +async def get_sso_config(sid: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)): """List active SSO providers with their redirect URLs for the specified session ID.""" # 1. Resolve session to get tenant context res = await query_dao.execute(db, select(SSOScanSession).where(SSOScanSession.id == sid)) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 14a7364e4..d2e0f73e5 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -1,4 +1,3 @@ -from typing import Any """Task management API routes.""" import uuid @@ -35,7 +34,7 @@ async def list_tasks( status_filter: str | None = None, type_filter: str | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List tasks for an agent.""" await check_agent_access(db, current_user, agent_id) @@ -66,7 +65,7 @@ async def create_task( agent_id: uuid.UUID, data: TaskCreate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Create a new task for an agent.""" agent, _access = await check_agent_access(db, current_user, agent_id) @@ -115,7 +114,7 @@ async def update_task( task_id: uuid.UUID, data: TaskUpdate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Update a task.""" await check_agent_access(db, current_user, agent_id) @@ -135,7 +134,7 @@ async def get_task_logs( agent_id: uuid.UUID, task_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get progress logs for a task.""" await check_agent_access(db, current_user, agent_id) @@ -151,7 +150,7 @@ async def add_task_log( task_id: uuid.UUID, data: TaskLogCreate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Add a progress log entry to a task.""" await check_agent_access(db, current_user, agent_id) @@ -166,7 +165,7 @@ async def trigger_task( agent_id: uuid.UUID, task_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Manually trigger a supervision task execution (for testing).""" from app.core.permissions import is_agent_expired diff --git a/backend/app/api/teams.py b/backend/app/api/teams.py index 227adb691..ebc7a97c2 100644 --- a/backend/app/api/teams.py +++ b/backend/app/api/teams.py @@ -1,4 +1,3 @@ -from typing import Any """Microsoft Teams Bot Channel API routes.""" import hmac @@ -267,7 +266,7 @@ async def configure_teams_channel( agent_id: uuid.UUID, data: dict, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Configure Microsoft Teams bot for an agent. Fields: app_id, app_secret.""" agent, _ = await check_agent_access(db, current_user, agent_id) @@ -332,7 +331,7 @@ async def configure_teams_channel( async def get_teams_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get Microsoft Teams channel configuration for an agent.""" await check_agent_access(db, current_user, agent_id) @@ -353,7 +352,7 @@ async def get_teams_webhook_url( agent_id: uuid.UUID, request: Request, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get the Microsoft Teams webhook URL for an agent.""" await check_agent_access(db, current_user, agent_id) @@ -366,7 +365,7 @@ async def get_teams_webhook_url( async def delete_teams_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Delete Microsoft Teams channel configuration for an agent.""" agent, _ = await check_agent_access(db, current_user, agent_id) @@ -394,7 +393,7 @@ async def delete_teams_channel( async def teams_event_webhook( agent_id: uuid.UUID, request: Request, - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Handle Microsoft Teams Bot Framework callbacks.""" try: diff --git a/backend/app/api/tenants.py b/backend/app/api/tenants.py index cee8aa859..7206fc585 100644 --- a/backend/app/api/tenants.py +++ b/backend/app/api/tenants.py @@ -1,4 +1,3 @@ -from typing import Any """Tenant (Company) management API. Public endpoints for self-service company creation and joining. @@ -154,7 +153,7 @@ class SelfCreateResponse(BaseModel): async def self_create_company( data: TenantCreate, current_user: User = Depends(get_authenticated_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Create a new company (self-service). The creator becomes org_admin. @@ -255,7 +254,7 @@ class JoinResponse(BaseModel): async def join_company( data: JoinRequest, current_user: User = Depends(get_authenticated_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Join an existing company using an invitation code. @@ -377,7 +376,7 @@ async def join_company( # ─── Registration Config ─────────────────────────────── @router.get("/registration-config") -async def get_registration_config(db: Any = None): +async def get_registration_config(db: AsyncSession = Depends(get_db)): """Public — returns whether self-creation of companies is allowed.""" from app.models.system_settings import SystemSetting result = await query_dao.execute(db, @@ -393,7 +392,7 @@ async def get_registration_config(db: Any = None): @router.get("/resolve-by-domain") async def resolve_tenant_by_domain( domain: str, - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Resolve a tenant by its sso_domain or subdomain slug. @@ -461,7 +460,7 @@ async def resolve_tenant_by_domain( @router.get("/", response_model=list[TenantOut]) async def list_tenants( current_user: User = Depends(require_role("platform_admin")), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List all tenants (platform_admin only).""" result = await query_dao.execute(db, select(Tenant).order_by(Tenant.created_at.desc())) @@ -471,7 +470,7 @@ async def list_tenants( @router.get("/me", response_model=TenantOut) async def get_my_tenant( current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Return the current user's own tenant. Any authenticated member can read this — the wizard and the chat model switcher need default_model_id, which @@ -489,7 +488,7 @@ async def get_my_tenant( @router.get("/me/token-usage") async def get_my_tenant_token_usage( current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Return aggregate token and prompt-cache usage for the current company.""" if not current_user.tenant_id: @@ -530,7 +529,7 @@ def bucket(total: int, cache_read: int, cache_creation: int) -> dict: async def get_tenant( tenant_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get tenant details. Platform admins can view any; org_admins only their own.""" if current_user.role not in ("platform_admin", "org_admin"): @@ -552,7 +551,7 @@ async def update_tenant( tenant_id: uuid.UUID, data: TenantUpdate, current_user: User = Depends(require_role("org_admin", "platform_admin")), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Update tenant settings. Platform admins can update any; org_admins only their own.""" if current_user.role == "org_admin": @@ -595,7 +594,7 @@ async def upload_tenant_logo( tenant_id: uuid.UUID, file: UploadFile = File(...), current_user: User = Depends(require_role("org_admin", "platform_admin")), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Upload a cropped square company logo. @@ -637,7 +636,7 @@ async def upload_tenant_logo( async def delete_tenant_logo( tenant_id: uuid.UUID, current_user: User = Depends(require_role("org_admin", "platform_admin")), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Remove a custom company logo and fall back to the generated default.""" tenant = await _get_updateable_tenant(tenant_id, current_user, db) @@ -660,7 +659,7 @@ async def assign_user_to_tenant( user_id: uuid.UUID, role: str = "member", current_user: User = Depends(require_role("platform_admin")), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Assign a user to a tenant with a specific role.""" # Verify tenant @@ -689,7 +688,7 @@ async def assign_user_to_tenant( async def delete_tenant( tenant_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Permanently delete a company and ALL its data. diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index 2edcb8d4c..8afaf141e 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -1,4 +1,3 @@ -from typing import Any """Tool management API — CRUD for tools and per-agent assignments.""" import uuid @@ -229,7 +228,7 @@ class CategoryConfigUpdate(BaseModel): async def list_tools( tenant_id: str | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List platform tools scoped by tenant (builtin + tenant-specific).""" _require_tool_manager(current_user) @@ -274,7 +273,7 @@ async def list_tools( async def create_tool( data: ToolCreate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Create a new tool (typically MCP). @@ -327,7 +326,7 @@ class BulkToolUpdateItem(BaseModel): async def update_tools_bulk( updates: list[BulkToolUpdateItem], current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Bulk update the enabled status of multiple tools.""" _require_tool_manager(current_user) @@ -351,7 +350,7 @@ async def update_tool( tool_id: uuid.UUID, data: ToolUpdate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Update a tool.""" _require_tool_manager(current_user) @@ -386,7 +385,7 @@ async def update_tool( async def delete_tool( tool_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Delete a tool (only non-builtin).""" _require_tool_manager(current_user) @@ -409,7 +408,7 @@ async def delete_tool( async def get_agent_tools( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get tools for a specific agent with their enabled status.""" # Determine if this is a system agent (e.g. OKR Agent). @@ -498,7 +497,7 @@ async def update_agent_tools( agent_id: uuid.UUID, updates: list[AgentToolUpdate], current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Update tool assignments for an agent.""" agent_obj = await _require_agent_tool_manager(db, current_user, agent_id) @@ -542,7 +541,7 @@ async def get_mcp_authorization_status( tool_id: uuid.UUID, response: Response, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Read one assigned Smithery connection for an authorized manager.""" response.headers["Cache-Control"] = "no-store" @@ -653,7 +652,7 @@ class MCPServerUpdate(BaseModel): async def update_mcp_server( data: MCPServerUpdate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Bulk-update the Server URL and API Key for all tools from an MCP server. @@ -705,7 +704,7 @@ async def update_mcp_server( async def list_agent_installed_tools( tenant_id: str | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Admin endpoint: list user-installed tools scoped by tenant.""" _require_tool_manager(current_user) @@ -757,7 +756,7 @@ async def list_agent_installed_tools( async def delete_agent_tool( agent_tool_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Admin: remove an agent-tool assignment. Also deletes the tool record if no other agents use it.""" _require_tool_manager(current_user) @@ -791,7 +790,7 @@ async def get_agent_tool_config( agent_id: uuid.UUID, tool_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get merged tool config (global defaults + agent overrides) and config_schema. @@ -836,7 +835,7 @@ async def update_agent_tool_config( tool_id: uuid.UUID, data: AgentToolConfigUpdate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Save per-agent config override for a tool.""" agent = await _require_agent_tool_manager(db, current_user, agent_id) @@ -874,7 +873,7 @@ async def update_agent_tool_config( async def get_agent_tools_with_config( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get agent's enabled tools with per-agent config info and config_schema for settings UI. @@ -1017,7 +1016,7 @@ async def get_category_config( agent_id: uuid.UUID, category: str, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Get shared configuration for a tool category. @@ -1100,7 +1099,7 @@ async def update_category_config( category: str, data: CategoryConfigUpdate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Update or create shared configuration for a tool category.""" from app.core.permissions import is_agent_creator @@ -1157,7 +1156,7 @@ async def delete_category_config( agent_id: uuid.UUID, category: str, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Remove shared configuration for a tool category.""" from app.core.permissions import is_agent_creator @@ -1181,7 +1180,7 @@ async def test_category_config( agent_id: uuid.UUID, category: str, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Test connectivity for a tool category.""" await _require_agent_tool_manager(db, current_user, agent_id) diff --git a/backend/app/api/users.py b/backend/app/api/users.py index f3d259b34..eed9cf409 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -1,4 +1,3 @@ -from typing import Any import uuid from fastapi import APIRouter, Depends, HTTPException, status @@ -52,7 +51,7 @@ class UserOut(BaseModel): async def list_users( tenant_id: str | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """List all users in the specified tenant (admin only).""" if current_user.role not in ("platform_admin", "org_admin"): @@ -107,7 +106,7 @@ async def update_user_quota( user_id: uuid.UUID, data: UserQuotaUpdate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Update a user's quota settings (admin only).""" if current_user.role not in ("platform_admin", "org_admin"): @@ -169,7 +168,7 @@ async def update_user_role( user_id: uuid.UUID, data: RoleUpdate, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Change a user's role within the same company. diff --git a/backend/app/api/wechat.py b/backend/app/api/wechat.py index e9d16bf9b..aa3b87d2c 100644 --- a/backend/app/api/wechat.py +++ b/backend/app/api/wechat.py @@ -1,7 +1,6 @@ """WeChat iLink Bot channel API routes.""" from __future__ import annotations -from typing import Any import asyncio import uuid @@ -58,7 +57,7 @@ async def create_wechat_qrcode( agent_id: uuid.UUID, data: dict | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): agent, _ = await check_agent_access(db, current_user, agent_id) if not is_agent_creator(current_user, agent): @@ -85,7 +84,7 @@ async def get_wechat_qrcode_status( qrcode: str, route_tag: str | None = None, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): agent, _ = await check_agent_access(db, current_user, agent_id) if not is_agent_creator(current_user, agent): @@ -158,7 +157,7 @@ async def get_wechat_qrcode_image( agent_id: uuid.UUID, url: str, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): agent, _ = await check_agent_access(db, current_user, agent_id) if not is_agent_creator(current_user, agent): @@ -178,7 +177,7 @@ async def get_wechat_qrcode_image( async def get_wechat_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): await check_agent_access(db, current_user, agent_id) result = await query_dao.execute(db, @@ -197,7 +196,7 @@ async def get_wechat_channel( async def delete_wechat_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): agent, _ = await check_agent_access(db, current_user, agent_id) if not is_agent_creator(current_user, agent): diff --git a/backend/app/api/wecom.py b/backend/app/api/wecom.py index 5b916f8e6..6876e40c1 100644 --- a/backend/app/api/wecom.py +++ b/backend/app/api/wecom.py @@ -1,4 +1,3 @@ -from typing import Any """WeCom (企业微信) Channel API routes. Provides Config CRUD and webhook-based message handling with AES encryption. @@ -112,7 +111,7 @@ def _verify_signature(token: str, timestamp: str, nonce: str, encrypt: str) -> s @router.get("/wecom-verify/{filename}") async def serve_wecom_verify_file( filename: str, - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Serve a WeCom domain verification file. @@ -156,7 +155,7 @@ async def configure_wecom_channel( agent_id: uuid.UUID, data: dict, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Configure WeCom bot for an agent. @@ -247,7 +246,7 @@ async def configure_wecom_channel( async def get_wecom_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): await check_agent_access(db, current_user, agent_id) result = await db.execute( @@ -272,7 +271,7 @@ async def get_wecom_channel( async def get_wecom_webhook_url( agent_id: uuid.UUID, request: Request, - db: Any = None, + db: AsyncSession = Depends(get_db), ): public_base = await platform_service.get_public_base_url(db, request) return {"webhook_url": f"{public_base}/api/channel/wecom/{agent_id}/webhook"} @@ -282,7 +281,7 @@ async def get_wecom_webhook_url( async def delete_wecom_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): agent, _ = await check_agent_access(db, current_user, agent_id) if not is_agent_creator(current_user, agent): @@ -314,7 +313,7 @@ async def wecom_verify_webhook( timestamp: str = "", nonce: str = "", echostr: str = "", - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Handle WeCom callback URL verification (GET request).""" result = await db.execute( @@ -352,7 +351,7 @@ async def wecom_event_webhook( msg_signature: str = "", timestamp: str = "", nonce: str = "", - db: Any = None, + db: AsyncSession = Depends(get_db), ): """Handle WeCom message callback (POST request with encrypted XML).""" body_bytes = await request.body() @@ -608,7 +607,7 @@ async def _process_wecom_text( async def wecom_callback( code: str, state: str = None, - db: Any = None, + db: AsyncSession = Depends(get_db), ): # 1. Resolve session to get tenant context tenant_id = None diff --git a/backend/app/api/whatsapp.py b/backend/app/api/whatsapp.py index 68fd08c24..a8d088adc 100644 --- a/backend/app/api/whatsapp.py +++ b/backend/app/api/whatsapp.py @@ -1,7 +1,6 @@ """WhatsApp Cloud API channel routes.""" from __future__ import annotations -from typing import Any import hashlib import hmac @@ -54,7 +53,7 @@ async def configure_whatsapp_channel( agent_id: uuid.UUID, data: dict, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): agent, _ = await check_agent_access(db, current_user, agent_id) if not is_agent_creator(current_user, agent): @@ -106,7 +105,7 @@ async def configure_whatsapp_channel( async def get_whatsapp_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): await check_agent_access(db, current_user, agent_id) result = await db.execute( @@ -122,7 +121,7 @@ async def get_whatsapp_channel( @router.get("/agents/{agent_id}/whatsapp-channel/webhook-url") -async def get_whatsapp_webhook_url(agent_id: uuid.UUID, request: Request, db: Any = None): +async def get_whatsapp_webhook_url(agent_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)): from app.services.platform_service import platform_service public_base = await platform_service.get_public_base_url(db, request) @@ -133,7 +132,7 @@ async def get_whatsapp_webhook_url(agent_id: uuid.UUID, request: Request, db: An async def delete_whatsapp_channel( agent_id: uuid.UUID, current_user: User = Depends(get_current_user), - db: Any = None, + db: AsyncSession = Depends(get_db), ): agent, _ = await check_agent_access(db, current_user, agent_id) if not is_agent_creator(current_user, agent): @@ -157,7 +156,7 @@ async def whatsapp_verify_webhook( hub_mode: str = Query("", alias="hub.mode"), hub_verify_token: str = Query("", alias="hub.verify_token"), hub_challenge: str = Query("", alias="hub.challenge"), - db: Any = None, + db: AsyncSession = Depends(get_db), ): result = await db.execute( select(ChannelConfig).where( @@ -178,7 +177,7 @@ async def whatsapp_verify_webhook( async def whatsapp_event_webhook( agent_id: uuid.UUID, request: Request, - db: Any = None, + db: AsyncSession = Depends(get_db), ): body = await request.body() result = await db.execute( diff --git a/backend/app/dao/agent_access_dao.py b/backend/app/dao/agent_access_dao.py index c0cddfc8b..41a60be9b 100644 --- a/backend/app/dao/agent_access_dao.py +++ b/backend/app/dao/agent_access_dao.py @@ -40,8 +40,7 @@ async def list_permissions(self, agent_id: Any) -> Sequence[AgentPermission]: async def list_active_user_ids_by_tenant(self, tenant_id: Any = None) -> list[Any]: """Return active user ids in a tenant.""" - ctx_tenant = self._require_tenant_id() - tid = ctx_tenant if ctx_tenant is not None else tenant_id + tid = self._require_tenant_id() or tenant_id async with self.session(readonly=True) as db: stmt = select(User.id).where(User.is_active == True) # noqa: E712 if tid is not None: @@ -63,8 +62,7 @@ async def list_custom_permission_user_ids(self, agent_id: Any) -> list[Any]: async def list_active_admin_user_ids_by_tenant(self, tenant_id: Any = None) -> list[Any]: """Return active tenant admin user ids.""" - ctx_tenant = self._require_tenant_id() - tid = ctx_tenant if ctx_tenant is not None else tenant_id + tid = self._require_tenant_id() or tenant_id async with self.session(readonly=True) as db: stmt = select(User.id).where( User.is_active == True, # noqa: E712 @@ -85,8 +83,7 @@ async def list_active_relationship_user_ids( """Return active org-member user ids already linked to an agent.""" if not user_ids: return set() - ctx_tenant = self._require_tenant_id() - tid = ctx_tenant if ctx_tenant is not None else tenant_id + tid = self._require_tenant_id() or tenant_id async with self.session(readonly=True) as db: stmt = ( select(OrgMember.user_id) @@ -106,8 +103,7 @@ async def list_active_users_by_ids(self, *, user_ids: set[Any], tenant_id: Any = """Return active users by ids under one tenant.""" if not user_ids: return [] - ctx_tenant = self._require_tenant_id() - tid = ctx_tenant if ctx_tenant is not None else tenant_id + tid = self._require_tenant_id() or tenant_id async with self.session(readonly=True) as db: stmt = select(User).where( User.id.in_(user_ids), diff --git a/backend/app/dao/base.py b/backend/app/dao/base.py index 2222d8ba2..27e57e3ac 100644 --- a/backend/app/dao/base.py +++ b/backend/app/dao/base.py @@ -193,6 +193,30 @@ def _require_tenant_id(self) -> uuid.UUID | None: """Return the active tenant_id or None if not set.""" return _tenant_ctx.get() + def add_scoped( + self, + db: AsyncSession, + obj: ModelType, + *, + tenant_id: uuid.UUID | None = None, + ) -> ModelType: + """Add a tenant-owned row after injecting and validating its tenant.""" + context_tenant_id = self._require_tenant_id() + if tenant_id is not None and context_tenant_id is not None and tenant_id != context_tenant_id: + raise RuntimeError("Explicit tenant_id does not match the active tenant context") + + resolved_tenant_id = tenant_id or context_tenant_id + if resolved_tenant_id is None: + raise RuntimeError("Tenant-scoped writes require a tenant_id or active tenant context") + + object_tenant_id = getattr(obj, "tenant_id", None) + if object_tenant_id is not None and object_tenant_id != resolved_tenant_id: + raise RuntimeError("Object tenant_id does not match the write tenant scope") + + obj.tenant_id = resolved_tenant_id + db.add(obj) + return obj + async def get_scoped(self, id: Any, db: Any = None) -> ModelType | None: """Fetch a single record by PK, automatically scoped to current tenant.""" tenant_id = self._require_tenant_id() diff --git a/backend/app/dao/chat_message_dao.py b/backend/app/dao/chat_message_dao.py index 7674cd6ea..9b9e57bd9 100644 --- a/backend/app/dao/chat_message_dao.py +++ b/backend/app/dao/chat_message_dao.py @@ -1,31 +1,16 @@ -"""DAO for ChatMessage model. - -Note: ChatMessage does not yet have a tenant_id column. Tenant isolation -is applied via the agent_id -> agents.tenant_id join path. -A migration to add tenant_id directly to chat_messages is tracked separately -(see implementation_plan.md Q3). Until then this DAO enforces isolation -by requiring an agent_id or session conversation_id scoped within the -caller's already-verified tenant context. -""" +"""Tenant-scoped persistence for ChatMessage rows.""" import uuid from collections.abc import Sequence from sqlalchemy import select -from app.dao.base import BaseDAO +from app.dao.base import TenantScopedBaseDAO from app.models.audit import ChatMessage -class ChatMessageDAO(BaseDAO[ChatMessage]): - """DAO for ChatMessage entities. - - Because chat_messages lacks a tenant_id column, callers must always - supply at least one of ``agent_id``, ``session_conversation_id``, or - ``user_id`` to scope the query. The DAO validates that the agent is - already confirmed to belong to the current tenant (callers are expected - to use AgentDAO.get_active() first before calling here). - """ +class ChatMessageDAO(TenantScopedBaseDAO[ChatMessage]): + """DAO for ChatMessage entities with automatic tenant write scoping.""" def __init__(self) -> None: super().__init__(ChatMessage) @@ -90,6 +75,7 @@ async def create_message( participant_id: uuid.UUID | None = None, thinking: str | None = None, mentions: list | None = None, + tenant_id: uuid.UUID | None = None, ) -> ChatMessage: """Create a single chat message.""" async with self.session() as db: @@ -103,7 +89,7 @@ async def create_message( thinking=thinking, mentions=mentions or [], ) - db.add(msg) + self.add_scoped(db, msg, tenant_id=tenant_id) await db.flush() return msg @@ -111,7 +97,8 @@ async def bulk_create(self, messages: list[dict]) -> Sequence[ChatMessage]: """Insert multiple messages in a single flush.""" async with self.session() as db: objs = [ChatMessage(**m) for m in messages] - db.add_all(objs) + for obj in objs: + self.add_scoped(db, obj) await db.flush() return objs diff --git a/backend/app/dao/chat_session_dao.py b/backend/app/dao/chat_session_dao.py index 7a0095eb3..3049a5daf 100644 --- a/backend/app/dao/chat_session_dao.py +++ b/backend/app/dao/chat_session_dao.py @@ -29,6 +29,24 @@ async def get_active(self, session_id: uuid.UUID, db: Any = None) -> ChatSession stmt = stmt.where(ChatSession.tenant_id == tenant_id) return (await session_db.execute(stmt)).scalar_one_or_none() + async def get_active_for_agent( + self, + *, + tenant_id: uuid.UUID, + agent_id: uuid.UUID, + session_id: uuid.UUID, + db: Any = None, + ) -> ChatSession | None: + """Fetch an active Session for one exact tenant and Agent scope.""" + async with self.session(db=db, readonly=True) as session_db: + stmt = select(ChatSession).where( + ChatSession.tenant_id == tenant_id, + ChatSession.agent_id == agent_id, + ChatSession.id == session_id, + ChatSession.deleted_at.is_(None), + ) + return (await session_db.execute(stmt)).scalar_one_or_none() + async def get_including_deleted(self, session_id: uuid.UUID, db: Any = None) -> ChatSession | None: """Fetch a session by ID including soft-deleted records.""" tenant_id = self._require_tenant_id() diff --git a/backend/app/dao/user_dao.py b/backend/app/dao/user_dao.py index 2b61468f0..875a35053 100644 --- a/backend/app/dao/user_dao.py +++ b/backend/app/dao/user_dao.py @@ -107,13 +107,11 @@ async def get_representative_user_for_identity(self, identity_id: Any) -> User | async def list_admin_users(self, tenant_id: Any = None) -> Sequence[User]: """Fetch all active org/platform admin users in a tenant. - If active tenant context exists in _tenant_ctx, enforces active tenant scope - to prevent cross-tenant queries by org_admin. + If active tenant context exists in _tenant_ctx, enforces active tenant scope. """ from app.dao.base import _tenant_ctx - active_tenant = _tenant_ctx.get() - tid = active_tenant if active_tenant is not None else tenant_id + tid = _tenant_ctx.get() or tenant_id if not tid: return [] async with self.session(readonly=True) as db: diff --git a/backend/app/services/agent_runtime/a2a_completion.py b/backend/app/services/agent_runtime/a2a_completion.py index c0e18e0bd..3533d761e 100644 --- a/backend/app/services/agent_runtime/a2a_completion.py +++ b/backend/app/services/agent_runtime/a2a_completion.py @@ -9,6 +9,7 @@ from sqlalchemy import select +from app.dao.chat_message_dao import chat_message_dao from app.models.agent import Agent from app.models.agent_run import AgentRun from app.models.audit import ChatMessage @@ -206,7 +207,8 @@ async def _handle_gateway_result( select(ChatMessage.id).where(ChatMessage.id == receipt_id) ) if receipt_result.scalar_one_or_none() is None: - db.add( + chat_message_dao.add_scoped( + db, ChatMessage( id=receipt_id, agent_id=session.agent_id, @@ -217,7 +219,8 @@ async def _handle_gateway_result( participant_id=participant.id, mentions=[], created_at=now, - ) + ), + tenant_id=run.tenant_id, ) inbound.status = "completed" inbound.result = content @@ -374,7 +377,8 @@ async def handle( ) now = self._clock() - db.add( + chat_message_dao.add_scoped( + db, ChatMessage( id=receipt_id, agent_id=session.agent_id, @@ -385,7 +389,8 @@ async def handle( participant_id=participant.id, mentions=[], created_at=now, - ) + ), + tenant_id=run.tenant_id, ) session.last_message_at = now diff --git a/backend/app/services/agent_runtime/a2a_runtime.py b/backend/app/services/agent_runtime/a2a_runtime.py index 1754e492d..488e3f6b6 100644 --- a/backend/app/services/agent_runtime/a2a_runtime.py +++ b/backend/app/services/agent_runtime/a2a_runtime.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import Settings, get_settings +from app.dao.chat_message_dao import chat_message_dao from app.core.permissions import ( evaluate_agent_relationship_status, evaluate_roster_agent_visibility, @@ -622,7 +623,8 @@ async def enqueue_gateway_a2a_runtime( ) chat_message = await db.get(ChatMessage, chat_message_id) if chat_message is None: - db.add( + chat_message_dao.add_scoped( + db, ChatMessage( id=chat_message_id, agent_id=session.agent_id, @@ -632,7 +634,8 @@ async def enqueue_gateway_a2a_runtime( conversation_id=str(session.id), participant_id=source_participant_id, mentions=[], - ) + ), + tenant_id=tenant_id, ) elif ( chat_message.conversation_id != str(session.id) @@ -852,7 +855,8 @@ async def execute( message_id = _input_message_id(source_run_id, tool_call_id) message = await db.get(ChatMessage, message_id) if message is None: - db.add( + chat_message_dao.add_scoped( + db, ChatMessage( id=message_id, agent_id=session.agent_id, @@ -862,7 +866,8 @@ async def execute( conversation_id=str(session.id), participant_id=source_participant_id, mentions=[], - ) + ), + tenant_id=tenant_id, ) elif ( message.conversation_id != str(session.id) diff --git a/backend/app/services/agent_runtime/chat_intake.py b/backend/app/services/agent_runtime/chat_intake.py index bf82d0ce7..ad69861e2 100644 --- a/backend/app/services/agent_runtime/chat_intake.py +++ b/backend/app/services/agent_runtime/chat_intake.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import Settings, get_settings +from app.dao.chat_message_dao import chat_message_dao from app.models.agent import Agent from app.models.agent_run import AgentRun from app.models.agent_run_command import AgentRunCommand @@ -442,7 +443,7 @@ async def _persist_user_message( mentions=[], created_at=now, ) - db.add(message) + chat_message_dao.add_scoped(db, message, tenant_id=session.tenant_id) elif ( existing.agent_id != (None if session.session_type == "group" else agent.id) or existing.user_id != (None if session.session_type == "group" else user.id) diff --git a/backend/app/services/agent_runtime/delivery.py b/backend/app/services/agent_runtime/delivery.py index fd4ca77b5..6b63740eb 100644 --- a/backend/app/services/agent_runtime/delivery.py +++ b/backend/app/services/agent_runtime/delivery.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.logging_config import get_trace_id +from app.dao.chat_message_dao import chat_message_dao from app.models.agent import Agent from app.models.agent_run import AgentRun from app.models.agent_run_event import AgentRunEvent @@ -916,7 +917,7 @@ async def deliver_runtime_message( mentions=[], created_at=now(), ) - db.add(message) + chat_message_dao.add_scoped(db, message, tenant_id=run.tenant_id) session.last_message_at = now() channel_delivery = stage_channel_delivery( db, diff --git a/backend/app/services/agent_runtime/trigger_completion.py b/backend/app/services/agent_runtime/trigger_completion.py index ccf55ac56..c41fbfc18 100644 --- a/backend/app/services/agent_runtime/trigger_completion.py +++ b/backend/app/services/agent_runtime/trigger_completion.py @@ -9,6 +9,7 @@ from sqlalchemy import select +from app.dao.chat_message_dao import chat_message_dao from app.models.agent_run import AgentRun from app.models.audit import ChatMessage from app.models.chat_session import ChatSession @@ -165,7 +166,8 @@ async def handle( execution.lease_owner = None execution.lease_expires_at = None execution.last_error = None if status == "completed" else detail - db.add( + chat_message_dao.add_scoped( + db, ChatMessage( id=receipt_id, agent_id=stored_run.agent_id, @@ -176,7 +178,8 @@ async def handle( participant_id=session.participant_id, mentions=[], created_at=now, - ) + ), + tenant_id=run.tenant_id, ) session.last_message_at = now await db.flush() diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index 0224cdf65..27e0ff386 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -39,6 +39,7 @@ evaluate_roster_human_visibility, ) from app.database import async_session +from app.dao.chat_session_dao import chat_session_dao from app.models.agent import Agent as AgentModel from app.models.audit import ChatMessage from app.models.chat_session import ChatSession @@ -73,6 +74,12 @@ from app.services.storage import get_storage_backend, normalize_storage_key from app.services.storage_runtime.base import WriteCondition, content_hash_bytes from app.services.workspace_locking import workspace_locks +from app.services.sandbox.execution_lease import SandboxExecutionLeaseStore +from app.services.sandbox.workspace_policy import ( + SandboxExecutionScope, + build_workspace_policy, + parse_canonical_uuid, +) from app.config import get_settings from app.services.llm.finish import ( FINISH_TOOL_NAME, @@ -1336,9 +1343,15 @@ class TempWorkspace: root: Path agent_id: uuid.UUID tenant_id: str | None - selected_paths: list[str] + materialized_paths: list[str] + publish_paths: list[str] manifest: dict[str, TempWorkspaceManifestEntry] + @property + def selected_paths(self) -> list[str]: + """Backward-compatible alias for callers that use one path set.""" + return self.materialized_paths + def cleanup(self) -> None: self.temp_dir.cleanup() @@ -1368,6 +1381,7 @@ async def _prepare_temp_workspace( agent_id: uuid.UUID, tenant_id: str | None = None, paths: list[str] | None = None, + publish_paths: list[str] | None = None, ) -> TempWorkspace: tmp = tempfile.TemporaryDirectory(prefix=f"clawith-agent-{str(agent_id)[:8]}-") temp_ws = Path(tmp.name) @@ -1388,7 +1402,8 @@ async def _prepare_temp_workspace( root=temp_ws, agent_id=agent_id, tenant_id=tenant_id, - selected_paths=list(selected), + materialized_paths=list(selected), + publish_paths=list(selected if publish_paths is None else publish_paths), manifest=manifest, ) @@ -1465,7 +1480,7 @@ async def _sync_tasks_to_file(agent_id: uuid.UUID, ws: Path): async def flush_temp_workspace(temp_workspace: TempWorkspace, conflict_mode: str = "fail") -> dict[str, list[str]]: """Flush local changes back to storage using manifest-based conflict checks.""" storage = get_storage_backend() - selected_paths = [normalize_workspace_path(path) for path in temp_workspace.selected_paths] + selected_paths = [normalize_workspace_path(path) for path in temp_workspace.publish_paths] manifest = temp_workspace.manifest local_files = _collect_temp_workspace_files(temp_workspace.root, selected_paths) @@ -1474,7 +1489,11 @@ async def flush_temp_workspace(temp_workspace: TempWorkspace, conflict_mode: str deleted: list[str] = [] skipped: list[str] = [] - async with workspace_locks(temp_workspace.agent_id, selected_paths): + async with workspace_locks( + temp_workspace.agent_id, + selected_paths, + tenant_id=temp_workspace.tenant_id, + ): for rel_path, local_path in local_files.items(): if local_path.name.startswith("_exec_tmp") or "__pycache__" in local_path.parts: continue @@ -1503,6 +1522,11 @@ async def flush_temp_workspace(temp_workspace: TempWorkspace, conflict_mode: str updated.append(rel_path) for rel_path, entry in manifest.items(): + if not any( + rel_path == selected or rel_path.startswith(selected.rstrip("/") + "/") + for selected in selected_paths + ): + continue if rel_path in local_files: continue result = await storage.delete_if_match( @@ -1528,15 +1552,20 @@ def _collect_temp_workspace_files(root: Path, selected_paths: list[str]) -> dict target = (root_resolved / selected).resolve() if not target.is_relative_to(root_resolved): continue + if (root_resolved / selected).is_symlink(): + continue if target.is_file(): files[normalize_workspace_path(selected)] = target continue if not target.exists() or not target.is_dir(): continue for path in target.rglob("*"): - if not path.is_file(): + if path.is_symlink() or not path.is_file(): + continue + resolved = path.resolve() + if not resolved.is_relative_to(target): continue - rel = path.resolve().relative_to(root_resolved).as_posix() + rel = resolved.relative_to(root_resolved).as_posix() files[normalize_workspace_path(rel)] = path return files @@ -1678,6 +1707,217 @@ async def _run_with_temp_workspace_outcome( temp_workspace.cleanup() +async def _resolve_sandbox_execution_scope( + *, + tenant_id: str | None, + agent_id: uuid.UUID, + session_id: str, +) -> SandboxExecutionScope: + if not tenant_id: + raise ValueError("Session sandbox execution requires a tenant") + tenant_uuid = parse_canonical_uuid(tenant_id, label="tenant_id") + session_uuid = parse_canonical_uuid(session_id, label="session_id") + chat_session = await chat_session_dao.get_active_for_agent( + tenant_id=tenant_uuid, + agent_id=agent_id, + session_id=session_uuid, + ) + if chat_session is None: + raise ValueError("Session does not belong to the tenant and Agent") + return SandboxExecutionScope(tenant_uuid, agent_id, session_uuid) + + +async def _execute_code_with_workspace_outcome( + *, + agent_id: uuid.UUID, + tenant_id: str | None, + session_id: str, + arguments: dict, + tool_name: str, + on_output=None, +) -> ToolExecutionOutcome: + """Resolve policy once and guard materialize/execute/publish for local Session code.""" + if tool_name == "execute_code_e2b": + return await _run_with_temp_workspace_outcome( + agent_id, + tenant_id, + lambda temp_ws: _execute_code_outcome( + agent_id, + temp_ws, + arguments, + tool_name=tool_name, + on_output=on_output, + ), + sync_back=True, + sync_back_on_non_success=True, + ) + + from app.config import get_sandbox_config + from app.services.sandbox.config import SandboxConfig + + tool_config = await _get_tool_config(agent_id, tool_name) + fallback_config = get_sandbox_config() + sandbox_config = ( + SandboxConfig.from_dict(tool_config, fallback_config) + if tool_config and tool_name == "execute_code" + else None + ) + if sandbox_config is None: + sandbox_config = fallback_config + + try: + session_uuid = parse_canonical_uuid(session_id, label="session_id") if session_id else None + policy = build_workspace_policy( + mode=sandbox_config.workspace_mode, + session_id=session_uuid, + default_paths=TEMP_WORKSPACE_DEFAULT_PATHS, + ) + except ValueError as exc: + return _typed_failure(str(exc), "sandbox_session_required") + + scope: SandboxExecutionScope | None = None + if session_id: + try: + scope = await _resolve_sandbox_execution_scope( + tenant_id=tenant_id, + agent_id=agent_id, + session_id=session_id, + ) + except ValueError as exc: + return _typed_failure(str(exc), "sandbox_execution_scope_invalid") + + lease = None + if scope is not None: + try: + lease = await SandboxExecutionLeaseStore().acquire(scope, ttl_seconds=60) + except Exception: + return _typed_failure( + "Sandbox coordination is unavailable.", + "sandbox_coordination_unavailable", + retryable=True, + ) + if lease is None: + return _typed_failure( + "Another code execution is active for this Session.", + "sandbox_session_busy", + retryable=True, + ) + await lease.start_heartbeat() + + execution_started = False + gateway_flush_result: dict[str, list[str]] | None = None + try: + temp_workspace = await _prepare_temp_workspace( + agent_id, + tenant_id=tenant_id, + paths=list(policy.materialized_paths), + publish_paths=list(policy.publish_paths), + ) + if policy.session_output_path: + (temp_workspace.root / policy.session_output_path).mkdir(parents=True, exist_ok=True) + try: + execution_started = True + async def before_gateway_publish() -> bool: + if lease is None: + return True + try: + return await lease.ensure_publication_window(120) + except Exception: + return False + + async def gateway_publish() -> None: + nonlocal gateway_flush_result + gateway_flush_result = await asyncio.wait_for( + flush_temp_workspace(temp_workspace, conflict_mode="fail"), + timeout=60, + ) + if gateway_flush_result["conflicted"]: + raise RuntimeError("Gateway workspace publication conflicted") + + outcome = await _execute_code_outcome( + agent_id, + temp_workspace.root, + arguments, + tool_name=tool_name, + on_output=on_output, + sandbox_config=sandbox_config, + session_id=str(scope.session_id) if scope else None, + publish_paths=list(policy.publish_paths), + before_gateway_publish=before_gateway_publish, + gateway_publish=gateway_publish, + ) + if lease is not None and lease.ownership_lost: + return _typed_unknown( + "Code may have run after the Session execution lease was lost.", + "sandbox_execution_lease_lost", + ) + if sandbox_config.publication_owner == "gateway": + flush_result = gateway_flush_result or { + "updated": [], + "deleted": [], + "conflicted": [], + "skipped": [], + } + changed_refs = tuple( + _workspace_artifact_ref(agent_id, path) + for path in flush_result["updated"] + ) + return replace( + outcome, + artifact_refs=tuple(dict.fromkeys((*outcome.artifact_refs, *changed_refs))), + metadata={**outcome.metadata, "workspace_publication": flush_result}, + ) + if lease is not None and not await lease.ensure_publication_window(120): + return _typed_unknown( + "Code ran but publication ownership could not be verified.", + "sandbox_execution_lease_lost", + ) + try: + flush_result = await asyncio.wait_for( + flush_temp_workspace(temp_workspace, conflict_mode="fail"), + timeout=60, + ) + except Exception as exc: + return _typed_unknown( + f"Local execution completed but workspace sync is unknown: {type(exc).__name__}.", + "workspace_sync_outcome_unknown", + ) + metadata = {**outcome.metadata, "workspace_publication": flush_result} + if flush_result["conflicted"]: + return _typed_unknown( + "Local execution completed but workspace sync conflicted.", + "workspace_sync_conflict", + metadata=metadata, + ) + changed_refs = tuple( + _workspace_artifact_ref(agent_id, path) + for path in flush_result["updated"] + ) + return replace( + outcome, + artifact_refs=tuple(dict.fromkeys((*outcome.artifact_refs, *changed_refs))), + metadata=metadata, + ) + finally: + temp_workspace.cleanup() + except Exception as exc: + if execution_started: + return _typed_unknown( + f"Sandbox execution outcome is unknown after {type(exc).__name__}.", + "sandbox_execution_outcome_unknown", + ) + return _typed_failure( + f"Sandbox execution could not start: {type(exc).__name__}.", + "sandbox_execution_failed", + ) + finally: + if lease is not None: + try: + await asyncio.shield(lease.release()) + except Exception: + logger.exception("[SandboxLease] Failed to release Session execution lease") + + async def _execute_workspace_mutation( tool_name: str, arguments: dict, @@ -2632,18 +2872,13 @@ async def execute_builtin_tool_outcome( sync_back=True, ) if tool_name in {"execute_code", "execute_code_e2b"}: - return await _run_with_temp_workspace_outcome( - agent_id, - tenant_id, - lambda temp_ws: _execute_code_outcome( - agent_id, - temp_ws, - arguments, - tool_name=tool_name, - on_output=on_output, - ), - sync_back=True, - sync_back_on_non_success=True, + return await _execute_code_with_workspace_outcome( + agent_id=agent_id, + tenant_id=tenant_id, + session_id=session_id, + arguments=arguments, + tool_name=tool_name, + on_output=on_output, ) if tool_name == "read_webpage": return await _read_webpage_outcome(arguments) @@ -2882,6 +3117,7 @@ async def _execute_tool_direct( tool_name: str, arguments: dict, agent_id: uuid.UUID, + session_id: str = "", ) -> str: """Execute a tool directly, bypassing autonomy checks. @@ -2905,12 +3141,14 @@ async def _execute_tool_direct( tool_name, _observability_arguments(tool_name, arguments), ) - return await _run_with_temp_workspace( - agent_id, - _agent_tenant_id, - lambda temp_ws: _execute_code(agent_id, temp_ws, arguments, tool_name=tool_name), - sync_back=True, + outcome = await _execute_code_with_workspace_outcome( + agent_id=agent_id, + tenant_id=_agent_tenant_id, + session_id=session_id, + arguments=arguments, + tool_name=tool_name, ) + return _legacy_tool_outcome_text(outcome, fallback="Code execution returned no summary.") elif tool_name == "web_search": return await _web_search(arguments, agent_id) elif tool_name == "jina_search": @@ -3237,12 +3475,15 @@ async def execute_tool( tool_name, _observability_arguments(tool_name, arguments), ) - result = await _run_with_temp_workspace( - agent_id, - _agent_tenant_id, - lambda temp_ws: _execute_code(agent_id, temp_ws, arguments, tool_name=tool_name, on_output=on_output), - sync_back=True, + outcome = await _execute_code_with_workspace_outcome( + agent_id=agent_id, + tenant_id=_agent_tenant_id, + session_id=session_id, + arguments=arguments, + tool_name=tool_name, + on_output=on_output, ) + result = _legacy_tool_outcome_text(outcome, fallback="Code execution returned no summary.") elif tool_name == "upload_image": file_path = (arguments.get("file_path") or "").strip() result = await _run_with_temp_workspace( @@ -9919,6 +10160,11 @@ async def _execute_code_outcome( *, tool_name: str = "execute_code", on_output=None, + sandbox_config=None, + session_id: str | None = None, + publish_paths: list[str] | None = None, + before_gateway_publish=None, + gateway_publish=None, ) -> ToolExecutionOutcome: """Execute code using the configured sandbox backend. @@ -10008,7 +10254,7 @@ async def _execute_code_outcome( default_timeout=default_timeout, max_timeout=max_timeout, ) - else: + elif sandbox_config is None: # The default execute_code tool retains the established platform # fallback behavior; it is a distinct explicit tool contract. fallback_config = get_sandbox_config() @@ -10028,6 +10274,11 @@ async def _execute_code_outcome( timeout = min(requested_timeout, sandbox_config.max_timeout) backend = get_sandbox_backend(sandbox_config) + if sandbox_config.workspace_mode == "isolated_output" and getattr(backend, "name", None) != "subprocess": + return _typed_failure( + "The configured sandbox backend cannot enforce isolated Session output.", + "sandbox_workspace_mode_unsupported", + ) if is_e2b_tool: if getattr(backend, "name", None) != "e2b": return _typed_failure( @@ -10052,6 +10303,12 @@ async def _execute_code_outcome( work_dir=str(work_dir), on_output=on_output, agent_id=agent_id, + session_id=session_id, + workspace_mode=sandbox_config.workspace_mode, + publication_owner=sandbox_config.publication_owner, + publish_paths=publish_paths, + before_gateway_publish=before_gateway_publish, + gateway_publish=gateway_publish, ) try: @@ -10062,6 +10319,11 @@ async def _execute_code_outcome( if result.success and result.exit_code == 0 else f"Code execution failed with exit code {result.exit_code}." ) + if result.error and result.error.startswith("sandbox_publication_unknown:"): + return _typed_unknown( + "Code ran but Sandbox publication could not be proven.", + "workspace_sync_outcome_unknown", + ) if result.success and result.exit_code == 0: return _typed_success(summary) return _typed_failure( diff --git a/backend/app/services/autonomy_service.py b/backend/app/services/autonomy_service.py index ae48b7942..8a6151534 100644 --- a/backend/app/services/autonomy_service.py +++ b/backend/app/services/autonomy_service.py @@ -375,7 +375,15 @@ async def _execute_approved_action( # Import and call the tool's direct executor (no autonomy re-check) from app.services.agent_tools import _execute_tool_direct - result = await _execute_tool_direct(tool_name, arguments, agent_id) + approved_session_id = "" + if isinstance(runtime_scope, dict) and runtime_scope.get("session_id"): + approved_session_id = str(runtime_scope["session_id"]) + result = await _execute_tool_direct( + tool_name, + arguments, + agent_id, + session_id=approved_session_id, + ) return result except Exception as e: logger.error(f"Failed to execute approved action {tool_name}: {e}") diff --git a/backend/app/services/builtin_tool_definitions.py b/backend/app/services/builtin_tool_definitions.py index 83f6b0054..0116cfc05 100644 --- a/backend/app/services/builtin_tool_definitions.py +++ b/backend/app/services/builtin_tool_definitions.py @@ -983,11 +983,23 @@ "cpu_limit": "0.5", "memory_limit": "256m", "allow_network": True, + "workspace_mode": "merge", + "publication_owner": "workspace_cas", "default_timeout": 30, "max_timeout": 60, }, "config_schema": { "fields": [ + { + "key": "workspace_mode", + "label": "Workspace Write Mode", + "type": "select", + "default": "merge", + "options": [ + {"label": "Merge workspace changes", "value": "merge"}, + {"label": "Session output only", "value": "isolated_output"}, + ], + }, { "key": "cpu_limit", "label": "CPU Limit", diff --git a/backend/app/services/sandbox/config.py b/backend/app/services/sandbox/config.py index 54da3c788..1dcacb874 100644 --- a/backend/app/services/sandbox/config.py +++ b/backend/app/services/sandbox/config.py @@ -2,7 +2,7 @@ from loguru import logger from enum import Enum -from typing import Optional +from typing import Literal, Optional from pydantic import BaseModel, Field @@ -29,6 +29,8 @@ class SandboxConfig(BaseModel): memory_limit: str = "256m" allow_network: bool = True allow_unsafe_fallback_when_bwrap_missing: bool = False + workspace_mode: Literal["merge", "isolated_output"] = "merge" + publication_owner: Literal["gateway", "workspace_cas"] = "workspace_cas" # API sandbox options api_key: str = "" @@ -116,6 +118,8 @@ def get_value(key: str, default=None, encrypt: bool = False): "allow_unsafe_fallback_when_bwrap_missing", False, ), + workspace_mode=get_value("workspace_mode", "merge"), + publication_owner=get_value("publication_owner", "workspace_cas"), default_timeout=get_value("default_timeout", 30), max_timeout=get_value("max_timeout", 60), http_proxy=get_value("http_proxy", None), diff --git a/backend/app/services/sandbox/execution_lease.py b/backend/app/services/sandbox/execution_lease.py new file mode 100644 index 000000000..08499127c --- /dev/null +++ b/backend/app/services/sandbox/execution_lease.py @@ -0,0 +1,110 @@ +"""Redis-backed execution lease for one tenant/Agent/Session sandbox scope.""" + +from __future__ import annotations + +import asyncio +import hashlib +import os +import socket +import uuid +from contextlib import suppress + +from loguru import logger + +from app.core.events import get_redis +from app.services.sandbox.workspace_policy import SandboxExecutionScope + +_RENEW_SCRIPT = """ +if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('pexpire', KEYS[1], ARGV[2]) +end +return 0 +""" +_RELEASE_SCRIPT = """ +if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) +end +return 0 +""" +_EXECUTOR_INSTANCE_ID = f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4()}" + + +class SandboxExecutionLease: + def __init__(self, key: str, value: str, ttl_seconds: int) -> None: + self.key = key + self._value = value + self.ttl_seconds = ttl_seconds + self.ownership_lost = False + self._stop = asyncio.Event() + self._heartbeat_task: asyncio.Task[None] | None = None + + @property + def correlation_id(self) -> str: + return hashlib.sha256(self._value.encode()).hexdigest()[:12] + + async def _renew(self, seconds: int) -> bool: + try: + redis = await get_redis() + renewed = bool(await redis.eval(_RENEW_SCRIPT, 1, self.key, self._value, seconds * 1000)) + except Exception: + logger.exception("[SandboxLease] Renewal unverifiable key={}", self.key) + renewed = False + if not renewed: + self.ownership_lost = True + return renewed + + async def start_heartbeat(self) -> None: + if self._heartbeat_task is not None: + return + + async def heartbeat() -> None: + interval = max(1, self.ttl_seconds // 3) + while True: + try: + await asyncio.wait_for(self._stop.wait(), timeout=interval) + return + except asyncio.TimeoutError: + if not await self._renew(self.ttl_seconds): + return + + self._heartbeat_task = asyncio.create_task(heartbeat()) + + async def ensure_publication_window(self, seconds: int) -> bool: + self._stop.set() + if self._heartbeat_task is not None: + with suppress(asyncio.CancelledError): + await self._heartbeat_task + self._heartbeat_task = None + return await self._renew(seconds) + + async def release(self) -> None: + self._stop.set() + if self._heartbeat_task is not None: + self._heartbeat_task.cancel() + with suppress(asyncio.CancelledError): + await self._heartbeat_task + redis = await get_redis() + await asyncio.shield(redis.eval(_RELEASE_SCRIPT, 1, self.key, self._value)) + + +class SandboxExecutionLeaseStore: + @staticmethod + def key(scope: SandboxExecutionScope) -> str: + return ( + f"tenant:{scope.tenant_id}:sandbox-execution:" + f"{scope.agent_id}:{scope.session_id}" + ) + + async def acquire( + self, + scope: SandboxExecutionScope, + *, + ttl_seconds: int = 60, + ) -> SandboxExecutionLease | None: + key = self.key(scope) + value = f"v1|{_EXECUTOR_INSTANCE_ID}|{uuid.uuid4().hex}" + redis = await get_redis() + acquired = await redis.set(key, value, nx=True, px=ttl_seconds * 1000) + if not acquired: + return None + return SandboxExecutionLease(key, value, ttl_seconds) diff --git a/backend/app/services/sandbox/local/subprocess_backend.py b/backend/app/services/sandbox/local/subprocess_backend.py index 1b434cecd..1278c7ef6 100644 --- a/backend/app/services/sandbox/local/subprocess_backend.py +++ b/backend/app/services/sandbox/local/subprocess_backend.py @@ -5,6 +5,7 @@ import shutil import signal import time +import uuid from pathlib import Path from loguru import logger @@ -16,6 +17,8 @@ MAX_STDOUT_CAPTURE_BYTES = 1_000_000 MAX_STDERR_CAPTURE_BYTES = 500_000 VENV_CREATION_TIMEOUT_SECONDS = 120 +PROCESS_TERMINATION_GRACE_SECONDS = 5 +SANDBOX_VENV_PATH = "/opt/clawith/venv" # Security patterns - reused from agent_tools.py @@ -109,14 +112,14 @@ def __init__(self, config: SandboxConfig): self.config = config def _venv_python(self, venv_path: Path) -> str: - return "/workspace/.venv/bin/python" + return f"{SANDBOX_VENV_PATH}/bin/python" def _host_venv_python(self, work_path: Path) -> str: return str(work_path / ".venv" / "bin" / "python") def _build_command(self, language: str, script_path: str) -> list[str]: if language == "python": - return ["/workspace/.venv/bin/python", "-I", "-B", str(script_path)] + return [f"{SANDBOX_VENV_PATH}/bin/python", "-I", "-B", str(script_path)] if language == "bash": return ["bash", "--noprofile", "--norc", str(script_path)] return ["node", str(script_path)] @@ -166,6 +169,32 @@ def _bind_if_exists(self, host_path: str, guest_path: str | None = None, *, read bind_flag = "--ro-bind" if read_only else "--bind" return [bind_flag, str(host), target] + async def _terminate_and_reap_process(self, proc: asyncio.subprocess.Process) -> None: + """Terminate a subprocess group and wait until its direct child is reaped.""" + if proc.returncode is not None: + await proc.wait() + return + + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + except (ProcessLookupError, PermissionError): + proc.kill() + + try: + await asyncio.wait_for( + asyncio.shield(proc.wait()), + timeout=PROCESS_TERMINATION_GRACE_SECONDS, + ) + return + except asyncio.TimeoutError: + pass + + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + proc.kill() + await proc.wait() + async def _ensure_workspace_venv(self, venv_path: Path) -> None: venv_python = venv_path / "bin" / "python" if not venv_python.exists(): @@ -188,15 +217,7 @@ async def _ensure_workspace_venv(self, venv_path: Path) -> None: ) except (asyncio.TimeoutError, asyncio.CancelledError) as exc: if proc.returncode is None: - try: - os.killpg(os.getpgid(proc.pid), signal.SIGTERM) - except (ProcessLookupError, PermissionError): - proc.kill() - try: - await asyncio.wait_for(proc.wait(), timeout=5) - except asyncio.TimeoutError: - proc.kill() - await proc.wait() + await self._terminate_and_reap_process(proc) if isinstance(exc, asyncio.CancelledError): raise raise RuntimeError( @@ -215,9 +236,25 @@ async def _ensure_workspace_venv(self, venv_path: Path) -> None: self._fix_pip_shebangs(venv_path) def _fix_pip_shebangs(self, venv_path: Path) -> None: - """Replace pip with a bash wrapper that delegates to uv pip for extreme performance.""" + """Replace pip with a bash wrapper that proxies execution to the host if in a sandbox, else delegates to uv pip.""" venv_bin = venv_path / "bin" - wrapper_script = '#!/bin/bash\nexec uv pip "$@"\n' + wrapper_script = ( + "#!/bin/bash\n" + "if [ -d /workspace/.tmp ]; then\n" + " REQ_ID=$RANDOM\n" + " REQ_FILE=\"/workspace/.tmp/.pip_request_${REQ_ID}\"\n" + " RES_FILE=\"/workspace/.tmp/.pip_response_${REQ_ID}\"\n" + " echo \"$@\" > \"$REQ_FILE\"\n" + " while [ ! -f \"$RES_FILE\" ]; do\n" + " sleep 0.2\n" + " done\n" + " EXIT_CODE=$(cat \"$RES_FILE\")\n" + " rm -f \"$RES_FILE\"\n" + " exit $EXIT_CODE\n" + "else\n" + " exec uv pip \"$@\"\n" + "fi\n" + ) for pip_cmd in ["pip", "pip3", "pip3.12"]: pip_path = venv_bin / pip_cmd @@ -276,7 +313,14 @@ def _preexec(): return _preexec - def _build_bwrap_command(self, command: list[str], work_path: Path, venv_path: Path) -> list[str] | None: + def _build_bwrap_command( + self, + command: list[str], + work_path: Path, + venv_path: Path, + staging_path: Path | None = None, + writable_path: str | None = None, + ) -> list[str] | None: bwrap = shutil.which("bwrap") if not bwrap: if not SubprocessBackend._bwrap_missing_warned: @@ -296,6 +340,9 @@ def _build_bwrap_command(self, command: list[str], work_path: Path, venv_path: P + self._bind_if_exists("/etc") ) + actual_workspace_mount = str(staging_path) if staging_path else str(work_path) + workspace_mount_flag = "--bind" if staging_path and writable_path is None else "--ro-bind" + cmd = [ bwrap, "--die-with-parent", @@ -306,24 +353,36 @@ def _build_bwrap_command(self, command: list[str], work_path: Path, venv_path: P "--unshare-cgroup-try", *base_binds, "--bind", "/data/agents/.uv-cache", "/uv-cache", - "--bind", str(work_path), "/workspace", - "--bind", str(venv_path), "/workspace/.venv", + workspace_mount_flag, actual_workspace_mount, "/workspace", + ] + if staging_path is not None and writable_path is not None: + writable_host = (staging_path / writable_path).resolve() + if not writable_host.is_relative_to(staging_path.resolve()): + raise ValueError("Sandbox writable path escapes staging root") + writable_host.mkdir(parents=True, exist_ok=True) + cmd.extend([ + "--bind", str(staging_path / ".tmp"), "/workspace/.tmp", + "--bind", str(writable_host), f"/workspace/{writable_path}", + "--setenv", "CLAWITH_SESSION_OUTPUT_DIR", f"/workspace/{writable_path}", + ]) + cmd.extend([ + "--ro-bind", str(venv_path), SANDBOX_VENV_PATH, "--dev", "/dev", "--proc", "/proc", "--dir", "/tmp", "--setenv", "HOME", "/workspace", - "--setenv", "PATH", f"/workspace/.venv/bin:{os.environ.get('PATH', '/usr/bin:/bin')}", + "--setenv", "PATH", f"{SANDBOX_VENV_PATH}/bin:{os.environ.get('PATH', '/usr/bin:/bin')}", "--setenv", "TMPDIR", "/workspace/.tmp", "--setenv", "PYTHONDONTWRITEBYTECODE", "1", "--setenv", "PYTHONNOUSERSITE", "1", "--setenv", "NODE_PATH", "", "--setenv", "BASH_ENV", "", "--setenv", "ENV", "", - "--setenv", "VIRTUAL_ENV", "/workspace/.venv", + "--setenv", "VIRTUAL_ENV", SANDBOX_VENV_PATH, "--setenv", "PIP_CACHE_DIR", "/workspace/.tmp/pip-cache", "--setenv", "PIP_DISABLE_PIP_VERSION_CHECK", "1", "--setenv", "UV_CACHE_DIR", "/uv-cache", - ] + ]) http_proxy = self.config.http_proxy or os.environ.get("http_proxy") or os.environ.get("HTTP_PROXY") https_proxy = self.config.https_proxy or os.environ.get("https_proxy") or os.environ.get("HTTPS_PROXY") no_proxy = self.config.no_proxy or os.environ.get("no_proxy") or os.environ.get("NO_PROXY") @@ -352,6 +411,7 @@ def get_capabilities(self) -> SandboxCapabilities: async def health_check(self) -> bool: """Check if basic system commands are available.""" + proc: asyncio.subprocess.Process | None = None try: proc = await asyncio.create_subprocess_exec( "python3", "--version", @@ -363,6 +423,308 @@ async def health_check(self) -> bool: except Exception: return False + async def _watch_pip_requests(self, staging_path: Path, venv_path: Path, stop_event: asyncio.Event) -> None: + """Watch for pip request files in the staging directory's .tmp and execute them using uv on the host.""" + while not stop_event.is_set(): + try: + tmp_dir = staging_path / ".tmp" + if tmp_dir.exists(): + for request_file in tmp_dir.glob(".pip_request_*"): + if not request_file.exists(): + continue + try: + args_str = request_file.read_text(encoding="utf-8").strip() + except Exception: + continue + + req_id = request_file.name.split("_")[-1] + response_file = tmp_dir / f".pip_response_{req_id}" + if response_file.exists(): + continue + + args = args_str.split() + # Run host-side uv pip --python venv_path/bin/python + cmd = ["uv", "pip", "--python", str(venv_path / "bin" / "python")] + args + logger.info(f"[Subprocess Sandbox Host] Proxying pip command: {' '.join(cmd)}") + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await proc.wait() + exit_code = proc.returncode + except Exception as exc: + logger.error(f"[Subprocess Sandbox Host] Failed to run proxy pip: {exc}") + exit_code = 1 + + try: + response_file.write_text(str(exit_code), encoding="utf-8") + request_file.unlink(missing_ok=True) + except Exception as exc: + logger.error(f"[Subprocess Sandbox Host] Failed to write pip response: {exc}") + except Exception as exc: + logger.error(f"[Subprocess Sandbox Host] Error in pip watcher loop: {exc}") + await asyncio.sleep(0.2) + + async def _verify_and_merge_outputs( + self, + staging_path: Path, + target_workspace: Path, + agent_id: uuid.UUID | None = None, + session_id: str | None = None, + publish_paths: list[str] | None = None, + record_revisions: bool = False, + ) -> None: + """Scan staging directory, enforce safety checks, sanitize HTML/SVG, and merge to workspace with DB revisions.""" + import shutil + try: + from lxml.html.clean import Cleaner + import lxml.html + cleaner = Cleaner( + scripts=True, + javascript=True, + comments=True, + style=False, + links=False, + meta=True, + page_structure=False, + processing_instructions=True, + embedded=True, + frames=True, + forms=True, + kill_tags=['script', 'iframe', 'object', 'embed', 'applet'], + remove_unknown_tags=False, + safe_attrs_only=True, + ) + except ImportError: + cleaner = None + + max_allowed_files = 100 + max_total_size = 50 * 1024 * 1024 # 50 MB + max_single_file_size = 10 * 1024 * 1024 # 10 MB + + file_count = 0 + total_size = 0 + banned_suffixes = {".py", ".sh", ".js", ".elf", ".exe", ".so", ".dylib", ".dll", ".bat", ".cmd"} + protected_files = {"soul.md", "tasks.json", "tasks.json.bak", "enterprise_info"} + + allowed_roots = tuple(Path(path) for path in (publish_paths or [""])) + + def is_allowed(relative_path: Path) -> bool: + return any(root == Path("") or relative_path == root or root in relative_path.parents for root in allowed_roots) + + # Collect files in staging + staging_files: dict[Path, Path] = {} + for root, dirs, files in os.walk(staging_path): + dirs[:] = [d for d in dirs if d not in (".venv", ".tmp")] + for file in files: + file_path = Path(root) / file + relative_path = file_path.relative_to(staging_path) + if ( + file.startswith("_exec_tmp") + or file.startswith(".pip_") + or ".tmp" in relative_path.parts + or not is_allowed(relative_path) + or file_path.is_symlink() + ): + continue + staging_files[relative_path] = file_path + + # Collect files in target_workspace + target_files: dict[Path, Path] = {} + for root, dirs, files in os.walk(target_workspace): + dirs[:] = [d for d in dirs if d not in (".venv", ".tmp")] + for file in files: + file_path = Path(root) / file + relative_path = file_path.relative_to(target_workspace) + if ( + file.startswith("_exec_tmp") + or file.startswith(".pip_") + or ".tmp" in relative_path.parts + or not is_allowed(relative_path) + or file_path.is_symlink() + ): + continue + target_files[relative_path] = file_path + + # Quota checks + for rel_path, file_path in staging_files.items(): + file_count += 1 + if file_count > max_allowed_files: + raise RuntimeError(f"Sandbox generated too many files (limit: {max_allowed_files})") + try: + file_size = file_path.stat().st_size + except FileNotFoundError: + continue + total_size += file_size + if total_size > max_total_size: + raise RuntimeError(f"Sandbox generated files exceeding total size limit (limit: {max_total_size} bytes)") + if file_size > max_single_file_size: + raise RuntimeError(f"File '{rel_path}' exceeds single file size limit ({max_single_file_size} bytes)") + + # Dynamic imports for database revisions + write_workspace_file = None + delete_workspace_file = None + async_session = None + if agent_id and record_revisions: + try: + from app.database import async_session + from app.services.workspace_collaboration import write_workspace_file, delete_workspace_file + except ImportError: + pass + + # 1. Process Created and Modified Files + for rel_path, file_path in staging_files.items(): + rel_path_str = str(rel_path) + + # Check protected system files + if rel_path_str in protected_files: + target_file = target_files.get(rel_path) + if not target_file: + logger.warning(f"[Sandbox Gateway] Blocked attempt to create protected file: {rel_path}") + continue + try: + if file_path.read_bytes() != target_file.read_bytes(): + logger.warning(f"[Sandbox Gateway] Blocked attempt to modify protected file: {rel_path}") + continue + except Exception: + continue + + # Check banned extension + if file_path.suffix.lower() in banned_suffixes: + logger.warning(f"[Sandbox Gateway] Blocked banned file extension: {rel_path}") + continue + + # Check if modified + is_new = rel_path not in target_files + is_modified = False + if not is_new: + try: + is_modified = file_path.read_bytes() != target_files[rel_path].read_bytes() + except Exception: + is_modified = True + + if not is_new and not is_modified: + continue + + # Sanitize HTML/SVG if cleaner is available + if file_path.suffix.lower() in (".html", ".svg"): + try: + content = file_path.read_text(encoding="utf-8") + if cleaner: + try: + doc = lxml.html.fragment_fromstring(content, create_parent='div') + clean_doc = cleaner.clean_html(doc) + cleaned = lxml.html.tostring(clean_doc, encoding="utf-8").decode("utf-8") + if cleaned.startswith("
") and cleaned.endswith("
"): + cleaned = cleaned[5:-6] + except Exception: + cleaned = cleaner.clean_html(content) + else: + import re + cleaned = re.sub(r")<[^<]*)*<\/script>", "", content, flags=re.IGNORECASE) + cleaned = re.sub(r"\bon[a-z]+\s*=\s*\"[^\"]*\"", "", cleaned, flags=re.IGNORECASE) + cleaned = re.sub(r"\bon[a-z]+\s*=\s*'[^']*'", "", cleaned, flags=re.IGNORECASE) + + file_path.write_text(cleaned, encoding="utf-8") + except Exception as e: + logger.error(f"[Sandbox Gateway] Failed to sanitize file '{rel_path}': {e}") + continue + + # Read content for revision + try: + file_content = file_path.read_text(encoding="utf-8") + except UnicodeDecodeError: + file_content = None + + # Copy verified file to workspace + dest_path = target_workspace / rel_path + dest_path.parent.mkdir(parents=True, exist_ok=True) + try: + shutil.copy2(file_path, dest_path) + except Exception as e: + logger.error(f"[Sandbox Gateway] Failed to copy '{rel_path}' to workspace: {e}") + continue + + # Record DB revision + if agent_id and write_workspace_file and async_session and file_content is not None: + try: + async with async_session() as db: + await write_workspace_file( + db, + agent_id=agent_id, + base_dir=target_workspace, + path=rel_path_str, + content=file_content, + actor_type="agent", + actor_id=agent_id, + session_id=session_id, + enforce_human_lock=True, + ) + await db.commit() + except Exception as e: + raise RuntimeError( + f"Gateway publication failed for '{rel_path}'" + ) from e + + # 2. Process Deleted Files + for rel_path, target_path in target_files.items(): + if rel_path in staging_files: + continue + rel_path_str = str(rel_path) + + if rel_path_str in protected_files: + logger.warning(f"[Sandbox Gateway] Blocked attempt to delete protected file: {rel_path}") + try: + shutil.copy2(target_path, staging_path / rel_path) + except Exception: + pass + continue + + try: + target_path.unlink(missing_ok=True) + except Exception as e: + logger.error(f"[Sandbox Gateway] Failed to delete local file '{rel_path}': {e}") + continue + + # Record DB deletion + if agent_id and delete_workspace_file and async_session: + try: + async with async_session() as db: + await delete_workspace_file( + db, + agent_id=agent_id, + base_dir=target_workspace, + path=rel_path_str, + actor_type="agent", + actor_id=agent_id, + session_id=session_id, + enforce_human_lock=True, + ) + await db.commit() + except Exception as e: + raise RuntimeError( + f"Gateway deletion failed for '{rel_path}'" + ) from e + + def _clone_workspace_to_staging(self, source: Path, dest: Path) -> None: + """Clone all workspace files to staging area, ignoring virtualenv and tmp folders.""" + import shutil + dest.mkdir(parents=True, exist_ok=True) + if not source.exists(): + return + for item in source.iterdir(): + if item.name in (".venv", ".tmp"): + continue + if item.is_file(): + if item.name.startswith("_exec_tmp"): + continue + shutil.copy2(item, dest / item.name) + elif item.is_dir(): + shutil.copytree(item, dest / item.name, symlinks=True, dirs_exist_ok=True) + async def execute( self, code: str, @@ -372,9 +734,17 @@ async def execute( **kwargs ) -> ExecutionResult: """Execute code in a subprocess.""" + import uuid on_output = kwargs.get("on_output") agent_id = kwargs.get("agent_id") + session_id = kwargs.get("session_id") + workspace_mode = kwargs.get("workspace_mode", "merge") + publication_owner = kwargs.get("publication_owner", "workspace_cas") + publish_paths = kwargs.get("publish_paths") + before_gateway_publish = kwargs.get("before_gateway_publish") + gateway_publish = kwargs.get("gateway_publish") start_time = time.time() + proc: asyncio.subprocess.Process | None = None # Validate language if language not in ("python", "bash", "node"): @@ -418,14 +788,17 @@ async def execute( work_path.mkdir(parents=True, exist_ok=True) (work_path / ".tmp").mkdir(parents=True, exist_ok=True) (work_path / ".tmp" / "pip-cache").mkdir(parents=True, exist_ok=True) + + # Setup staging directory for secure output isolation + staging_id = str(uuid.uuid4()) + staging_path = work_path / ".tmp" / f"staging_{staging_id}" + self._clone_workspace_to_staging(work_path, staging_path) + (staging_path / ".tmp").mkdir(parents=True, exist_ok=True) # Determine persistent venv path if possible if agent_id: - # We place the virtual environment in a persistent location venv_path = Path("/data/agents").resolve() / str(agent_id) / ".venv" venv_path.parent.mkdir(parents=True, exist_ok=True) - - # Ensure global uv cache exists uv_cache = Path("/data/agents/.uv-cache") uv_cache.mkdir(parents=True, exist_ok=True) else: @@ -439,17 +812,31 @@ async def execute( elif language == "node": ext = ".js" - # Write code to temp file - script_path = work_path / f"_exec_tmp{ext}" + # Write code to temp file inside real work_path (read-only bound to guest /workspace via staging copy) + # Note: script_path must be written inside staging_path so sandbox can see and run it! + script_path = staging_path / ".tmp" / f"_exec_tmp{ext}" try: await self._ensure_workspace_venv(venv_path) script_path.write_text(code, encoding="utf-8") - sandbox_command = self._build_command(language, f"/workspace/{script_path.name}") - bwrap_command = self._build_bwrap_command(sandbox_command, work_path, venv_path) + # Start background task to watch for pip requests + pip_stop_event = asyncio.Event() + pip_watcher_task = asyncio.create_task( + self._watch_pip_requests(staging_path, venv_path, pip_stop_event) + ) + + sandbox_command = self._build_command(language, f"/workspace/.tmp/{script_path.name}") + writable_path = publish_paths[0] if workspace_mode == "isolated_output" and publish_paths else None + bwrap_command = self._build_bwrap_command( + sandbox_command, + work_path, + venv_path, + staging_path=staging_path, + writable_path=writable_path, + ) if not bwrap_command: - if not self.config.allow_unsafe_fallback_when_bwrap_missing: + if workspace_mode == "isolated_output" or not self.config.allow_unsafe_fallback_when_bwrap_missing: duration_ms = int((time.time() - start_time) * 1000) return ExecutionResult( success=False, @@ -464,14 +851,15 @@ async def execute( ), ) - host_command = self._build_host_command(language, script_path, work_path) + # Fallback path runs on host script inside staging_path to prevent polluting real workspace + host_command = self._build_host_command(language, script_path, staging_path) logger.warning( "[Subprocess] bubblewrap missing; using local fallback without filesystem isolation" ) proc = await asyncio.create_subprocess_exec( *host_command, - cwd=str(work_path), - **self._build_exec_kwargs(work_path, timeout, use_preexec=True), + cwd=str(staging_path), + **self._build_exec_kwargs(staging_path, timeout, use_preexec=True), ) else: proc = await asyncio.create_subprocess_exec( @@ -492,7 +880,6 @@ async def read_stream(stream, out, label="stdout"): remaining = capture_limit - len(out) if remaining > 0: out.extend(chunk[:remaining]) - # Real-time streaming: push each chunk to the WebSocket if on_output: try: text = chunk.decode("utf-8", errors="replace") @@ -505,13 +892,10 @@ async def read_stream(stream, out, label="stdout"): is_timeout = False try: - await asyncio.wait_for(proc.wait(), timeout=timeout) + await asyncio.wait_for(asyncio.shield(proc.wait()), timeout=timeout) except asyncio.TimeoutError: - try: - os.killpg(os.getpgid(proc.pid), signal.SIGTERM) - except Exception: - proc.kill() is_timeout = True + await self._terminate_and_reap_process(proc) await asyncio.gather(task1, task2) stdout = bytes(stdout_data) @@ -522,6 +906,40 @@ async def read_stream(stream, out, label="stdout"): duration_ms = int((time.time() - start_time) * 1000) + # Stop pip watcher before verification + try: + pip_stop_event.set() + await pip_watcher_task + except Exception: + pass + + # Safe verification and merge of output files (run for both bwrap and fallback execution) + try: + if publication_owner == "gateway" and before_gateway_publish is not None: + if not await before_gateway_publish(): + raise RuntimeError("Sandbox publication ownership could not be verified") + await self._verify_and_merge_outputs( + staging_path, + work_path, + agent_id=agent_id, + session_id=session_id, + publish_paths=publish_paths, + record_revisions=False, + ) + if publication_owner == "gateway": + if gateway_publish is None: + raise RuntimeError("Gateway publication callback is missing") + await gateway_publish() + except Exception as exc: + return ExecutionResult( + success=False, + stdout=stdout_str, + stderr=stderr_str, + exit_code=1, + duration_ms=duration_ms, + error=f"sandbox_publication_unknown: {type(exc).__name__}" + ) + if is_timeout: return ExecutionResult( success=False, @@ -554,8 +972,31 @@ async def read_stream(stream, out, label="stdout"): ) finally: - # Clean up temp script - try: - script_path.unlink(missing_ok=True) - except Exception: - pass + if proc is not None and proc.returncode is None: + try: + await self._terminate_and_reap_process(proc) + except Exception: + logger.exception("[Subprocess] Failed to reap sandbox process during cleanup") + + # Stop the pip watcher task + if 'pip_stop_event' in locals() and 'pip_watcher_task' in locals(): + try: + pip_stop_event.set() + await pip_watcher_task + except Exception: + pass + + # Clean up temp script inside staging if not done + if 'script_path' in locals(): + try: + script_path.unlink(missing_ok=True) + except Exception: + pass + + # Clean up staging folder + if 'staging_path' in locals(): + try: + if staging_path.exists(): + shutil.rmtree(staging_path) + except Exception: + pass diff --git a/backend/app/services/sandbox/workspace_policy.py b/backend/app/services/sandbox/workspace_policy.py new file mode 100644 index 000000000..76699fc82 --- /dev/null +++ b/backend/app/services/sandbox/workspace_policy.py @@ -0,0 +1,65 @@ +"""Trusted workspace policy for local code execution.""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from typing import Literal + +from app.services.workspace_collaboration import normalize_workspace_path + +WorkspaceMode = Literal["merge", "isolated_output"] +PublicationOwner = Literal["gateway", "workspace_cas"] + + +@dataclass(frozen=True, slots=True) +class SandboxExecutionScope: + tenant_id: uuid.UUID + agent_id: uuid.UUID + session_id: uuid.UUID + + +@dataclass(frozen=True, slots=True) +class SandboxWorkspacePolicy: + mode: WorkspaceMode + session_id: uuid.UUID | None + materialized_paths: tuple[str, ...] + publish_paths: tuple[str, ...] + + @property + def session_output_path(self) -> str | None: + if self.session_id is None: + return None + return normalize_workspace_path(f"workspace/output/{self.session_id}") + + @property + def guest_output_path(self) -> str | None: + relative = self.session_output_path + return f"/workspace/{relative}" if relative else None + + +def parse_canonical_uuid(value: str | uuid.UUID, *, label: str) -> uuid.UUID: + try: + parsed = value if isinstance(value, uuid.UUID) else uuid.UUID(str(value)) + except (TypeError, ValueError, AttributeError) as exc: + raise ValueError(f"{label} must be a canonical UUID") from exc + if str(parsed) != str(value).lower(): + raise ValueError(f"{label} must be a canonical UUID") + return parsed + + +def build_workspace_policy( + *, + mode: WorkspaceMode, + session_id: uuid.UUID | None, + default_paths: list[str] | tuple[str, ...], +) -> SandboxWorkspacePolicy: + materialized = tuple(normalize_workspace_path(path) for path in default_paths) + if mode == "merge": + return SandboxWorkspacePolicy(mode, session_id, materialized, materialized) + if mode != "isolated_output": + raise ValueError("Unsupported sandbox workspace mode") + if session_id is None: + raise ValueError("isolated_output requires a Session") + output_path = normalize_workspace_path(f"workspace/output/{session_id}") + return SandboxWorkspacePolicy(mode, session_id, materialized, (output_path,)) diff --git a/backend/app/services/workspace_locking.py b/backend/app/services/workspace_locking.py index ceafb43f1..5f021d5d7 100644 --- a/backend/app/services/workspace_locking.py +++ b/backend/app/services/workspace_locking.py @@ -32,8 +32,10 @@ def _normalize_workspace_path(path: str) -> str: return "/".join(parts) -def _lock_key(agent_id: uuid.UUID, path: str) -> str: +def _lock_key(agent_id: uuid.UUID, path: str, tenant_id: uuid.UUID | str | None = None) -> str: normalized = _normalize_workspace_path(path) or "." + if tenant_id is not None: + return f"tenant:{tenant_id}:workspace-lock:{agent_id}:{normalized}" return f"{LOCK_PREFIX}:{agent_id}:{normalized}" @@ -42,15 +44,22 @@ async def acquire_workspace_lock( path: str, *, owner_token: str, + tenant_id: uuid.UUID | str | None = None, ttl_seconds: int = DEFAULT_LOCK_TTL_SECONDS, ) -> bool: redis = await get_redis() - return bool(await redis.set(_lock_key(agent_id, path), owner_token, ex=ttl_seconds, nx=True)) + return bool(await redis.set(_lock_key(agent_id, path, tenant_id), owner_token, ex=ttl_seconds, nx=True)) -async def release_workspace_lock(agent_id: uuid.UUID, path: str, *, owner_token: str) -> None: +async def release_workspace_lock( + agent_id: uuid.UUID, + path: str, + *, + owner_token: str, + tenant_id: uuid.UUID | str | None = None, +) -> None: redis = await get_redis() - await redis.eval(_RELEASE_IF_OWNER_SCRIPT, 1, _lock_key(agent_id, path), owner_token) + await redis.eval(_RELEASE_IF_OWNER_SCRIPT, 1, _lock_key(agent_id, path, tenant_id), owner_token) @asynccontextmanager @@ -59,6 +68,7 @@ async def workspace_locks( paths: list[str], *, ttl_seconds: int = DEFAULT_LOCK_TTL_SECONDS, + tenant_id: uuid.UUID | str | None = None, ): normalized = sorted({_normalize_workspace_path(path) or "." for path in paths if path is not None}) owner_token = uuid.uuid4().hex @@ -69,6 +79,7 @@ async def workspace_locks( agent_id, path, owner_token=owner_token, + tenant_id=tenant_id, ttl_seconds=ttl_seconds, ) if not ok: @@ -77,4 +88,4 @@ async def workspace_locks( yield finally: for path in reversed(acquired): - await release_workspace_lock(agent_id, path, owner_token=owner_token) + await release_workspace_lock(agent_id, path, owner_token=owner_token, tenant_id=tenant_id) diff --git a/backend/scripts/backfill_chat_message_tenant_id.py b/backend/scripts/backfill_chat_message_tenant_id.py new file mode 100644 index 000000000..9d9e1bada --- /dev/null +++ b/backend/scripts/backfill_chat_message_tenant_id.py @@ -0,0 +1,100 @@ +"""Backfill ChatMessage.tenant_id from its authoritative ChatSession. + +Usage from ``backend/``:: + + uv run python scripts/backfill_chat_message_tenant_id.py + uv run python scripts/backfill_chat_message_tenant_id.py --apply +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys + +from sqlalchemy import text + +_BACKEND_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if _BACKEND_ROOT not in sys.path: + sys.path.insert(0, _BACKEND_ROOT) + +from app.database import async_session # noqa: E402 + + +async def _counts() -> tuple[int, int]: + async with async_session() as db: + result = await db.execute( + text( + """ + SELECT + count(*) FILTER (WHERE s.tenant_id IS NOT NULL) AS resolvable, + count(*) FILTER (WHERE s.tenant_id IS NULL) AS unresolved + FROM chat_messages AS m + LEFT JOIN chat_sessions AS s ON s.id::text = m.conversation_id + WHERE m.tenant_id IS NULL + """ + ) + ) + row = result.one() + return int(row.resolvable), int(row.unresolved) + + +async def process_data(batch_size: int, apply: bool) -> int: + resolvable, unresolved = await _counts() + mode = "APPLY" if apply else "DRY-RUN" + print(f"mode={mode} resolvable={resolvable} unresolved={unresolved}") + if unresolved: + print("Refusing to continue: some tenant-less messages have no authoritative session tenant.") + return 1 + if not apply: + return 0 + + updated = 0 + while True: + async with async_session() as db: + result = await db.execute( + text( + """ + WITH batch AS ( + SELECT m.id, s.tenant_id + FROM chat_messages AS m + JOIN chat_sessions AS s ON s.id::text = m.conversation_id + WHERE m.tenant_id IS NULL + AND s.tenant_id IS NOT NULL + ORDER BY m.id + LIMIT :batch_size + ) + UPDATE chat_messages AS m + SET tenant_id = batch.tenant_id + FROM batch + WHERE m.id = batch.id + RETURNING m.id + """ + ), + {"batch_size": batch_size}, + ) + batch_count = len(result.all()) + await db.commit() + updated += batch_count + print(f"updated={updated}") + if batch_count < batch_size: + break + + remaining, unresolved = await _counts() + print(f"complete updated={updated} remaining_resolvable={remaining} unresolved={unresolved}") + return 0 if remaining == 0 and unresolved == 0 else 1 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--batch-size", type=int, default=500) + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + if args.batch_size <= 0: + parser.error("--batch-size must be positive") + return asyncio.run(process_data(args.batch_size, args.apply)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/test_agent_runtime_a2a_completion.py b/backend/tests/test_agent_runtime_a2a_completion.py index a95b7c5ae..cb9cefcd4 100644 --- a/backend/tests/test_agent_runtime_a2a_completion.py +++ b/backend/tests/test_agent_runtime_a2a_completion.py @@ -272,6 +272,7 @@ async def resume_source(command): assert len(db.added) == 1 message = db.added[0] assert isinstance(message, ChatMessage) + assert message.tenant_id == run.tenant_id assert message.id == uuid.uuid5( run.run_id, "a2a-terminal:target-terminal", diff --git a/backend/tests/test_agent_runtime_trigger_completion.py b/backend/tests/test_agent_runtime_trigger_completion.py index 838585b26..c4909fb4a 100644 --- a/backend/tests/test_agent_runtime_trigger_completion.py +++ b/backend/tests/test_agent_runtime_trigger_completion.py @@ -206,6 +206,7 @@ async def test_completed_checkpoint_settles_execution_and_reflection_once() -> N assert len(db.added) == 1 message = db.added[0] assert isinstance(message, ChatMessage) + assert message.tenant_id == run.tenant_id assert message.id == uuid.uuid5( run.run_id, "trigger-terminal:checkpoint-terminal", diff --git a/backend/tests/test_agent_tools_storage_workspace.py b/backend/tests/test_agent_tools_storage_workspace.py index 3d27566aa..d660be81b 100644 --- a/backend/tests/test_agent_tools_storage_workspace.py +++ b/backend/tests/test_agent_tools_storage_workspace.py @@ -344,6 +344,34 @@ async def test_flush_temp_workspace_fails_on_conflict(monkeypatch): assert storage.files[f"{agent_id}/workspace/input.md"] == b"# Remote change\n" +@pytest.mark.asyncio +async def test_flush_temp_workspace_filters_manifest_deletions_to_publish_paths(monkeypatch): + agent_id = uuid.uuid4() + session_id = uuid.uuid4() + session_path = f"workspace/output/{session_id}" + storage = MemoryStorageBackend({ + f"{agent_id}/workspace/read-only.md": b"keep", + f"{agent_id}/{session_path}/result.txt": b"delete-me", + }) + monkeypatch.setattr(agent_tools, "get_storage_backend", lambda: storage) + + temp_ws = await agent_tools._prepare_temp_workspace( + agent_id, + tenant_id=str(uuid.uuid4()), + paths=["workspace"], + publish_paths=[session_path], + ) + try: + (temp_ws.root / session_path / "result.txt").unlink() + (temp_ws.root / "workspace" / "read-only.md").write_text("changed", encoding="utf-8") + result = await agent_tools.flush_temp_workspace(temp_ws) + finally: + temp_ws.cleanup() + + assert result["deleted"] == [f"{session_path}/result.txt"] + assert storage.files[f"{agent_id}/workspace/read-only.md"] == b"keep" + + @pytest.mark.asyncio async def test_write_workspace_file_fails_on_expected_version_conflict(monkeypatch, tmp_path): agent_id = uuid.uuid4() diff --git a/backend/tests/test_api_database_dependencies.py b/backend/tests/test_api_database_dependencies.py new file mode 100644 index 000000000..22c6af945 --- /dev/null +++ b/backend/tests/test_api_database_dependencies.py @@ -0,0 +1,17 @@ +"""Regression checks for FastAPI database-session dependency injection.""" + +from fastapi.routing import APIRoute + +from app.main import app + + +def test_database_session_is_never_exposed_as_a_query_parameter() -> None: + """A missing Depends(get_db) silently turns ``db`` into an optional query parameter.""" + offenders = sorted( + f"{','.join(sorted(route.methods or []))} {route.path}" + for route in app.routes + if isinstance(route, APIRoute) + and any(parameter.name == "db" for parameter in route.dependant.query_params) + ) + + assert offenders == [], "Routes with an un-injected db parameter:\n" + "\n".join(offenders) diff --git a/backend/tests/test_base_dao.py b/backend/tests/test_base_dao.py index 71dcfe03b..4e4d75a43 100644 --- a/backend/tests/test_base_dao.py +++ b/backend/tests/test_base_dao.py @@ -5,7 +5,7 @@ from sqlalchemy import String, create_engine, select from sqlalchemy.orm import Mapped, Session, mapped_column -from app.dao.base import BaseDAO, tenant_context +from app.dao.base import BaseDAO, TenantScopedBaseDAO, tenant_context from app.database import Base, _session_ctx @@ -136,3 +136,38 @@ def test_orm_session_injects_tenant_filter_for_direct_queries(): records = session.scalars(select(TenantScopedRecord).order_by(TenantScopedRecord.id)).all() assert [record.id for record in records] == ["a"] + + +def test_scoped_write_injects_tenant_from_context(): + tenant_id = uuid.uuid4() + record = TenantScopedRecord(id="new", tenant_id=None) + session = RecordingSession() + + with tenant_context(tenant_id): + TenantScopedBaseDAO(TenantScopedRecord).add_scoped(session, record) + + assert record.tenant_id == tenant_id + assert session.added == [record] + + +def test_scoped_write_accepts_explicit_tenant_without_context(): + tenant_id = uuid.uuid4() + record = TenantScopedRecord(id="new", tenant_id=None) + + TenantScopedBaseDAO(TenantScopedRecord).add_scoped(RecordingSession(), record, tenant_id=tenant_id) + + assert record.tenant_id == tenant_id + + +def test_scoped_write_rejects_tenant_mismatch(): + record = TenantScopedRecord(id="new", tenant_id=uuid.uuid4()) + + with tenant_context(uuid.uuid4()), pytest.raises(RuntimeError, match="Object tenant_id"): + TenantScopedBaseDAO(TenantScopedRecord).add_scoped(RecordingSession(), record) + + +def test_scoped_write_rejects_missing_tenant(): + record = TenantScopedRecord(id="new", tenant_id=None) + + with pytest.raises(RuntimeError, match="require a tenant_id"): + TenantScopedBaseDAO(TenantScopedRecord).add_scoped(RecordingSession(), record) diff --git a/backend/tests/test_llm_model_tenant_scope.py b/backend/tests/test_llm_model_tenant_scope.py index 0f606856c..a86ee3ffc 100644 --- a/backend/tests/test_llm_model_tenant_scope.py +++ b/backend/tests/test_llm_model_tenant_scope.py @@ -1,10 +1,15 @@ import uuid from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import HTTPException -from app.api.enterprise import _llm_management_tenant_id, _llm_model_scope +from app.api.enterprise import ( + _llm_management_tenant_id, + _llm_model_scope, + list_llm_models, +) def _user(tenant_id: uuid.UUID, role: str = "org_admin") -> SimpleNamespace: @@ -26,6 +31,20 @@ def test_platform_admin_can_select_another_tenant_for_llm_models() -> None: assert _llm_management_tenant_id(_user(uuid.uuid4(), "platform_admin"), str(target_tenant_id)) == target_tenant_id +@pytest.mark.asyncio +async def test_list_llm_models_accepts_resolved_uuid_tenant_scope() -> None: + tenant_id = uuid.uuid4() + db = AsyncMock() + result = MagicMock() + result.scalars.return_value.all.return_value = [] + db.execute.return_value = result + + assert await list_llm_models(current_user=_user(tenant_id), db=db) == [] + + statement = db.execute.await_args.args[0] + assert tenant_id.hex in str(statement.compile(compile_kwargs={"literal_binds": True})) + + def test_org_admin_model_mutation_query_is_tenant_scoped() -> None: tenant_id = uuid.uuid4() statement = _llm_model_scope(uuid.uuid4(), _user(tenant_id)) diff --git a/backend/tests/test_sandbox_execution_policy.py b/backend/tests/test_sandbox_execution_policy.py new file mode 100644 index 000000000..c3a3d2a1b --- /dev/null +++ b/backend/tests/test_sandbox_execution_policy.py @@ -0,0 +1,159 @@ +"""Contracts for Session-scoped sandbox policy and Redis execution leases.""" + +import uuid + +import pytest + +from app.services import agent_tools +from app.services.agent_runtime.tool_execution import ToolExecutionOutcome +from app.services.sandbox.config import SandboxConfig +from app.services.sandbox import execution_lease +from app.services.sandbox.execution_lease import SandboxExecutionLeaseStore +from app.services.sandbox.workspace_policy import ( + SandboxExecutionScope, + build_workspace_policy, + parse_canonical_uuid, +) + + +class FakeRedis: + def __init__(self) -> None: + self.values: dict[str, str] = {} + + async def set(self, key, value, *, nx=False, px=None): + if nx and key in self.values: + return False + self.values[key] = value + return True + + async def eval(self, script, _key_count, key, value, *args): + if self.values.get(key) != value: + return 0 + if "pexpire" in script: + return 1 + del self.values[key] + return 1 + + +def test_isolated_policy_uses_exact_session_output() -> None: + session_id = uuid.uuid4() + policy = build_workspace_policy( + mode="isolated_output", + session_id=session_id, + default_paths=["workspace", "memory", "skills"], + ) + + assert policy.publish_paths == (f"workspace/output/{session_id}",) + assert policy.guest_output_path == f"/workspace/workspace/output/{session_id}" + assert policy.materialized_paths == ("workspace", "memory", "skills") + + +def test_isolated_policy_requires_session() -> None: + with pytest.raises(ValueError, match="requires a Session"): + build_workspace_policy(mode="isolated_output", session_id=None, default_paths=["workspace"]) + + +def test_session_uuid_must_be_canonical() -> None: + value = uuid.uuid4() + assert parse_canonical_uuid(str(value), label="session_id") == value + with pytest.raises(ValueError, match="canonical UUID"): + parse_canonical_uuid("not-a-session", label="session_id") + + +@pytest.mark.asyncio +async def test_execution_lease_is_tenant_scoped_and_owner_only(monkeypatch) -> None: + redis = FakeRedis() + + async def fake_get_redis(): + return redis + + monkeypatch.setattr(execution_lease, "get_redis", fake_get_redis) + scope = SandboxExecutionScope(uuid.uuid4(), uuid.uuid4(), uuid.uuid4()) + store = SandboxExecutionLeaseStore() + + first = await store.acquire(scope) + second = await store.acquire(scope) + + assert first is not None + assert second is None + assert first.key.startswith(f"tenant:{scope.tenant_id}:sandbox-execution:") + assert await first.ensure_publication_window(120) is True + redis.values[first.key] = "foreign-owner" + assert await first.ensure_publication_window(120) is False + await first.release() + assert redis.values[first.key] == "foreign-owner" + + +@pytest.mark.asyncio +async def test_local_session_busy_fails_before_code(monkeypatch) -> None: + tenant_id = uuid.uuid4() + agent_id = uuid.uuid4() + session_id = uuid.uuid4() + executed = False + + async def tool_config(*_args): + return {"workspace_mode": "isolated_output"} + + async def resolve_scope(**_kwargs): + return SandboxExecutionScope(tenant_id, agent_id, session_id) + + async def busy(*_args, **_kwargs): + return None + + async def forbidden_execute(*_args, **_kwargs): + nonlocal executed + executed = True + return ToolExecutionOutcome("succeeded", "ok", None) + + monkeypatch.setattr(agent_tools, "_get_tool_config", tool_config) + monkeypatch.setattr(agent_tools, "_resolve_sandbox_execution_scope", resolve_scope) + monkeypatch.setattr(SandboxExecutionLeaseStore, "acquire", busy) + monkeypatch.setattr(agent_tools, "_execute_code_outcome", forbidden_execute) + monkeypatch.setattr( + "app.config.get_sandbox_config", + lambda: SandboxConfig(workspace_mode="merge"), + ) + + outcome = await agent_tools._execute_code_with_workspace_outcome( + agent_id=agent_id, + tenant_id=str(tenant_id), + session_id=str(session_id), + arguments={"language": "python", "code": "print(1)"}, + tool_name="execute_code", + ) + + assert outcome.status == "failed" + assert outcome.error_code == "sandbox_session_busy" + assert outcome.retryable is True + assert executed is False + + +@pytest.mark.asyncio +async def test_invalid_session_scope_fails_before_lease(monkeypatch) -> None: + acquired = False + + async def tool_config(*_args): + return {"workspace_mode": "isolated_output"} + + async def invalid_scope(**_kwargs): + raise ValueError("Session does not belong to the tenant and Agent") + + async def forbidden_acquire(*_args, **_kwargs): + nonlocal acquired + acquired = True + + monkeypatch.setattr(agent_tools, "_get_tool_config", tool_config) + monkeypatch.setattr(agent_tools, "_resolve_sandbox_execution_scope", invalid_scope) + monkeypatch.setattr(SandboxExecutionLeaseStore, "acquire", forbidden_acquire) + monkeypatch.setattr("app.config.get_sandbox_config", lambda: SandboxConfig()) + + outcome = await agent_tools._execute_code_with_workspace_outcome( + agent_id=uuid.uuid4(), + tenant_id=str(uuid.uuid4()), + session_id=str(uuid.uuid4()), + arguments={"language": "python", "code": "print(1)"}, + tool_name="execute_code", + ) + + assert outcome.error_code == "sandbox_execution_scope_invalid" + assert acquired is False diff --git a/backend/tests/test_sandbox_subprocess_backend.py b/backend/tests/test_sandbox_subprocess_backend.py index 8ef6ad915..ff4cacda3 100644 --- a/backend/tests/test_sandbox_subprocess_backend.py +++ b/backend/tests/test_sandbox_subprocess_backend.py @@ -1,13 +1,15 @@ """Local sandbox bootstrap must not block the Backend event loop.""" import asyncio +import signal +import uuid from pathlib import Path import pytest from app.services.sandbox.config import SandboxConfig from app.services.sandbox.local import subprocess_backend -from app.services.sandbox.local.subprocess_backend import SubprocessBackend +from app.services.sandbox.local.subprocess_backend import SANDBOX_VENV_PATH, SubprocessBackend @pytest.mark.asyncio @@ -75,6 +77,40 @@ async def fake_create(*_args, **_kwargs): assert terminated == [456] +@pytest.mark.asyncio +async def test_terminate_and_reap_process_waits_after_group_termination(monkeypatch) -> None: + terminated: list[tuple[int, signal.Signals]] = [] + + class _Process: + returncode = None + pid = 789 + + def __init__(self) -> None: + self.wait_calls = 0 + + async def wait(self) -> int: + self.wait_calls += 1 + self.returncode = -signal.SIGTERM + return self.returncode + + def kill(self) -> None: + self.returncode = -signal.SIGKILL + + proc = _Process() + monkeypatch.setattr(subprocess_backend.os, "getpgid", lambda pid: pid) + monkeypatch.setattr( + subprocess_backend.os, + "killpg", + lambda pid, sig: terminated.append((pid, sig)), + ) + + backend = SubprocessBackend(SandboxConfig()) + await backend._terminate_and_reap_process(proc) # type: ignore[arg-type] + + assert terminated == [(789, signal.SIGTERM)] + assert proc.wait_calls == 1 + + def test_subprocess_backend_proxy_env_propagation(tmp_path: Path) -> None: config = SandboxConfig( http_proxy="http://127.0.0.1:8080", @@ -112,6 +148,40 @@ def test_subprocess_backend_proxy_bwrap_command(monkeypatch, tmp_path: Path) -> assert cmd[idx_https + 1] == "http://proxy.example.com:8443" +def test_isolated_bwrap_mounts_only_tmp_and_session_output_writable(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr("shutil.which", lambda cmd: "/usr/bin/bwrap" if cmd == "bwrap" else None) + staging = tmp_path / "staging" + (staging / ".tmp").mkdir(parents=True) + session_id = uuid.uuid4() + output_path = f"workspace/output/{session_id}" + backend = SubprocessBackend(SandboxConfig(workspace_mode="isolated_output")) + + cmd = backend._build_bwrap_command( + ["python", "/workspace/.tmp/_exec_tmp.py"], + tmp_path, + tmp_path / ".venv", + staging_path=staging, + writable_path=output_path, + ) + + assert cmd is not None + root_index = cmd.index("/workspace") + assert cmd[root_index - 2] == "--ro-bind" + venv_index = cmd.index(SANDBOX_VENV_PATH) + assert cmd[venv_index - 2] == "--ro-bind" + assert cmd[venv_index - 1] == str(tmp_path / ".venv") + assert "/workspace/.venv" not in cmd + tmp_index = cmd.index("/workspace/.tmp") + output_index = cmd.index(f"/workspace/{output_path}") + assert cmd[tmp_index - 2] == "--bind" + assert cmd[output_index - 2] == "--bind" + assert root_index < tmp_index < output_index + env_index = cmd.index("CLAWITH_SESSION_OUTPUT_DIR") + assert cmd[env_index + 1] == f"/workspace/{output_path}" + virtual_env_index = cmd.index("VIRTUAL_ENV") + assert cmd[virtual_env_index + 1] == SANDBOX_VENV_PATH + + def test_sandbox_config_proxy_parsing() -> None: data = { "http_proxy": "http://10.0.0.1:3128", @@ -123,3 +193,191 @@ def test_sandbox_config_proxy_parsing() -> None: assert config.https_proxy == "http://10.0.0.1:3128" assert config.no_proxy == ".local,10.0.0.0/8" + +@pytest.mark.asyncio +async def test_sandbox_output_sanitization(tmp_path: Path) -> None: + # Setup staging and target directories + staging = tmp_path / "staging" + staging.mkdir() + target = tmp_path / "target" + target.mkdir() + + # 1. Create HTML file with malicious script tag + html_file = staging / "index.html" + html_file.write_text("

Hello

", encoding="utf-8") + + # 2. Create SVG file with malicious onload handler + svg_file = staging / "image.svg" + svg_file.write_text('', encoding="utf-8") + + # 3. Create a banned script file + script_file = staging / "evil.sh" + script_file.write_text("rm -rf /", encoding="utf-8") + + # Run verification and merge + backend = SubprocessBackend(SandboxConfig()) + await backend._verify_and_merge_outputs(staging, target) + + # Assertions + # HTML should be cleaned + cleaned_html = (target / "index.html").read_text(encoding="utf-8") + assert "