diff --git a/backend/alembic/versions/v1_0_0_f061_enterprise_info_tenant_id.py b/backend/alembic/versions/v1_0_0_f061_enterprise_info_tenant_id.py index e2f2b48fe..172bc7c24 100644 --- a/backend/alembic/versions/v1_0_0_f061_enterprise_info_tenant_id.py +++ b/backend/alembic/versions/v1_0_0_f061_enterprise_info_tenant_id.py @@ -31,20 +31,50 @@ depends_on: Union[str, Sequence[str], None] = None +def _column_names() -> set[str]: + return { + column["name"] + for column in sa.inspect(op.get_bind()).get_columns("enterprise_info") + } + + +def _index_names() -> set[str]: + return { + index["name"] + for index in sa.inspect(op.get_bind()).get_indexes("enterprise_info") + } + + +def _unique_constraint_names() -> set[str]: + return { + constraint["name"] + for constraint in sa.inspect(op.get_bind()).get_unique_constraints("enterprise_info") + if constraint["name"] + } + + def upgrade() -> None: # 1. Add tenant_id column with default uuid generator or nullable first if populated - op.add_column("enterprise_info", sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=True)) - op.create_index(op.f("ix_enterprise_info_tenant_id"), "enterprise_info", ["tenant_id"], unique=False) + if "tenant_id" not in _column_names(): + op.add_column("enterprise_info", sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=True)) + if "ix_enterprise_info_tenant_id" not in _index_names(): + op.create_index(op.f("ix_enterprise_info_tenant_id"), "enterprise_info", ["tenant_id"], unique=False) # 2. Drop legacy single info_type unique constraint - op.drop_constraint("enterprise_info_info_type_key", "enterprise_info", type_="unique") + if "enterprise_info_info_type_key" in _unique_constraint_names(): + op.drop_constraint("enterprise_info_info_type_key", "enterprise_info", type_="unique") # 3. Create new composite unique constraint (tenant_id, info_type) - op.create_unique_constraint("uq_enterprise_info_tenant_type", "enterprise_info", ["tenant_id", "info_type"]) + if "uq_enterprise_info_tenant_type" not in _unique_constraint_names(): + op.create_unique_constraint("uq_enterprise_info_tenant_type", "enterprise_info", ["tenant_id", "info_type"]) def downgrade() -> None: - op.drop_constraint("uq_enterprise_info_tenant_type", "enterprise_info", type_="unique") - op.create_unique_constraint("enterprise_info_info_type_key", "enterprise_info", ["info_type"]) - op.drop_index(op.f("ix_enterprise_info_tenant_id"), table_name="enterprise_info") - op.drop_column("enterprise_info", "tenant_id") + if "uq_enterprise_info_tenant_type" in _unique_constraint_names(): + op.drop_constraint("uq_enterprise_info_tenant_type", "enterprise_info", type_="unique") + if "enterprise_info_info_type_key" not in _unique_constraint_names(): + op.create_unique_constraint("enterprise_info_info_type_key", "enterprise_info", ["info_type"]) + if "ix_enterprise_info_tenant_id" in _index_names(): + op.drop_index(op.f("ix_enterprise_info_tenant_id"), table_name="enterprise_info") + if "tenant_id" in _column_names(): + op.drop_column("enterprise_info", "tenant_id") 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..6458e171f 100644 --- a/backend/app/dao/base.py +++ b/backend/app/dao/base.py @@ -12,6 +12,19 @@ ModelType = TypeVar("ModelType", bound=Base) +_IDENTITY_MEMBERSHIP_SCOPE_OPTION = "clawith_identity_membership_scope" + + +def identity_membership_query(statement: Any) -> Any: + """Allow an identity-bound User query to inspect all tenant memberships. + + Only models that explicitly opt in via + ``__identity_membership_tenant_bypass__`` are affected. Callers must still + constrain the statement by ``identity_id`` and, for a switch, the requested + ``tenant_id``. + """ + return statement.execution_options(**{_IDENTITY_MEMBERSHIP_SCOPE_OPTION: True}) + class BaseDAO(Generic[ModelType]): """Base class for data access objects, managing session context and basic CRUD.""" @@ -139,8 +152,17 @@ def _inject_tenant_scope(execute_state: Any) -> None: return statement = execute_state.statement + identity_membership_scope = ( + execute_state.execution_options.get(_IDENTITY_MEMBERSHIP_SCOPE_OPTION) is True + ) for mapper in execute_state.all_mappers: model = mapper.class_ + if identity_membership_scope and getattr( + model, + "__identity_membership_tenant_bypass__", + False, + ): + continue if _is_tenant_scoped_model(model): statement = statement.options( with_loader_criteria( @@ -193,6 +215,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..2d4481f52 100644 --- a/backend/app/dao/user_dao.py +++ b/backend/app/dao/user_dao.py @@ -3,7 +3,7 @@ from sqlalchemy import select from sqlalchemy.orm import selectinload -from app.dao.base import BaseDAO +from app.dao.base import BaseDAO, identity_membership_query from app.models.user import Identity, User from app.models.tenant import Tenant @@ -17,7 +17,9 @@ def __init__(self) -> None: async def get_by_identity_and_tenant(self, identity_id: Any, tenant_id: Any | None) -> User | None: """Find a user in a specific tenant (or tenant-less) by identity ID.""" async with self.session(readonly=True) as db: - query = select(User).where(User.identity_id == identity_id) + query = identity_membership_query( + select(User).where(User.identity_id == identity_id) + ) if tenant_id is not None: query = query.where(User.tenant_id == tenant_id) else: @@ -28,7 +30,9 @@ async def get_by_identity_and_tenant(self, identity_id: Any, tenant_id: Any | No async def get_by_identity_id(self, identity_id: Any, include_identity: bool = False) -> Sequence[User]: """Find all users associated with an identity ID.""" async with self.session(readonly=True) as db: - query = select(User).where(User.identity_id == identity_id) + query = identity_membership_query( + select(User).where(User.identity_id == identity_id) + ) if include_identity: query = query.options(selectinload(User.identity)) result = await db.execute(query) @@ -37,7 +41,7 @@ async def get_by_identity_id(self, identity_id: Any, include_identity: bool = Fa async def get_login_users_with_tenants(self, identity_id: Any) -> Sequence[tuple[User, Tenant | None]]: """Fetch login candidate users with tenant metadata in one round trip.""" async with self.session(readonly=True) as db: - query = ( + query = identity_membership_query( select(User, Tenant) .outerjoin(Tenant, User.tenant_id == Tenant.id) .where(User.identity_id == identity_id) @@ -99,7 +103,12 @@ async def get_with_identity(self, user_id: Any) -> User | None: async def get_representative_user_for_identity(self, identity_id: Any) -> User | None: """Find a representative user (e.g. latest created) associated with an identity ID.""" async with self.session(readonly=True) as db: - query = select(User).where(User.identity_id == identity_id).order_by(User.created_at.desc()).limit(1) + query = identity_membership_query( + select(User) + .where(User.identity_id == identity_id) + .order_by(User.created_at.desc()) + .limit(1) + ) result = await db.execute(query) return result.scalar_one_or_none() @@ -107,13 +116,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: @@ -134,4 +141,3 @@ async def list_by_ids(self, user_ids: Sequence[Any], db: Any = None) -> Sequence user_dao = UserDAO() - diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 734802dae..77e89804f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -56,6 +56,9 @@ class User(Base): __tablename__ = "users" __tenant_scoped__ = True + # Identity membership discovery is the sole controlled exception to the + # active-tenant read filter. DAO queries still require an exact identity_id. + __identity_membership_tenant_bypass__ = True # Note: Unique constraints for (tenant_id, username), (tenant_id, email) and (tenant_id, primary_mobile) # are handled via partial unique indexes in migration to allow NULL values 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/command_worker.py b/backend/app/services/agent_runtime/command_worker.py index 9d028b50a..b3616766f 100644 --- a/backend/app/services/agent_runtime/command_worker.py +++ b/backend/app/services/agent_runtime/command_worker.py @@ -37,6 +37,8 @@ from app.services.agent_runtime.tool_execution import ( ToolExecutionReconciliationPending, ) +from app.services.sandbox.local.subprocess_backend import close_subprocess_sandbox_run +from app.services.sandbox.run_scope import sandbox_run_scope_id from app.services.group_realtime import publish_stored_group_message @@ -819,6 +821,7 @@ async def _process_locked( checkpoint=checkpoint, ) + sandbox_run_token = sandbox_run_scope_id.set(str(run.run_id)) try: await self._command_executor.execute( connection=connection, @@ -833,6 +836,9 @@ async def _process_locked( error_message=str(exc), run=run, ) + finally: + sandbox_run_scope_id.reset(sandbox_run_token) + await close_subprocess_sandbox_run(str(run.run_id)) observed = await self._checkpoint_reader.read_for_command( connection=connection, 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/model_step_service.py b/backend/app/services/agent_runtime/model_step_service.py index ec6e84961..e7e99e148 100644 --- a/backend/app/services/agent_runtime/model_step_service.py +++ b/backend/app/services/agent_runtime/model_step_service.py @@ -61,7 +61,10 @@ from app.services.agent_tools import get_runtime_agent_tools_for_llm from app.services.vision_inject import compress_bytes_to_base64 from app.services.llm.client import LLMMessage -from app.services.llm.failover import FailoverErrorType, classify_error +from app.services.llm.failover import ( + classify_error, + is_retryable_classification, +) from app.services.llm.finish import ( content_claims_group_handoff, find_finish_call, @@ -1473,11 +1476,12 @@ async def _call_prepared_with_retry( ) except Exception as exc: classification = classify_error(exc) + is_retryable = is_retryable_classification(classification) if ( - classification != FailoverErrorType.RETRYABLE + not is_retryable or attempt >= total_attempts ): - if classification == FailoverErrorType.RETRYABLE: + if is_retryable: logger.warning( "[RuntimeModelRetry] exhausted provider={} model={} " "attempts={} error_type={} http_status={} classification={}", @@ -1606,7 +1610,7 @@ async def complete_once( ) except Exception as primary_error: primary_classification = classify_error(primary_error) - if primary_classification != FailoverErrorType.RETRYABLE: + if not is_retryable_classification(primary_classification): logger.error( "[RuntimeModelFailure] run_id={} agent_id={} stage=primary " "provider={} model={} classification={} http_status={} " @@ -1692,7 +1696,7 @@ async def complete_once( ) except Exception as fallback_error: fallback_classification = classify_error(fallback_error) - if fallback_classification == FailoverErrorType.RETRYABLE: + if is_retryable_classification(fallback_classification): return self._provider_retry_wait( context=context, model=fallback, diff --git a/backend/app/services/agent_runtime/run_compactor.py b/backend/app/services/agent_runtime/run_compactor.py index e4ea401c9..0010cd86d 100644 --- a/backend/app/services/agent_runtime/run_compactor.py +++ b/backend/app/services/agent_runtime/run_compactor.py @@ -33,7 +33,10 @@ ) from app.services.llm.client import LLMMessage from app.services.llm.single_step import LLMCompletionStep, complete_llm_once -from app.services.llm.failover import FailoverErrorType, classify_error +from app.services.llm.failover import ( + classify_error, + is_retryable_classification, +) from app.services.llm.multimodal_content import ( MultimodalContentError, estimate_multimodal_tokens, @@ -597,7 +600,7 @@ async def _compact_batches( supports_vision=False, ) except Exception as exc: - if classify_error(exc) == FailoverErrorType.RETRYABLE: + if is_retryable_classification(classify_error(exc)): raise TransientRunCompactorError( "thread_compact_provider_transient", "Thread Compact provider call failed transiently", 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..e83686f97 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -28,7 +28,7 @@ from contextvars import ContextVar from datetime import date, datetime, timedelta, timezone from pathlib import Path -from typing import Optional, Any, cast +from typing import Optional, Any, Literal, cast import re from loguru import logger @@ -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,11 +74,23 @@ 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.local.run_workspace import ( + RunWorkspaceIdentity, + use_run_workspace, +) +from app.services.sandbox.run_scope import sandbox_run_scope_id +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, ) from app.services.builtin_tool_definitions import ( + AGENT_RELATIVE_PATH_ARGUMENTS, BUILTIN_TOOL_DEFINITIONS, BUILTIN_TOOL_NAMES, WRITE_FILE_MAX_CONTENT_CHARS, @@ -162,6 +175,26 @@ def _read_file_binary_error(path: str) -> str | None: ) +def _agent_relative_path_error(tool_name: str, arguments: Mapping[str, object]) -> str | None: + """Reject model-facing absolute paths before they reach Storage adapters.""" + for field in AGENT_RELATIVE_PATH_ARGUMENTS.get(tool_name, ()): + value = arguments.get(field) + if not isinstance(value, str) or not value.strip(): + continue + normalized = value.strip().replace("\\", "/") + is_absolute = normalized.startswith("/") or bool( + re.match(r"^[A-Za-z]:/", normalized) + ) + is_uri = bool(re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", normalized)) + if is_absolute or is_uri: + return ( + f"{tool_name} {field} must be Agent-root-relative, for example " + "'workspace/output/report.md'; paths must not start with '/' " + "or use a URI scheme." + ) + return None + + def _observability_arguments(tool_name: str, arguments: dict) -> dict: """Return a fail-closed, canonical-path-aware copy for logs/UI errors.""" try: @@ -1049,9 +1082,70 @@ async def _get_runtime_dynamic_mcp_tool_names( return ready +_ISOLATED_OUTPUT_TOOL_PROMPT = ( + " Workspace write policy: isolated session output. Materialized directories " + "inside the sandbox are readable and writable for the current Agent loop, " + "but only files under the Agent-relative path " + "workspace/output// are published " + "back to the host Workspace. Other sandbox writes are temporary. The working " + "directory is / and every model-visible path is relative to that Agent root. " + "Use the same paths as file tools, including the leading workspace/, skills/, " + "or memory/ segment. Read the exact relative persistent output directory from " + "CLAWITH_SESSION_OUTPUT_DIR; do not omit or duplicate any path segment, and " + "do not return Sandbox absolute paths." +) + + +def _with_isolated_output_prompt(tool: dict) -> dict: + """Add the configured local write boundary to the model-facing tool schema.""" + patched = deepcopy(tool) + function = patched.get("function") + if not isinstance(function, dict): + return patched + description = str(function.get("description") or "").rstrip() + if _ISOLATED_OUTPUT_TOOL_PROMPT.strip() not in description: + function["description"] = f"{description}{_ISOLATED_OUTPUT_TOOL_PROMPT}" + parameters = function.get("parameters") + if isinstance(parameters, dict): + properties = parameters.get("properties") + if isinstance(properties, dict) and isinstance(properties.get("code"), dict): + code_schema = properties["code"] + code_description = str( + code_schema.get("description") or "Code to execute" + ).rstrip() + code_schema["description"] = ( + f"{code_description}{_ISOLATED_OUTPUT_TOOL_PROMPT}" + ) + return patched + + async def get_runtime_agent_tools_for_llm(agent_id: uuid.UUID) -> list[dict]: """Resolve the current Durable Runtime workset with typed-outcome gating.""" tools = await get_agent_tools_for_llm(agent_id) + try: + execute_code_config = await _get_tool_config(agent_id, "execute_code") or {} + from app.config import get_sandbox_config + from app.services.sandbox.config import SandboxConfig + + fallback_config = get_sandbox_config() + sandbox_config = ( + SandboxConfig.from_dict(execute_code_config, fallback_config) + if execute_code_config + else fallback_config + ) + except Exception as exc: + logger.warning( + "[Tools] Code Executor workspace policy lookup failed: {}", + type(exc).__name__, + ) + sandbox_config = None + if sandbox_config is not None and sandbox_config.workspace_mode == "isolated_output": + tools = [ + _with_isolated_output_prompt(tool) + if tool.get("function", {}).get("name") == "execute_code" + else tool + for tool in tools + ] dynamic_mcp_names = await _get_runtime_dynamic_mcp_tool_names(agent_id) resolved = _runtime_typed_tools( tools, @@ -1336,9 +1430,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 +1468,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 +1489,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, ) @@ -1462,19 +1564,27 @@ async def _sync_tasks_to_file(agent_id: uuid.UUID, ws: Path): logger.error(f"[AgentTools] Failed to sync tasks: {e}") -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.""" +async def flush_temp_workspace( + temp_workspace: TempWorkspace, + conflict_mode: Literal["fail", "overwrite"] = "fail", +) -> dict[str, list[str]]: + """Flush local changes, optionally replacing Session-isolated output.""" 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) + run_id = sandbox_run_scope_id.get().strip() or None updated: list[str] = [] conflicted: list[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 @@ -1490,6 +1600,18 @@ async def flush_temp_workspace(temp_workspace: TempWorkspace, conflict_mode: str else WriteCondition(require_absent=True) ) storage_key = entry.storage_key if entry else normalize_storage_key(f"{temp_workspace.agent_id}/{rel_path}") + if conflict_mode == "overwrite": + await storage.write_bytes(storage_key, data) + version = await storage.get_version(storage_key) + manifest[rel_path] = TempWorkspaceManifestEntry( + rel_path=rel_path, + storage_key=storage_key, + base_version_token=version.token, + base_hash=current_hash, + size=len(data), + ) + updated.append(rel_path) + continue result = await storage.write_bytes_if_match( storage_key, data, @@ -1497,23 +1619,71 @@ async def flush_temp_workspace(temp_workspace: TempWorkspace, conflict_mode: str ) if not result.ok: conflicted.append(rel_path) + logger.warning( + "[WorkspaceFlushConflict] run_id={} agent_id={} operation=write " + "path={} condition={} expected_version={} current_exists={} " + "current_version={} updated={} deleted={} skipped={}", + run_id, + temp_workspace.agent_id, + rel_path, + "version_match" if entry else "require_absent", + entry.base_version_token if entry else None, + result.current_version.exists if result.current_version else None, + result.current_version.token if result.current_version else None, + updated, + deleted, + skipped, + ) if conflict_mode == "fail": return {"updated": updated, "deleted": deleted, "conflicted": conflicted, "skipped": skipped} continue + version = result.current_version or await storage.get_version(storage_key) + manifest[rel_path] = TempWorkspaceManifestEntry( + rel_path=rel_path, + storage_key=storage_key, + base_version_token=version.token, + base_hash=current_hash, + size=len(data), + ) updated.append(rel_path) - for rel_path, entry in manifest.items(): + for rel_path, entry in list(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 + if conflict_mode == "overwrite": + await storage.delete(entry.storage_key) + manifest.pop(rel_path, None) + deleted.append(rel_path) + continue result = await storage.delete_if_match( entry.storage_key, condition=WriteCondition(version_token=entry.base_version_token), ) if not result.ok: conflicted.append(rel_path) + logger.warning( + "[WorkspaceFlushConflict] run_id={} agent_id={} operation=delete " + "path={} condition=version_match expected_version={} " + "current_exists={} current_version={} updated={} deleted={} skipped={}", + run_id, + temp_workspace.agent_id, + rel_path, + entry.base_version_token, + result.current_version.exists if result.current_version else None, + result.current_version.token if result.current_version else None, + updated, + deleted, + skipped, + ) if conflict_mode == "fail": return {"updated": updated, "deleted": deleted, "conflicted": conflicted, "skipped": skipped} continue + manifest.pop(rel_path, None) deleted.append(rel_path) return {"updated": updated, "deleted": deleted, "conflicted": conflicted, "skipped": skipped} @@ -1528,15 +1698,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 +1853,239 @@ 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 + run_id = sandbox_run_scope_id.get().strip() or None + workspace_identity = RunWorkspaceIdentity( + agent_id=str(agent_id), + tenant_id=str(scope.tenant_id) if scope else tenant_id, + session_id=str(scope.session_id) if scope else None, + workspace_mode=policy.mode, + materialized_paths=policy.materialized_paths, + publish_paths=policy.publish_paths, + ) + + async def prepare_workspace() -> TempWorkspace: + 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: + (workspace.root / policy.session_output_path).mkdir(parents=True, exist_ok=True) + return workspace + + try: + async with use_run_workspace( + run_id=run_id, + identity=workspace_identity, + factory=prepare_workspace, + ) as run_workspace: + temp_workspace = cast(TempWorkspace, run_workspace) + 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=policy.publication_conflict_mode, + ), + 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=policy.publication_conflict_mode, + ), + 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, + ) + 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, @@ -2519,6 +2927,12 @@ async def execute_builtin_tool_outcome( Durable Runtime rejects those as ``untyped_tool_outcome``; this function never infers success from display text or from a non-raising handler. """ + path_error = _agent_relative_path_error(tool_name, arguments) + if path_error is not None: + return _typed_failure( + path_error, + "workspace_path_invalid", + ) if ( tool_name in _WORKSPACE_SCOPED_FILE_TOOL_NAMES and arguments.get("workspace_scope", "agent") != "agent" @@ -2632,18 +3046,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,12 +3291,16 @@ 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. Used by the approval post-processing hook after an action has been approved and needs to actually run. """ + path_error = _agent_relative_path_error(tool_name, arguments) + if path_error is not None: + return f"❌ {path_error}" _agent_tenant_id = await _get_agent_tenant_id(agent_id) ws = _agent_workspace_root(agent_id) try: @@ -2905,12 +3318,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": @@ -2976,6 +3391,10 @@ async def execute_tool( content = arguments.get("content", "") return content if isinstance(content, str) else str(content) + path_error = _agent_relative_path_error(tool_name, arguments) + if path_error is not None: + return f"❌ {path_error}" + _agent_tenant_id = await _get_agent_tenant_id(agent_id) ws = _agent_workspace_root(agent_id) @@ -3237,12 +3656,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 +10341,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 +10435,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 +10455,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 +10484,13 @@ async def _execute_code_outcome( work_dir=str(work_dir), on_output=on_output, agent_id=agent_id, + session_id=session_id, + run_id=sandbox_run_scope_id.get().strip() or None, + 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,11 +10501,26 @@ async def _execute_code_outcome( if result.success and result.exit_code == 0 else f"Code execution failed with exit code {result.exit_code}." ) + output_metadata: dict[str, str] = {} + if sandbox_config.workspace_mode == "isolated_output" and publish_paths: + output_path = normalize_workspace_path(publish_paths[0]) + output_metadata["workspace_path"] = output_path + summary = ( + f"{summary}\n\nPersistent output directory: {output_path} " + "(Agent-relative; use this exact path with file tools)." + ) + 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", + metadata=output_metadata, + ) if result.success and result.exit_code == 0: - return _typed_success(summary) + return _typed_success(summary, metadata=output_metadata) return _typed_failure( summary, "sandbox_execution_failed", + metadata=output_metadata, ) except ValueError as e: 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..e5323a7f6 100644 --- a/backend/app/services/builtin_tool_definitions.py +++ b/backend/app/services/builtin_tool_definitions.py @@ -16,6 +16,40 @@ WRITE_FILE_MAX_CONTENT_CHARS = 6_000 +# Model-facing paths use one Agent-root-relative namespace. The same literal +# path must work across file tools and execute_code; absolute Sandbox mount +# paths are an internal implementation detail. +AGENT_RELATIVE_PATH_ARGUMENTS: Mapping[str, tuple[str, ...]] = { + "list_files": ("path",), + "read_file": ("path",), + "write_file": ("path",), + "delete_file": ("path",), + "move_file": ("source_path", "destination_path"), + "edit_file": ("path",), + "search_files": ("path",), + "find_files": ("path",), + "read_document": ("path",), + "convert_csv_to_xlsx": ("source_path", "target_path"), + "convert_html_to_pdf": ("source_path", "target_path"), + "convert_html_to_pptx": ("source_path", "target_path"), + "convert_markdown_to_docx": ("source_path", "target_path"), + "convert_markdown_to_pdf": ("source_path", "target_path"), + "send_channel_file": ("file_path",), + "send_file_to_agent": ("file_path",), + "upload_image": ("file_path",), + "generate_image_siliconflow": ("save_path",), + "generate_image_openai": ("save_path",), + "generate_image_google": ("save_path",), + "generate_image_custom": ("save_path",), + "publish_page": ("path",), +} + +_AGENT_RELATIVE_PATH_DESCRIPTION = ( + "Use an Agent-root-relative path such as 'workspace/reports/report.md'; " + "never start the path with '/'." +) + + # Builtin tool definitions — these map to the hardcoded AGENT_TOOLS _BUILTIN_TOOL_SOURCE = [ { @@ -983,11 +1017,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", @@ -3852,8 +3898,20 @@ def _readiness(definition: Mapping[str, Any]) -> str: def _canonical_definition(seed: Mapping[str, Any]) -> dict[str, Any]: effect, retry_policy, parallel_safe = _policy_for_name(str(seed["name"])) + canonical = deepcopy(dict(seed)) + properties = (canonical.get("parameters_schema") or {}).get("properties") + if isinstance(properties, dict): + for field in AGENT_RELATIVE_PATH_ARGUMENTS.get(str(seed["name"]), ()): + property_schema = properties.get(field) + if not isinstance(property_schema, dict): + continue + current = str(property_schema.get("description") or "").strip() + if _AGENT_RELATIVE_PATH_DESCRIPTION not in current: + property_schema["description"] = ( + f"{current} {_AGENT_RELATIVE_PATH_DESCRIPTION}".strip() + ) return { - **deepcopy(dict(seed)), + **canonical, "effect": effect, "retry_policy": retry_policy, "parallel_safe": parallel_safe, diff --git a/backend/app/services/llm/caller.py b/backend/app/services/llm/caller.py index f854bc06a..ffd537974 100644 --- a/backend/app/services/llm/caller.py +++ b/backend/app/services/llm/caller.py @@ -38,7 +38,7 @@ normalize_llm_finish_reason, normalize_textual_tool_protocol, ) -from .failover import classify_error, FailoverErrorType +from .failover import classify_error, is_retryable_classification from .finish import find_finish_call from .utils import LLMMessage, create_llm_client, get_max_tokens, get_model_api_key @@ -189,7 +189,7 @@ def is_retryable_error(result: str) -> bool: if not (result.startswith("[LLM Error]") or result.startswith("[LLM call error]") or result.startswith("[Error]")): return False - return classify_error(Exception(result)) != FailoverErrorType.NON_RETRYABLE + return is_retryable_classification(classify_error(Exception(result))) def _get_model_timeout(model: "LLMModel") -> float: diff --git a/backend/app/services/llm/failover.py b/backend/app/services/llm/failover.py index 7184fb278..239b98893 100644 --- a/backend/app/services/llm/failover.py +++ b/backend/app/services/llm/failover.py @@ -18,6 +18,11 @@ class FailoverErrorType(Enum): UNKNOWN = "unknown" +def is_retryable_classification(classification: FailoverErrorType) -> bool: + """Retry every provider failure that is not explicitly deterministic.""" + return classification != FailoverErrorType.NON_RETRYABLE + + def classify_error(error: Exception) -> FailoverErrorType: """Classify an exception as retryable or non-retryable. @@ -81,4 +86,5 @@ def classify_error(error: Exception) -> FailoverErrorType: __all__ = [ "FailoverErrorType", "classify_error", + "is_retryable_classification", ] 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/run_workspace.py b/backend/app/services/sandbox/local/run_workspace.py new file mode 100644 index 000000000..8f3000f6c --- /dev/null +++ b/backend/app/services/sandbox/local/run_workspace.py @@ -0,0 +1,103 @@ +"""Run-scoped materialized workspace lifecycle for local sandboxes.""" + +import asyncio +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + + +class RunWorkspace(Protocol): + """Minimum interface required for a run-scoped materialized workspace.""" + + root: Path + + def cleanup(self) -> None: ... + + +@dataclass(frozen=True) +class RunWorkspaceIdentity: + """Configuration that must remain stable throughout one Agent loop.""" + + agent_id: str + tenant_id: str | None + session_id: str | None + workspace_mode: str + materialized_paths: tuple[str, ...] + publish_paths: tuple[str, ...] + + +@dataclass +class _RunWorkspaceState: + identity: RunWorkspaceIdentity + workspace: RunWorkspace + lock: asyncio.Lock + + +_run_workspace_tasks: dict[str, asyncio.Task[_RunWorkspaceState]] = {} + + +async def _create_state( + identity: RunWorkspaceIdentity, + factory: Callable[[], Awaitable[RunWorkspace]], +) -> _RunWorkspaceState: + return _RunWorkspaceState( + identity=identity, + workspace=await factory(), + lock=asyncio.Lock(), + ) + + +async def _get_or_create_state( + run_id: str, + identity: RunWorkspaceIdentity, + factory: Callable[[], Awaitable[RunWorkspace]], +) -> _RunWorkspaceState: + task = _run_workspace_tasks.get(run_id) + if task is None: + task = asyncio.create_task(_create_state(identity, factory)) + _run_workspace_tasks[run_id] = task + try: + state = await asyncio.shield(task) + except BaseException: + if task.done() and _run_workspace_tasks.get(run_id) is task: + _run_workspace_tasks.pop(run_id, None) + raise + if state.identity != identity: + raise RuntimeError("Agent-loop sandbox workspace identity changed") + return state + + +@asynccontextmanager +async def use_run_workspace( + *, + run_id: str | None, + identity: RunWorkspaceIdentity, + factory: Callable[[], Awaitable[RunWorkspace]], +) -> AsyncIterator[RunWorkspace]: + """Materialize once per Run, while preserving one-shot legacy behavior.""" + if not run_id: + workspace = await factory() + try: + yield workspace + finally: + workspace.cleanup() + return + + state = await _get_or_create_state(run_id, identity, factory) + async with state.lock: + yield state.workspace + + +async def close_run_workspace(run_id: str) -> None: + """Discard the materialized workspace owned by one settled Agent loop.""" + task = _run_workspace_tasks.pop(run_id, None) + if task is None: + return + try: + state = await asyncio.shield(task) + except (asyncio.CancelledError, Exception): + return + async with state.lock: + state.workspace.cleanup() diff --git a/backend/app/services/sandbox/local/subprocess_backend.py b/backend/app/services/sandbox/local/subprocess_backend.py index 1b434cecd..16c351d3d 100644 --- a/backend/app/services/sandbox/local/subprocess_backend.py +++ b/backend/app/services/sandbox/local/subprocess_backend.py @@ -1,21 +1,50 @@ """Local subprocess-based sandbox backend.""" import asyncio +from dataclasses import dataclass import os +import shlex import shutil import signal +import tempfile import time +import uuid from pathlib import Path from loguru import logger from app.services.sandbox.base import BaseSandboxBackend, ExecutionResult, SandboxCapabilities from app.services.sandbox.config import SandboxConfig +from app.services.sandbox.local.run_workspace import close_run_workspace from app.services.workspace_paths import WorkspacePathError, resolve_path_within_root 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" +_BWRAP_DONE_PREFIX = "__CLAWITH_BWRAP_DONE__" +MAX_PUBLISHED_FILES_PER_EXECUTION = 100 +MAX_DELETED_FILES_PER_EXECUTION = 100 +MAX_PUBLISHED_TOTAL_BYTES = 50 * 1024 * 1024 +MAX_PUBLISHED_FILE_BYTES = 10 * 1024 * 1024 + + +@dataclass +class _PersistentBwrapSession: + run_id: str + agent_id: uuid.UUID | None + session_id: str | None + workspace_mode: str + publish_paths: tuple[str, ...] + work_path: Path + temp_dir: tempfile.TemporaryDirectory + staging_path: Path + venv_path: Path + process: asyncio.subprocess.Process + pip_stop_event: asyncio.Event + pip_watcher_task: asyncio.Task + lock: asyncio.Lock # Security patterns - reused from agent_tools.py @@ -104,28 +133,51 @@ class SubprocessBackend(BaseSandboxBackend): name = "subprocess" _bwrap_missing_warned = False + _run_sessions: dict[str, _PersistentBwrapSession] = {} def __init__(self, config: SandboxConfig): self.config = config + @classmethod + async def close_run(cls, run_id: str) -> None: + """Stop and remove the bubblewrap process owned by one Agent loop.""" + session = cls._run_sessions.pop(run_id, None) + if session is None: + return + session.pip_stop_event.set() + try: + await session.pip_watcher_task + except (asyncio.CancelledError, Exception): + pass + if session.process.returncode is None: + try: + if session.process.stdin is not None: + session.process.stdin.write(b"exit\n") + await session.process.stdin.drain() + await asyncio.wait_for(session.process.wait(), timeout=2) + except (asyncio.TimeoutError, BrokenPipeError, ConnectionResetError): + backend = cls(SandboxConfig()) + await backend._terminate_and_reap_process(session.process) + session.temp_dir.cleanup() + 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 ["bash", "--noprofile", "--norc", "-o", "pipefail", str(script_path)] return ["node", str(script_path)] def _build_host_command(self, language: str, script_path: Path, work_path: Path) -> list[str]: if language == "python": return [self._host_venv_python(work_path), "-I", "-B", str(script_path)] if language == "bash": - return ["bash", "--noprofile", "--norc", str(script_path)] + return ["bash", "--noprofile", "--norc", "-o", "pipefail", str(script_path)] return ["node", str(script_path)] def _build_safe_env(self, work_path: Path) -> dict[str, str]: @@ -166,6 +218,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 +266,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 +285,30 @@ 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" + " OUT_FILE=\"/workspace/.tmp/.pip_output_${REQ_ID}\"\n" + " echo \"$@\" > \"$REQ_FILE\"\n" + " while [ ! -f \"$RES_FILE\" ]; do\n" + " sleep 0.2\n" + " done\n" + " if [ -f \"$OUT_FILE\" ]; then\n" + " cat \"$OUT_FILE\"\n" + " rm -f \"$OUT_FILE\"\n" + " fi\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 +367,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 +394,11 @@ def _build_bwrap_command(self, command: list[str], work_path: Path, venv_path: P + self._bind_if_exists("/etc") ) + if staging_path is not None: + for directory in ("workspace", "memory", "skills"): + (staging_path / directory).mkdir(parents=True, exist_ok=True) + (staging_path / "workspace" / ".tmp").mkdir(parents=True, exist_ok=True) + cmd = [ bwrap, "--die-with-parent", @@ -306,24 +409,45 @@ 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", + ] + if staging_path is not None: + cmd.extend([ + "--bind", str(staging_path / "workspace"), "/workspace", + "--bind", str(staging_path / "memory"), "/memory", + "--bind", str(staging_path / "skills"), "/skills", + ]) + for root_file in ("focus.md", "soul.md", "HEARTBEAT.md"): + source = staging_path / root_file + if source.exists(): + cmd.extend(["--bind", str(source), f"/{root_file}"]) + else: + cmd.extend(["--bind", str(work_path), "/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([ + "--setenv", "CLAWITH_SESSION_OUTPUT_DIR", 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") @@ -335,7 +459,7 @@ def _build_bwrap_command(self, command: list[str], work_path: Path, venv_path: P cmd.extend(["--setenv", "no_proxy", no_proxy, "--setenv", "NO_PROXY", no_proxy]) cmd.append("--chdir") - cmd.append("/workspace") + cmd.append("/") if not self.config.allow_network: cmd.append("--unshare-net") cmd.extend(command) @@ -352,6 +476,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 +488,560 @@ 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 / "workspace" / ".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}" + output_file = tmp_dir / f".pip_output_{req_id}" + if response_file.exists(): + continue + + args = args_str.split() + if not args: + raise ValueError("Empty pip proxy request") + cmd = [ + "uv", "pip", args[0], + "--python", str(venv_path / "bin" / "python"), + *args[1:], + ] + 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, + ) + stdout, stderr = await proc.communicate() + exit_code = proc.returncode + output = (stdout + stderr).decode("utf-8", errors="replace") + except Exception as exc: + logger.error(f"[Subprocess Sandbox Host] Failed to run proxy pip: {exc}") + exit_code = 1 + output = f"pip proxy failed: {exc}\n" + + try: + output_file.write_text(output[-20000:], encoding="utf-8") + 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, + workspace_mode: str = "merge", + 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 + + 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 + + publication_candidates: dict[Path, Path] = {} + for rel_path, file_path in staging_files.items(): + rel_path_str = str(rel_path) + target_file = target_files.get(rel_path) + if rel_path_str in protected_files: + if target_file is None: + 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 OSError: + continue + if file_path.suffix.lower() in banned_suffixes: + logger.warning( + f"[Sandbox Gateway] Blocked banned file extension: {rel_path}" + ) + continue + if target_file is not None: + try: + if file_path.read_bytes() == target_file.read_bytes(): + continue + except OSError: + pass + publication_candidates[rel_path] = file_path + + deletion_candidates = { + rel_path: target_path + for rel_path, target_path in target_files.items() + if rel_path not in staging_files and str(rel_path) not in protected_files + } + for rel_path, target_path in target_files.items(): + if rel_path in staging_files or str(rel_path) not in protected_files: + continue + logger.warning( + f"[Sandbox Gateway] Blocked attempt to delete protected file: {rel_path}" + ) + try: + restored_path = staging_path / rel_path + restored_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(target_path, restored_path) + except OSError: + pass + + # Session-isolated output has one serialized writer and cannot mutate the + # shared Workspace tree, so shared-workspace change-count limits do not + # apply. Content safety and byte-size limits remain enforced below. + if workspace_mode != "isolated_output": + if len(publication_candidates) > MAX_PUBLISHED_FILES_PER_EXECUTION: + raise RuntimeError( + "Sandbox generated too many changed files " + f"(limit: {MAX_PUBLISHED_FILES_PER_EXECUTION})" + ) + if len(deletion_candidates) > MAX_DELETED_FILES_PER_EXECUTION: + raise RuntimeError( + "Sandbox deleted too many files " + f"(limit: {MAX_DELETED_FILES_PER_EXECUTION})" + ) + + total_size = 0 + for rel_path, file_path in publication_candidates.items(): + try: + file_size = file_path.stat().st_size + except FileNotFoundError: + continue + total_size += file_size + if total_size > MAX_PUBLISHED_TOTAL_BYTES: + raise RuntimeError( + "Sandbox generated changed files exceeding total size limit " + f"(limit: {MAX_PUBLISHED_TOTAL_BYTES} bytes)" + ) + if file_size > MAX_PUBLISHED_FILE_BYTES: + raise RuntimeError( + f"File '{rel_path}' exceeds single file size limit " + f"({MAX_PUBLISHED_FILE_BYTES} 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 publication_candidates.items(): + rel_path_str = str(rel_path) + + # 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 deletion_candidates.items(): + rel_path_str = str(rel_path) + + 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 _start_persistent_session( + self, + *, + run_id: str, + work_path: Path, + venv_path: Path, + agent_id: uuid.UUID | None, + session_id: str | None, + workspace_mode: str, + publish_paths: list[str] | None, + ) -> _PersistentBwrapSession | None: + temp_dir = tempfile.TemporaryDirectory(prefix=f"clawith-bwrap-{run_id[:8]}-") + staging_path = Path(temp_dir.name) + self._clone_workspace_to_staging(work_path, staging_path) + (staging_path / "workspace" / ".tmp" / "pip-cache").mkdir( + parents=True, + exist_ok=True, + ) + writable_path = ( + publish_paths[0] + if workspace_mode == "isolated_output" and publish_paths + else None + ) + bwrap_command = self._build_bwrap_command( + ["bash", "--noprofile", "--norc"], + work_path, + venv_path, + staging_path=staging_path, + writable_path=writable_path, + ) + if bwrap_command is None: + temp_dir.cleanup() + return None + process = await asyncio.create_subprocess_exec( + *bwrap_command, + cwd=str(work_path), + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=self._build_safe_env(work_path), + start_new_session=True, + ) + pip_stop_event = asyncio.Event() + pip_watcher_task = asyncio.create_task( + self._watch_pip_requests(staging_path, venv_path, pip_stop_event) + ) + persistent = _PersistentBwrapSession( + run_id=run_id, + agent_id=agent_id, + session_id=session_id, + workspace_mode=workspace_mode, + publish_paths=tuple(publish_paths or ()), + work_path=work_path.resolve(), + temp_dir=temp_dir, + staging_path=staging_path, + venv_path=venv_path, + process=process, + pip_stop_event=pip_stop_event, + pip_watcher_task=pip_watcher_task, + lock=asyncio.Lock(), + ) + SubprocessBackend._run_sessions[run_id] = persistent + return persistent + + async def _persistent_session( + self, + *, + run_id: str, + work_path: Path, + venv_path: Path, + agent_id: uuid.UUID | None, + session_id: str | None, + workspace_mode: str, + publish_paths: list[str] | None, + ) -> _PersistentBwrapSession | None: + existing = SubprocessBackend._run_sessions.get(run_id) + expected_paths = tuple(publish_paths or ()) + if existing is not None and ( + existing.process.returncode is not None + or existing.agent_id != agent_id + or existing.session_id != session_id + or existing.workspace_mode != workspace_mode + or existing.publish_paths != expected_paths + or existing.work_path != work_path.resolve() + ): + await SubprocessBackend.close_run(run_id) + existing = None + if existing is None: + return await self._start_persistent_session( + run_id=run_id, + work_path=work_path, + venv_path=venv_path, + agent_id=agent_id, + session_id=session_id, + workspace_mode=workspace_mode, + publish_paths=publish_paths, + ) + return existing + + async def _run_in_persistent_session( + self, + session: _PersistentBwrapSession, + *, + code: str, + language: str, + timeout: int, + on_output, + ) -> tuple[int, str, str, bool]: + token = uuid.uuid4().hex + extension = {"python": ".py", "bash": ".sh", "node": ".js"}[language] + temp_path = session.staging_path / "workspace" / ".tmp" + script_path = temp_path / f"_exec_tmp_{token}{extension}" + stdout_path = temp_path / f"_exec_stdout_{token}" + stderr_path = temp_path / f"_exec_stderr_{token}" + script_path.write_text(code, encoding="utf-8") + command = self._build_command( + language, + f"/workspace/.tmp/{script_path.name}", + ) + marker = f"{_BWRAP_DONE_PREFIX}{token}:" + shell_line = ( + f"{shlex.join(command)} >{shlex.quote('/workspace/.tmp/' + stdout_path.name)} " + f"2>{shlex.quote('/workspace/.tmp/' + stderr_path.name)}; " + f"__clawith_rc=$?; printf '{marker}%s\\n' \"$__clawith_rc\"\n" + ) + process = session.process + if process.stdin is None or process.stdout is None: + raise RuntimeError("Persistent bubblewrap control pipes are unavailable") + process.stdin.write(shell_line.encode("utf-8")) + await process.stdin.drain() + + stream_stop = asyncio.Event() + + async def stream_output_files() -> None: + offsets = {stdout_path: 0, stderr_path: 0} + labels = {stdout_path: "stdout", stderr_path: "stderr"} + while not stream_stop.is_set(): + if on_output: + for path, offset in tuple(offsets.items()): + if not path.exists(): + continue + with path.open("rb") as stream: + stream.seek(offset) + chunk = stream.read() + if chunk: + offsets[path] += len(chunk) + try: + await on_output( + chunk.decode("utf-8", errors="replace"), + labels[path], + ) + except Exception: + pass + try: + await asyncio.wait_for(stream_stop.wait(), timeout=0.1) + except asyncio.TimeoutError: + pass + + if on_output: + for path, offset in tuple(offsets.items()): + if not path.exists(): + continue + with path.open("rb") as stream: + stream.seek(offset) + chunk = stream.read() + if chunk: + try: + await on_output( + chunk.decode("utf-8", errors="replace"), + labels[path], + ) + except Exception: + pass + + stream_task = asyncio.create_task(stream_output_files()) + + timed_out = False + exit_code = 1 + try: + async with asyncio.timeout(timeout): + while True: + line = await process.stdout.readline() + if not line: + detail = "" + if process.stderr is not None: + detail = (await process.stderr.read()).decode( + "utf-8", + errors="replace", + )[:500] + raise RuntimeError( + "Persistent bubblewrap exited before command settlement" + + (f": {detail}" if detail else "") + ) + decoded = line.decode("utf-8", errors="replace").strip() + if decoded.startswith(marker): + exit_code = int(decoded.removeprefix(marker)) + break + except asyncio.TimeoutError: + timed_out = True + await self._terminate_and_reap_process(process) + exit_code = 124 + finally: + stream_stop.set() + await stream_task + + stdout = ( + stdout_path.read_bytes()[:MAX_STDOUT_CAPTURE_BYTES] + if stdout_path.exists() + else b"" + ) + stderr = ( + stderr_path.read_bytes()[:MAX_STDERR_CAPTURE_BYTES] + if stderr_path.exists() + else b"" + ) + stdout_text = stdout.decode("utf-8", errors="replace")[:10000] + stderr_text = stderr.decode("utf-8", errors="replace")[:5000] + for path in (script_path, stdout_path, stderr_path): + path.unlink(missing_ok=True) + return exit_code, stdout_text, stderr_text, timed_out + async def execute( self, code: str, @@ -372,9 +1051,18 @@ 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") + run_id = kwargs.get("run_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"): @@ -416,21 +1104,131 @@ async def execute( error=str(exc), ) 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) # 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: venv_path = work_path / ".venv" + try: + await self._ensure_workspace_venv(venv_path) + if isinstance(run_id, str) and run_id: + persistent = await self._persistent_session( + run_id=run_id, + work_path=work_path, + venv_path=venv_path, + agent_id=agent_id, + session_id=session_id, + workspace_mode=workspace_mode, + publish_paths=publish_paths, + ) + if persistent is not None: + async with persistent.lock: + exit_code, stdout_str, stderr_str, is_timeout = ( + await self._run_in_persistent_session( + persistent, + code=code, + language=language, + timeout=timeout, + on_output=on_output, + ) + ) + duration_ms = int((time.time() - start_time) * 1000) + try: + if ( + publication_owner == "gateway" + and before_gateway_publish is not None + and not await before_gateway_publish() + ): + raise RuntimeError( + "Sandbox publication ownership could not be verified" + ) + await self._verify_and_merge_outputs( + persistent.staging_path, + work_path, + agent_id=agent_id, + session_id=session_id, + publish_paths=publish_paths, + workspace_mode=workspace_mode, + 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=( + "sandbox_publication_unknown: " + f"{type(exc).__name__}" + ), + ) + finally: + if is_timeout: + await SubprocessBackend.close_run(run_id) + if is_timeout: + return ExecutionResult( + success=False, + stdout=stdout_str, + stderr=stderr_str, + exit_code=124, + duration_ms=duration_ms, + error=( + f"Code execution timed out after {timeout}s. " + "The Agent-loop sandbox was reset." + ), + ) + return ExecutionResult( + success=exit_code == 0, + stdout=stdout_str, + stderr=stderr_str, + exit_code=exit_code, + duration_ms=duration_ms, + error=None if exit_code == 0 else f"Exit code: {exit_code}", + ) + if ( + workspace_mode == "isolated_output" + or not self.config.allow_unsafe_fallback_when_bwrap_missing + ): + return ExecutionResult( + success=False, + stdout="", + stderr="", + exit_code=1, + duration_ms=int((time.time() - start_time) * 1000), + error=( + "bubblewrap (bwrap) is required for execute_code but " + "is not available." + ), + ) + except Exception as exc: + return ExecutionResult( + success=False, + stdout="", + stderr="", + exit_code=1, + duration_ms=int((time.time() - start_time) * 1000), + error=f"sandbox_persistent_execution_failed: {type(exc).__name__}", + ) + + # Legacy calls without a Runtime Run retain one-shot 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 / "workspace" / ".tmp").mkdir(parents=True, exist_ok=True) + # Determine command and file extension if language == "python": ext = ".py" @@ -439,17 +1237,30 @@ 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 / "workspace" / ".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 +1275,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 +1304,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 +1316,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 +1330,41 @@ 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, + workspace_mode=workspace_mode, + 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, @@ -540,7 +1383,6 @@ async def read_stream(stream, out, label="stdout"): duration_ms=duration_ms, error=None if proc.returncode == 0 else f"Exit code: {proc.returncode}" ) - except Exception as e: duration_ms = int((time.time() - start_time) * 1000) logger.exception("[Subprocess] Execution error") @@ -554,8 +1396,48 @@ 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 + + +async def close_subprocess_sandbox_run(run_id: str) -> None: + """Release all local sandbox resources associated with one Agent loop.""" + try: + await SubprocessBackend.close_run(run_id) + except Exception: + logger.exception( + "[Subprocess] Failed to close Agent-loop sandbox for run {}", + run_id, + ) + finally: + try: + await close_run_workspace(run_id) + except Exception: + logger.exception( + "[Subprocess] Failed to discard Agent-loop workspace for run {}", + run_id, + ) diff --git a/backend/app/services/sandbox/run_scope.py b/backend/app/services/sandbox/run_scope.py new file mode 100644 index 000000000..a1b0a8989 --- /dev/null +++ b/backend/app/services/sandbox/run_scope.py @@ -0,0 +1,9 @@ +"""Runtime scope for reusing one local sandbox during an Agent loop.""" + +from contextvars import ContextVar + + +sandbox_run_scope_id: ContextVar[str] = ContextVar( + "sandbox_run_scope_id", + default="", +) diff --git a/backend/app/services/sandbox/workspace_policy.py b/backend/app/services/sandbox/workspace_policy.py new file mode 100644 index 000000000..841735de1 --- /dev/null +++ b/backend/app/services/sandbox/workspace_policy.py @@ -0,0 +1,74 @@ +"""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"] +PublicationConflictMode = Literal["fail", "overwrite"] + + +@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 + if relative is None: + return None + workspace_relative = relative.removeprefix("workspace/") + return f"/workspace/{workspace_relative}" + + @property + def publication_conflict_mode(self) -> PublicationConflictMode: + """Return the durable write policy for this workspace mode.""" + return "overwrite" if self.mode == "isolated_output" else "fail" + + +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_command_worker.py b/backend/tests/test_agent_runtime_command_worker.py index e6ae83769..f799a758c 100644 --- a/backend/tests/test_agent_runtime_command_worker.py +++ b/backend/tests/test_agent_runtime_command_worker.py @@ -354,6 +354,7 @@ async def test_pre_command_side_effect_runs_after_claim_commit_and_before_graph( reader = _Reader(command=(None, observed), latest=(None,)) executor = _Executor(timeline) pre_handler = _PreCommandHandler(timeline) + close_sandbox = AsyncMock() worker = _worker( timeline=timeline, run=run, @@ -371,6 +372,10 @@ async def test_pre_command_side_effect_runs_after_claim_commit_and_before_graph( "app.services.agent_runtime.command_worker.mark_command_applied", new=AsyncMock(), ), + patch( + "app.services.agent_runtime.command_worker.close_subprocess_sandbox_run", + new=close_sandbox, + ), ): result = await worker.run_once() @@ -381,6 +386,7 @@ async def test_pre_command_side_effect_runs_after_claim_commit_and_before_graph( assert pre_handler.calls[0][1].id == command.id assert timeline.index("transaction_exit") < timeline.index("pre_command") assert timeline.index("pre_command") < timeline.index("executor_start") + close_sandbox.assert_awaited_once_with(str(run.id)) @pytest.mark.asyncio diff --git a/backend/tests/test_agent_runtime_model_step_service.py b/backend/tests/test_agent_runtime_model_step_service.py index 483c61a8e..7704526c2 100644 --- a/backend/tests/test_agent_runtime_model_step_service.py +++ b/backend/tests/test_agent_runtime_model_step_service.py @@ -2701,6 +2701,45 @@ async def complete(model_arg, *args, **kwargs): assert "runtime_failover_from_model_id" not in result.assistant_message +@pytest.mark.asyncio +async def test_unknown_primary_error_retries_on_same_model() -> None: + tenant_id = uuid.uuid4() + model = _model(tenant_id) + fallback = _model(tenant_id) + agent = _agent(tenant_id) + agent.fallback_model_id = fallback.id + state = _state(tenant_id, model, agent) + called_models: list[uuid.UUID] = [] + + async def complete(model_arg, *args, **kwargs): + del args, kwargs + called_models.append(model_arg.id) + if len(called_models) == 1: + raise json.JSONDecodeError("Expecting value", "", 0) + return LLMCompletionStep( + content="Recovered from malformed provider JSON", + tool_calls=(), + reasoning_content=None, + retry_instruction=None, + usage=TokenUsage(total_tokens=12), + ) + + result = await _failover_service( + model, + fallback, + agent, + _ContextBuilder(_build()), + complete, + ).complete_once(state, _context(state)) + + assert result.intent == "finish" + assert result.finish_content == "Recovered from malformed provider JSON" + assert called_models == [model.id, model.id] + assert result.assistant_message is not None + assert result.assistant_message["runtime_model_id"] == str(model.id) + assert "runtime_failover_from_model_id" not in result.assistant_message + + @pytest.mark.asyncio async def test_non_retryable_primary_error_never_calls_configured_fallback() -> None: tenant_id = uuid.uuid4() diff --git a/backend/tests/test_agent_runtime_run_compactor.py b/backend/tests/test_agent_runtime_run_compactor.py index 1f1d2d9ca..2e14c849c 100644 --- a/backend/tests/test_agent_runtime_run_compactor.py +++ b/backend/tests/test_agent_runtime_run_compactor.py @@ -743,6 +743,26 @@ async def complete(*_args, **_kwargs): assert raised.value.is_transient_compact_error is True +@pytest.mark.asyncio +async def test_unknown_provider_failure_is_typed_for_langgraph_retry() -> None: + state, context, tenant_id = _state( + [_normal("old", "old " * 300), _normal("current")] + ) + + async def complete(*_args, **_kwargs): + raise json.JSONDecodeError("Expecting value", "", 0) + + with pytest.raises(TransientRunCompactorError) as raised: + await _service( + model=_model(tenant_id), + completion=complete, + effective_budget=1_000, + current_tokens=900, + ).compact_if_needed(state, context) + + assert raised.value.is_transient_compact_error is True + + @pytest.mark.asyncio async def test_invalid_summary_is_deterministic_and_never_committed() -> None: state, context, tenant_id = _state( 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..197585828 100644 --- a/backend/tests/test_agent_tools_storage_workspace.py +++ b/backend/tests/test_agent_tools_storage_workspace.py @@ -324,6 +324,32 @@ async def test_flush_temp_workspace_only_writes_changed_files(monkeypatch): assert storage.files[f"{agent_id}/workspace/other.md"] == b"# Other\n" +@pytest.mark.asyncio +async def test_flush_temp_workspace_refreshes_manifest_for_reused_workspace(monkeypatch): + agent_id = uuid.uuid4() + storage_key = f"{agent_id}/workspace/input.md" + storage = MemoryStorageBackend({storage_key: b"first"}) + monkeypatch.setattr(agent_tools, "get_storage_backend", lambda: storage) + + temp_ws = await agent_tools._prepare_temp_workspace(agent_id, paths=["workspace"]) + try: + local_file = temp_ws.root / "workspace" / "input.md" + local_file.write_bytes(b"second") + first = await agent_tools.flush_temp_workspace(temp_ws) + first_token = temp_ws.manifest["workspace/input.md"].base_version_token + + local_file.write_bytes(b"first") + second = await agent_tools.flush_temp_workspace(temp_ws) + finally: + temp_ws.cleanup() + + assert first["updated"] == ["workspace/input.md"] + assert second["updated"] == ["workspace/input.md"] + assert storage.files[storage_key] == b"first" + assert temp_ws.manifest["workspace/input.md"].base_hash == agent_tools.content_hash_bytes(b"first") + assert temp_ws.manifest["workspace/input.md"].base_version_token != first_token + + @pytest.mark.asyncio async def test_flush_temp_workspace_fails_on_conflict(monkeypatch): agent_id = uuid.uuid4() @@ -344,6 +370,94 @@ 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_isolated_output_overwrites_unmanifested_existing_file(monkeypatch): + agent_id = uuid.uuid4() + session_path = f"workspace/output/{uuid.uuid4()}" + storage_key = f"{agent_id}/{session_path}/result.json" + storage = MemoryStorageBackend() + monkeypatch.setattr(agent_tools, "get_storage_backend", lambda: storage) + + temp_ws = await agent_tools._prepare_temp_workspace( + agent_id, + paths=[], + publish_paths=[session_path], + ) + try: + output_file = temp_ws.root / session_path / "result.json" + output_file.parent.mkdir(parents=True) + output_file.write_bytes(b"session-result") + await storage.write_bytes(storage_key, b"previous-result") + result = await agent_tools.flush_temp_workspace( + temp_ws, + conflict_mode="overwrite", + ) + finally: + temp_ws.cleanup() + + assert result["updated"] == [f"{session_path}/result.json"] + assert result["conflicted"] == [] + assert storage.files[storage_key] == b"session-result" + assert f"{session_path}/result.json" in temp_ws.manifest + + +@pytest.mark.asyncio +async def test_flush_isolated_output_deletes_newer_existing_file(monkeypatch): + agent_id = uuid.uuid4() + session_path = f"workspace/output/{uuid.uuid4()}" + storage_key = f"{agent_id}/{session_path}/result.json" + storage = MemoryStorageBackend({storage_key: b"materialized-result"}) + monkeypatch.setattr(agent_tools, "get_storage_backend", lambda: storage) + + temp_ws = await agent_tools._prepare_temp_workspace( + agent_id, + paths=[session_path], + publish_paths=[session_path], + ) + try: + (temp_ws.root / session_path / "result.json").unlink() + await storage.write_bytes(storage_key, b"newer-result") + result = await agent_tools.flush_temp_workspace( + temp_ws, + conflict_mode="overwrite", + ) + finally: + temp_ws.cleanup() + + assert result["deleted"] == [f"{session_path}/result.json"] + assert result["conflicted"] == [] + assert storage_key not in storage.files + assert f"{session_path}/result.json" not in temp_ws.manifest + + +@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..04d92b679 100644 --- a/backend/tests/test_base_dao.py +++ b/backend/tests/test_base_dao.py @@ -5,7 +5,12 @@ 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, + identity_membership_query, + tenant_context, +) from app.database import Base, _session_ctx @@ -22,6 +27,18 @@ class TenantScopedRecord(Base): tenant_id: Mapped[str] = mapped_column(String, nullable=False) +class IdentityMembershipRecord(Base): + """Mapped stand-in for User's controlled identity-membership exception.""" + + __tablename__ = "test_identity_membership_records" + __tenant_scoped__ = True + __identity_membership_tenant_bypass__ = True + + id: Mapped[str] = mapped_column(String, primary_key=True) + identity_id: Mapped[str] = mapped_column(String, nullable=False) + tenant_id: Mapped[str] = mapped_column(String, nullable=False) + + class RecordingSession: def __init__(self): self.added = [] @@ -136,3 +153,87 @@ 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_identity_membership_query_can_read_all_tenants_for_one_identity(): + engine = create_engine("sqlite://") + IdentityMembershipRecord.__table__.create(engine) + TenantScopedRecord.__table__.create(engine) + tenant_a = str(uuid.uuid4()) + tenant_b = str(uuid.uuid4()) + + with Session(engine) as session: + session.add_all( + [ + IdentityMembershipRecord( + id="membership-a", + identity_id="identity-1", + tenant_id=tenant_a, + ), + IdentityMembershipRecord( + id="membership-b", + identity_id="identity-1", + tenant_id=tenant_b, + ), + IdentityMembershipRecord( + id="other-identity", + identity_id="identity-2", + tenant_id=tenant_b, + ), + TenantScopedRecord(id="ordinary-a", tenant_id=tenant_a), + TenantScopedRecord(id="ordinary-b", tenant_id=tenant_b), + ] + ) + session.commit() + + with tenant_context(tenant_a): + memberships = session.scalars( + identity_membership_query( + select(IdentityMembershipRecord) + .where(IdentityMembershipRecord.identity_id == "identity-1") + .order_by(IdentityMembershipRecord.id) + ) + ).all() + ordinary_records = session.scalars( + identity_membership_query( + select(TenantScopedRecord).order_by(TenantScopedRecord.id) + ) + ).all() + + assert [record.id for record in memberships] == ["membership-a", "membership-b"] + assert [record.id for record in ordinary_records] == ["ordinary-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_builtin_tool_contracts.py b/backend/tests/test_builtin_tool_contracts.py index a11fd1210..707a3e70b 100644 --- a/backend/tests/test_builtin_tool_contracts.py +++ b/backend/tests/test_builtin_tool_contracts.py @@ -12,6 +12,7 @@ from app.services import agent_tools, tool_seeder from app.services.builtin_tool_definitions import ( + AGENT_RELATIVE_PATH_ARGUMENTS, BUILTIN_TOOL_DEFINITIONS, BUILTIN_TOOL_NAMES, BUILTIN_TOOL_SEEDS, @@ -99,6 +100,9 @@ def test_known_schema_contracts_match_handler_validation() -> None: assert write_file["properties"]["mode"]["enum"] == ["overwrite", "append"] assert write_file["properties"]["mode"]["default"] == "overwrite" assert write_file["required"] == ["path", "content"] + assert "Agent-root-relative" in write_file["properties"]["path"]["description"] + assert "never start" in write_file["properties"]["path"]["description"] + assert "Agent-root-relative" in upload_image["properties"]["file_path"]["description"] assert send_channel["required"] == ["target_member_id", "message"] assert send_platform["required"] == ["message"] assert send_platform["anyOf"] == [ @@ -118,6 +122,18 @@ def test_known_schema_contracts_match_handler_validation() -> None: assert "reauthorize" in import_mcp["properties"] +def test_all_agent_path_arguments_publish_the_relative_path_contract() -> None: + for tool_name, fields in AGENT_RELATIVE_PATH_ARGUMENTS.items(): + properties = builtin_model_definition(tool_name)["function"]["parameters"][ + "properties" + ] + for field in fields: + assert field in properties, f"{tool_name}.{field} is not defined" + description = properties[field]["description"] + assert "Agent-root-relative" in description + assert "never start" in description + + @pytest.mark.parametrize( "name", ["at", "finish", "wait", "group_query_members", "group_future_tool"], 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..eb412ba7f --- /dev/null +++ b/backend/tests/test_sandbox_execution_policy.py @@ -0,0 +1,402 @@ +"""Contracts for Session-scoped sandbox policy and Redis execution leases.""" + +import uuid +from types import SimpleNamespace + +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.base import ExecutionResult +from app.services.sandbox import execution_lease +from app.services.sandbox.execution_lease import SandboxExecutionLeaseStore +from app.services.sandbox.local.run_workspace import close_run_workspace +from app.services.sandbox.run_scope import sandbox_run_scope_id +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/output/{session_id}" + assert policy.materialized_paths == ("workspace", "memory", "skills") + assert policy.publication_conflict_mode == "overwrite" + + +def test_merge_policy_preserves_conflict_detection() -> None: + policy = build_workspace_policy( + mode="merge", + session_id=uuid.uuid4(), + default_paths=["workspace"], + ) + + assert policy.publication_conflict_mode == "fail" + + +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_isolated_output_prompt_directs_code_to_session_output_env() -> None: + original = { + "type": "function", + "function": { + "name": "execute_code", + "description": "Execute code.", + "parameters": { + "type": "object", + "properties": { + "code": {"type": "string", "description": "Code to execute"}, + }, + }, + }, + } + + patched = agent_tools._with_isolated_output_prompt(original) + + description = patched["function"]["description"] + code_description = patched["function"]["parameters"]["properties"]["code"]["description"] + for value in (description, code_description): + assert "CLAWITH_SESSION_OUTPUT_DIR" in value + assert "workspace/output//" in value + assert "/workspace/output//" not in value + assert "every model-visible path is relative" in value + assert "do not omit or duplicate any path segment" in value + assert "working directory is /" in value + assert "Other sandbox writes are temporary" in value + assert original["function"]["description"] == "Execute code." + + +@pytest.mark.asyncio +async def test_runtime_tools_apply_isolated_output_prompt(monkeypatch) -> None: + tool = { + "type": "function", + "function": { + "name": "execute_code", + "description": "Execute code.", + "parameters": { + "type": "object", + "properties": {"code": {"type": "string"}}, + }, + }, + } + + async def agent_tools_for_llm(_agent_id): + return [tool] + + async def tool_config(_agent_id, tool_name): + assert tool_name == "execute_code" + return {"workspace_mode": "isolated_output"} + + async def no_dynamic_mcp(_agent_id): + return set() + + monkeypatch.setattr(agent_tools, "get_agent_tools_for_llm", agent_tools_for_llm) + monkeypatch.setattr(agent_tools, "_get_tool_config", tool_config) + monkeypatch.setattr( + agent_tools, + "_get_runtime_dynamic_mcp_tool_names", + no_dynamic_mcp, + ) + + resolved = await agent_tools.get_runtime_agent_tools_for_llm(uuid.uuid4()) + + assert len(resolved) == 1 + description = resolved[0]["function"]["description"] + assert "CLAWITH_SESSION_OUTPUT_DIR" in description + assert "workspace/output//" in description + assert "/workspace/output//" not in description + + +@pytest.mark.asyncio +async def test_file_tools_reject_absolute_model_paths_before_storage() -> None: + outcome = await agent_tools.execute_builtin_tool_outcome( + "list_files", + {"path": "/workspace/output/session-1"}, + agent_id=uuid.uuid4(), + user_id=uuid.uuid4(), + ) + + assert isinstance(outcome, ToolExecutionOutcome) + assert outcome.status == "failed" + assert outcome.error_code == "workspace_path_invalid" + assert "workspace/output/report.md" in (outcome.result_summary or "") + + legacy_result = await agent_tools.execute_tool( + "read_file", + {"path": "/workspace/output/session-1/report.md"}, + agent_id=uuid.uuid4(), + user_id=uuid.uuid4(), + ) + assert "must be Agent-root-relative" in legacy_result + + +@pytest.mark.asyncio +async def test_isolated_execute_result_returns_agent_relative_output_path( + monkeypatch, + tmp_path, +) -> None: + session_id = uuid.uuid4() + output_path = f"workspace/output/{session_id}" + + class Backend: + name = "subprocess" + + async def execute(self, **_kwargs): + return ExecutionResult(True, "ok", "", 0, 1) + + def _format_result(self, _result): + return "ok" + + async def tool_config(*_args): + return {} + + monkeypatch.setattr(agent_tools, "_get_tool_config", tool_config) + monkeypatch.setattr( + "app.services.sandbox.registry.get_sandbox_backend", + lambda _config: Backend(), + ) + + outcome = await agent_tools._execute_code_outcome( + uuid.uuid4(), + tmp_path, + {"language": "python", "code": "print('ok')"}, + sandbox_config=SandboxConfig(workspace_mode="isolated_output"), + session_id=str(session_id), + publish_paths=[output_path], + ) + + assert outcome.status == "succeeded" + assert output_path in (outcome.result_summary or "") + assert f"/{output_path}" not in (outcome.result_summary or "") + assert outcome.metadata["workspace_path"] == output_path + + +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 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("publication_owner", ["gateway", "workspace_cas"]) +async def test_isolated_execution_uses_replacement_publication( + monkeypatch, + tmp_path, + publication_owner, +) -> None: + tenant_id = uuid.uuid4() + agent_id = uuid.uuid4() + session_id = uuid.uuid4() + conflict_modes = [] + prepare_count = 0 + cleanup_count = 0 + + class Lease: + ownership_lost = False + + async def start_heartbeat(self): + return None + + async def ensure_publication_window(self, _seconds): + return True + + async def release(self): + return None + + async def tool_config(*_args): + return { + "workspace_mode": "isolated_output", + "publication_owner": publication_owner, + } + + async def resolve_scope(**_kwargs): + return SandboxExecutionScope(tenant_id, agent_id, session_id) + + async def acquire(*_args, **_kwargs): + return Lease() + + async def prepare(*_args, **_kwargs): + nonlocal prepare_count, cleanup_count + prepare_count += 1 + + def cleanup(): + nonlocal cleanup_count + cleanup_count += 1 + + return SimpleNamespace(root=tmp_path, cleanup=cleanup) + + async def flush(_workspace, conflict_mode): + conflict_modes.append(conflict_mode) + return {"updated": [], "deleted": [], "conflicted": [], "skipped": []} + + async def execute(*_args, gateway_publish=None, **_kwargs): + if gateway_publish is not None and publication_owner == "gateway": + await gateway_publish() + 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", acquire) + monkeypatch.setattr(agent_tools, "_prepare_temp_workspace", prepare) + monkeypatch.setattr(agent_tools, "flush_temp_workspace", flush) + monkeypatch.setattr(agent_tools, "_execute_code_outcome", execute) + monkeypatch.setattr("app.config.get_sandbox_config", lambda: SandboxConfig()) + + run_id = str(uuid.uuid4()) + token = sandbox_run_scope_id.set(run_id) + try: + first = 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", + ) + second = 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(2)"}, + tool_name="execute_code", + ) + finally: + sandbox_run_scope_id.reset(token) + await close_run_workspace(run_id) + + assert first.status == "succeeded" + assert second.status == "succeeded" + assert conflict_modes == ["overwrite", "overwrite"] + assert prepare_count == 1 + assert cleanup_count == 1 diff --git a/backend/tests/test_sandbox_subprocess_backend.py b/backend/tests/test_sandbox_subprocess_backend.py index 8ef6ad915..eece192e3 100644 --- a/backend/tests/test_sandbox_subprocess_backend.py +++ b/backend/tests/test_sandbox_subprocess_backend.py @@ -1,13 +1,20 @@ """Local sandbox bootstrap must not block the Backend event loop.""" import asyncio +import signal +from types import SimpleNamespace +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, + close_subprocess_sandbox_run, +) @pytest.mark.asyncio @@ -75,6 +82,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", @@ -92,6 +133,17 @@ def test_subprocess_backend_proxy_env_propagation(tmp_path: Path) -> None: assert env.get("NO_PROXY") == "localhost,127.0.0.1" +def test_bash_commands_enable_pipefail(tmp_path: Path) -> None: + backend = SubprocessBackend(SandboxConfig()) + + assert backend._build_command("bash", "/workspace/.tmp/test.sh") == [ + "bash", "--noprofile", "--norc", "-o", "pipefail", "/workspace/.tmp/test.sh", + ] + assert backend._build_host_command("bash", tmp_path / "test.sh", tmp_path) == [ + "bash", "--noprofile", "--norc", "-o", "pipefail", str(tmp_path / "test.sh"), + ] + + def test_subprocess_backend_proxy_bwrap_command(monkeypatch, tmp_path: Path) -> None: monkeypatch.setattr("shutil.which", lambda cmd: "/usr/bin/bwrap" if cmd == "bwrap" else None) config = SandboxConfig( @@ -112,6 +164,117 @@ 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_uses_workspace_tool_paths_and_writable_copy(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] == "--bind" + assert cmd[root_index - 1] == str(staging / "workspace") + skills_index = cmd.index("/skills") + assert cmd[skills_index - 2] == "--bind" + assert cmd[skills_index - 1] == str(staging / "skills") + assert "/workspace/skills" not in cmd + 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 + env_index = cmd.index("CLAWITH_SESSION_OUTPUT_DIR") + assert cmd[env_index + 1] == f"workspace/output/{session_id}" + assert f"/workspace/{output_path}" not in cmd + virtual_env_index = cmd.index("VIRTUAL_ENV") + assert cmd[virtual_env_index + 1] == SANDBOX_VENV_PATH + chdir_index = cmd.index("--chdir") + assert cmd[chdir_index + 1] == "/" + + +@pytest.mark.asyncio +async def test_persistent_bwrap_session_is_reused_for_same_agent_loop( + monkeypatch, + tmp_path: Path, +) -> None: + backend = SubprocessBackend(SandboxConfig(workspace_mode="isolated_output")) + run_id = str(uuid.uuid4()) + agent_id = uuid.uuid4() + session_id = str(uuid.uuid4()) + publish_paths = [f"workspace/output/{session_id}"] + starts = 0 + persistent = SimpleNamespace( + process=SimpleNamespace(returncode=None), + agent_id=agent_id, + session_id=session_id, + workspace_mode="isolated_output", + publish_paths=tuple(publish_paths), + work_path=(tmp_path / "workspace").resolve(), + staging_path=tmp_path / "persistent", + ) + + async def start(**_kwargs): + nonlocal starts + starts += 1 + SubprocessBackend._run_sessions[run_id] = persistent + return persistent + + monkeypatch.setattr(backend, "_start_persistent_session", start) + SubprocessBackend._run_sessions.pop(run_id, None) + try: + first = await backend._persistent_session( + run_id=run_id, + work_path=tmp_path / "workspace", + venv_path=tmp_path / "venv", + agent_id=agent_id, + session_id=session_id, + workspace_mode="isolated_output", + publish_paths=publish_paths, + ) + second = await backend._persistent_session( + run_id=run_id, + work_path=tmp_path / "workspace", + venv_path=tmp_path / "venv", + agent_id=agent_id, + session_id=session_id, + workspace_mode="isolated_output", + publish_paths=publish_paths, + ) + finally: + SubprocessBackend._run_sessions.pop(run_id, None) + + assert first is persistent + assert second is persistent + assert starts == 1 + + +@pytest.mark.asyncio +async def test_close_subprocess_sandbox_run_releases_process_and_workspace(monkeypatch) -> None: + closed = [] + + async def close_process(run_id): + closed.append(("process", run_id)) + + async def close_workspace(run_id): + closed.append(("workspace", run_id)) + + monkeypatch.setattr(SubprocessBackend, "close_run", close_process) + monkeypatch.setattr(subprocess_backend, "close_run_workspace", close_workspace) + + await close_subprocess_sandbox_run("run-1") + + assert closed == [("process", "run-1"), ("workspace", "run-1")] + + def test_sandbox_config_proxy_parsing() -> None: data = { "http_proxy": "http://10.0.0.1:3128", @@ -123,3 +286,287 @@ 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 "