From 1253cea3e86ee17036b0b11d29ab7be0cb464fbd Mon Sep 17 00:00:00 2001 From: friday <78522128+fengnanrui@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:58:31 +0800 Subject: [PATCH] fix: harden tenant-safe runtime delivery --- .../v1_0_0_f061_enterprise_info_tenant_id.py | 83 ++++++++-- ...1_0_0_f062_chat_message_tenant_not_null.py | 62 ++++++++ backend/app/api/activity.py | 6 +- backend/app/api/admin.py | 16 +- backend/app/api/advanced.py | 10 +- backend/app/api/agent_credentials.py | 8 +- backend/app/api/agentbay_control.py | 16 +- backend/app/api/agents.py | 30 ++-- backend/app/api/atlassian.py | 8 +- backend/app/api/chat_sessions.py | 14 +- backend/app/api/dingtalk.py | 8 +- backend/app/api/directory.py | 18 +-- backend/app/api/discord_bot.py | 10 +- backend/app/api/enterprise.py | 143 ++++++++++++------ backend/app/api/feishu.py | 10 +- backend/app/api/files.py | 28 ++-- backend/app/api/focus.py | 6 +- backend/app/api/gateway.py | 11 +- backend/app/api/google_workspace.py | 4 +- backend/app/api/groups.py | 64 ++++---- backend/app/api/messages.py | 4 +- backend/app/api/notification.py | 10 +- backend/app/api/onboarding.py | 8 +- backend/app/api/organization.py | 4 +- backend/app/api/pages.py | 4 +- backend/app/api/relationships.py | 18 +-- backend/app/api/schedules.py | 12 +- backend/app/api/slack.py | 10 +- backend/app/api/sso.py | 8 +- backend/app/api/tasks.py | 12 +- backend/app/api/teams.py | 10 +- backend/app/api/tenants.py | 43 ++++-- backend/app/api/tools.py | 36 ++--- backend/app/api/users.py | 6 +- backend/app/api/websocket.py | 11 +- backend/app/api/wechat.py | 10 +- backend/app/api/wecom.py | 16 +- backend/app/api/whatsapp.py | 12 +- backend/app/config.py | 2 + backend/app/dao/chat_message_dao.py | 35 +++-- backend/app/models/audit.py | 4 +- backend/app/schemas/schemas.py | 11 +- backend/app/services/agent_manager.py | 7 +- .../services/agent_runtime/a2a_completion.py | 2 + .../app/services/agent_runtime/a2a_runtime.py | 2 + .../app/services/agent_runtime/chat_intake.py | 1 + .../app/services/agent_runtime/delivery.py | 1 + .../agent_runtime/trigger_completion.py | 1 + backend/app/services/agent_tools.py | 10 ++ backend/app/services/chat_session_service.py | 11 ++ backend/app/services/group_message_service.py | 2 + backend/app/services/heartbeat.py | 21 ++- .../app/services/trigger_runtime/intake.py | 1 + backend/tests/test_agent_runtime_delivery.py | 1 + .../test_api_database_dependency_contract.py | 42 +++++ .../test_chat_message_tenant_contract.py | 104 +++++++++++++ backend/tests/test_heartbeat_runtime.py | 39 +++++ .../pages/agent-detail/AgentDetailPage.tsx | 18 ++- .../pages/enterprise-settings/tabs/LlmTab.tsx | 23 ++- 59 files changed, 792 insertions(+), 335 deletions(-) create mode 100644 backend/alembic/versions/v1_0_0_f062_chat_message_tenant_not_null.py create mode 100644 backend/tests/test_api_database_dependency_contract.py create mode 100644 backend/tests/test_chat_message_tenant_contract.py 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..aa4dbc179 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 @@ -18,33 +18,84 @@ """ -from typing import Sequence, Union +from __future__ import annotations + +from collections.abc import Sequence -from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql +from alembic import op + # revision identifiers, used by Alembic. revision: str = "f061_enterprise_info_tenant_id" -down_revision: Union[str, None] = "f060_tenant_id_backfill" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | None = "f060_tenant_id_backfill" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None 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) + inspector = sa.inspect(op.get_bind()) - # 2. Drop legacy single info_type unique constraint - op.drop_constraint("enterprise_info_info_type_key", "enterprise_info", type_="unique") + # Fresh deployments build the current ORM schema in ``initial_schema``, so + # these objects may already exist before Alembic reaches this revision. + columns = {column["name"] for column in inspector.get_columns("enterprise_info")} + if "tenant_id" not in columns: + op.add_column( + "enterprise_info", + sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=True), + ) - # 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"]) + indexes = {index["name"] for index in inspector.get_indexes("enterprise_info")} + if op.f("ix_enterprise_info_tenant_id") not in indexes: + op.create_index( + op.f("ix_enterprise_info_tenant_id"), + "enterprise_info", + ["tenant_id"], + unique=False, + ) + + constraints = { + constraint["name"] + for constraint in inspector.get_unique_constraints("enterprise_info") + } + if "enterprise_info_info_type_key" in constraints: + op.drop_constraint( + "enterprise_info_info_type_key", + "enterprise_info", + type_="unique", + ) + if "uq_enterprise_info_tenant_type" not in constraints: + 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") + inspector = sa.inspect(op.get_bind()) + constraints = { + constraint["name"] + for constraint in inspector.get_unique_constraints("enterprise_info") + } + if "uq_enterprise_info_tenant_type" in constraints: + op.drop_constraint( + "uq_enterprise_info_tenant_type", + "enterprise_info", + type_="unique", + ) + if "enterprise_info_info_type_key" not in constraints: + op.create_unique_constraint( + "enterprise_info_info_type_key", + "enterprise_info", + ["info_type"], + ) + + indexes = {index["name"] for index in inspector.get_indexes("enterprise_info")} + if op.f("ix_enterprise_info_tenant_id") in indexes: + op.drop_index(op.f("ix_enterprise_info_tenant_id"), table_name="enterprise_info") + + columns = {column["name"] for column in inspector.get_columns("enterprise_info")} + if "tenant_id" in columns: + op.drop_column("enterprise_info", "tenant_id") diff --git a/backend/alembic/versions/v1_0_0_f062_chat_message_tenant_not_null.py b/backend/alembic/versions/v1_0_0_f062_chat_message_tenant_not_null.py new file mode 100644 index 000000000..6cad4d20d --- /dev/null +++ b/backend/alembic/versions/v1_0_0_f062_chat_message_tenant_not_null.py @@ -0,0 +1,62 @@ +"""Require tenant ownership for every chat message. + +Background: + ChatMessage is tenant-scoped, but its tenant_id column remained nullable after + the original backfill. Missing ownership therefore produced messages that + were committed successfully and then hidden by automatic tenant filtering. + +Scope: + Make chat_messages.tenant_id NOT NULL after all writers have been updated to + provide an explicit tenant. + +Idempotent: + Inspector checks the current nullability before changing the column. Existing + NULL rows intentionally stop the migration so operators can reconcile their + ownership out of band instead of assigning data to the wrong tenant. + +Revision ID: f062_chat_message_tenant_nn +Revises: f061_enterprise_info_tenant_id +Create Date: 2026-08-14 10:00:00.000000 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "f062_chat_message_tenant_nn" +down_revision: str | None = "f061_enterprise_info_tenant_id" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _tenant_column() -> dict: + inspector = sa.inspect(op.get_bind()) + return next( + column + for column in inspector.get_columns("chat_messages") + if column["name"] == "tenant_id" + ) + + +def upgrade() -> None: + if _tenant_column()["nullable"]: + op.alter_column( + "chat_messages", + "tenant_id", + existing_type=sa.UUID(as_uuid=True), + nullable=False, + ) + + +def downgrade() -> None: + if not _tenant_column()["nullable"]: + op.alter_column( + "chat_messages", + "tenant_id", + existing_type=sa.UUID(as_uuid=True), + nullable=True, + ) diff --git a/backend/app/api/activity.py b/backend/app/api/activity.py index 354e29d58..b1cab07a8 100644 --- a/backend/app/api/activity.py +++ b/backend/app/api/activity.py @@ -19,7 +19,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 +45,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 +59,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..e6d3c975e 100644 --- a/backend/app/api/advanced.py +++ b/backend/app/api/advanced.py @@ -37,7 +37,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 +49,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 +67,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 +164,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 +206,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..97aac8d92 100644 --- a/backend/app/api/agent_credentials.py +++ b/backend/app/api/agent_credentials.py @@ -53,7 +53,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 +73,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 +122,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 +178,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..70b615a1e 100644 --- a/backend/app/api/agentbay_control.py +++ b/backend/app/api/agentbay_control.py @@ -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..2f4d69858 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -137,7 +137,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 +196,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 +391,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 +574,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 +608,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 +706,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 +804,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 +885,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 +1001,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 +1092,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 +1110,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 +1132,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 +1171,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 +1199,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 +1219,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..9edf6aded 100644 --- a/backend/app/api/atlassian.py +++ b/backend/app/api/atlassian.py @@ -32,7 +32,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 +91,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 +110,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 +132,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..b847c42f1 100644 --- a/backend/app/api/chat_sessions.py +++ b/backend/app/api/chat_sessions.py @@ -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..2aad2c2f3 100644 --- a/backend/app/api/dingtalk.py +++ b/backend/app/api/dingtalk.py @@ -32,7 +32,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 +100,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 +119,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 +272,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..6599cfc75 100644 --- a/backend/app/api/directory.py +++ b/backend/app/api/directory.py @@ -56,7 +56,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 +79,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 +127,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 +184,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 +216,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 +243,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 +275,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 +321,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 +361,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..9f72ea848 100644 --- a/backend/app/api/discord_bot.py +++ b/backend/app/api/discord_bot.py @@ -28,7 +28,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 +98,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 +114,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 +124,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 +199,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..160537ddf 100644 --- a/backend/app/api/enterprise.py +++ b/backend/app/api/enterprise.py @@ -23,6 +23,7 @@ from app.services.org_sync_adapter import derive_member_department_paths from app.models.agent import Agent from app.models.llm import LLMModel +from app.models.tenant import Tenant from app.models.audit import AuditLog, ApprovalRequest, EnterpriseInfo from app.schemas.schemas import ( ApprovalAction, ApprovalRequestOut, AuditLogOut, EnterpriseInfoOut, @@ -106,6 +107,23 @@ def _llm_model_scope(model_id: uuid.UUID, current_user: User): return select(LLMModel).where(*conditions) +def _validate_llm_token_limits( + *, + max_output_tokens: int | None, + context_window_tokens: int | None, +) -> None: + """Ensure the configured context always leaves capacity for input tokens.""" + if ( + max_output_tokens is not None + and context_window_tokens is not None + and max_output_tokens >= context_window_tokens + ): + raise HTTPException( + status_code=422, + detail="Max output tokens must be smaller than the context window", + ) + + # ─── Public: Check Email Exists ──────────────────────── class CheckEmailRequest(BaseModel): @@ -115,7 +133,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 +406,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 +416,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,10 +433,14 @@ 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) + _validate_llm_token_limits( + max_output_tokens=data.max_output_tokens, + context_window_tokens=data.context_window_tokens, + ) model = LLMModel( provider=data.provider, model=data.model, @@ -430,6 +452,7 @@ async def add_llm_model( enabled=data.enabled, supports_vision=data.supports_vision, max_output_tokens=data.max_output_tokens, + context_window_tokens=data.context_window_tokens, request_timeout=data.request_timeout, tenant_id=tid, ) @@ -439,11 +462,19 @@ async def add_llm_model( # First enabled model for a tenant becomes that tenant's default. # Admins can later reassign via PATCH /llm-models/{id}/set-default. if model.tenant_id and model.enabled: - from app.models.tenant import Tenant t_result = await db.execute(select(Tenant).where(Tenant.id == model.tenant_id)) tenant = t_result.scalar_one_or_none() if tenant and tenant.default_model_id is None: tenant.default_model_id = model.id + await db.execute( + update(Agent) + .where( + Agent.tenant_id == tenant.id, + Agent.primary_model_id.is_(None), + Agent.deleted_at.is_(None), + ) + .values(primary_model_id=model.id) + ) return LLMModelOut.model_validate(model) @@ -452,7 +483,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)) @@ -464,7 +495,6 @@ async def set_default_llm_model( if not model.enabled: raise HTTPException(status_code=400, detail="Model is disabled") - from app.models.tenant import Tenant t_result = await db.execute(select(Tenant).where(Tenant.id == model.tenant_id)) tenant = t_result.scalar_one_or_none() if not tenant: @@ -481,12 +511,17 @@ async def set_default_llm_model( # default. They were "implicitly following the default" — make them # follow the new one. Agents whose model is something else (the user # explicitly picked it) are left alone. - if previous_default and previous_default != model.id: - from app.models.agent import Agent + if previous_default != model.id: + previous_model_condition = ( + Agent.primary_model_id.is_(None) + if previous_default is None + else Agent.primary_model_id == previous_default + ) await db.execute( update(Agent) .where(Agent.tenant_id == tenant.id) - .where(Agent.primary_model_id == previous_default) + .where(Agent.deleted_at.is_(None)) + .where(previous_model_condition) .values(primary_model_id=model.id) ) logger.info( @@ -501,7 +536,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 +571,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)) @@ -550,25 +585,33 @@ async def update_llm_model( model.provider = data.provider if data.model: model.model = data.model - if data.label is not None: + fields_set = data.model_fields_set + if "label" in fields_set and data.label is not None: model.label = data.label - if hasattr(data, 'base_url') and data.base_url is not None: + if "base_url" in fields_set: model.base_url = data.base_url if data.api_key and data.api_key.strip() and not data.api_key.startswith('****'): # Skip masked values model.api_key_encrypted = encrypt_data(data.api_key.strip(), settings.SECRET_KEY) - if data.temperature is not None: + if "temperature" in fields_set: model.temperature = data.temperature - if data.max_tokens_per_day is not None: + if "max_tokens_per_day" in fields_set: model.max_tokens_per_day = data.max_tokens_per_day if data.enabled is not None: model.enabled = data.enabled - if hasattr(data, 'supports_vision') and data.supports_vision is not None: + if "supports_vision" in fields_set and data.supports_vision is not None: model.supports_vision = data.supports_vision - if hasattr(data, 'max_output_tokens') and data.max_output_tokens is not None: + if "max_output_tokens" in fields_set: model.max_output_tokens = data.max_output_tokens - if hasattr(data, 'request_timeout') and data.request_timeout is not None: + if "context_window_tokens" in fields_set: + model.context_window_tokens = data.context_window_tokens + if "request_timeout" in fields_set: model.request_timeout = data.request_timeout + _validate_llm_token_limits( + max_output_tokens=model.max_output_tokens, + context_window_tokens=model.context_window_tokens, + ) + if _llm_config_fingerprint(model) != original_config_fingerprint: model.supports_tool_calling = None model.tool_calling_capability_source = None @@ -590,7 +633,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 +651,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 +672,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 +713,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 +733,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 +754,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 +814,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 +840,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 +897,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 +933,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 +958,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 +1076,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 +1088,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 +1129,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 +1149,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 +1165,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 +1283,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 +1437,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 +1488,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 +1554,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 +1622,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 +1679,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 +1718,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 +1785,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 +1868,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 +1902,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 +2047,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 +2076,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 +2142,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 +2186,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 +2225,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..e4507dbe4 100644 --- a/backend/app/api/feishu.py +++ b/backend/app/api/feishu.py @@ -87,7 +87,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 +181,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 +241,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 +256,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 +267,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..3a17ff284 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -227,7 +227,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 +293,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 +432,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 +563,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 +624,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 +669,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 +691,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 +705,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 +736,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 +776,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 +816,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 +866,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 +1079,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 +1134,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..2b9b3ac9c 100644 --- a/backend/app/api/focus.py +++ b/backend/app/api/focus.py @@ -48,7 +48,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 +59,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 +83,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..33abb44ac 100644 --- a/backend/app/api/gateway.py +++ b/backend/app/api/gateway.py @@ -63,7 +63,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 +219,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: @@ -270,6 +270,7 @@ async def report_result( db.add( ChatMessage( id=result_message_id, + tenant_id=agent.tenant_id, agent_id=agent.id, user_id=msg.sender_user_id or getattr(agent, "creator_id", agent.id), role="assistant", @@ -344,7 +345,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 +361,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 +573,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..0c0d946a4 100644 --- a/backend/app/api/google_workspace.py +++ b/backend/app/api/google_workspace.py @@ -39,7 +39,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 +195,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..80f638b05 100644 --- a/backend/app/api/messages.py +++ b/backend/app/api/messages.py @@ -27,7 +27,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 +85,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..39c4363e5 100644 --- a/backend/app/api/notification.py +++ b/backend/app/api/notification.py @@ -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..98afcb220 100644 --- a/backend/app/api/onboarding.py +++ b/backend/app/api/onboarding.py @@ -177,7 +177,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 +187,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 +199,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 +228,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..386636784 100644 --- a/backend/app/api/organization.py +++ b/backend/app/api/organization.py @@ -29,7 +29,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 +54,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..8eb26004b 100644 --- a/backend/app/api/pages.py +++ b/backend/app/api/pages.py @@ -24,7 +24,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 +64,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..99cde14d9 100644 --- a/backend/app/api/relationships.py +++ b/backend/app/api/relationships.py @@ -130,7 +130,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 +188,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 +298,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 +380,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 +406,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 +446,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 +481,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 +497,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 +542,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..74f5add75 100644 --- a/backend/app/api/schedules.py +++ b/backend/app/api/schedules.py @@ -56,7 +56,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 +85,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 +117,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 +151,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 +174,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 +217,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..d5e2ef36d 100644 --- a/backend/app/api/slack.py +++ b/backend/app/api/slack.py @@ -35,7 +35,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 +78,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 +94,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 +104,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 +157,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..c9aba98a4 100644 --- a/backend/app/api/sso.py +++ b/backend/app/api/sso.py @@ -25,7 +25,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 +50,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 +95,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 +105,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..3d5e6614e 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -35,7 +35,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 +66,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 +115,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 +135,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 +151,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 +166,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..c756218f9 100644 --- a/backend/app/api/teams.py +++ b/backend/app/api/teams.py @@ -267,7 +267,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 +332,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 +353,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 +366,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 +394,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..d028ad3e5 100644 --- a/backend/app/api/tenants.py +++ b/backend/app/api/tenants.py @@ -154,7 +154,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. @@ -183,12 +183,12 @@ async def self_create_company( access_token = None + from app.core.security import create_access_token from app.services.registration_service import registration_service if current_user.tenant_id is not None: # Multi-tenant: user already belongs to a company. # Create a NEW User record for the new tenant instead of overwriting. - from app.core.security import create_access_token from app.models.participant import Participant new_user = User( @@ -229,6 +229,13 @@ async def self_create_company( current_user.quota_agent_ttl_hours = tenant.default_agent_ttl_hours await query_dao.flush(db) await registration_service.bind_org_member(current_user) + # The registration token was issued before the user had a tenant. + # Replace it so tenant-scoped DAO calls work immediately after setup. + access_token = create_access_token( + str(current_user.id), + current_user.role, + tenant_id=str(current_user.tenant_id), + ) await query_dao.commit(db) @@ -255,7 +262,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. @@ -311,12 +318,12 @@ async def join_company( access_token = None + from app.core.security import create_access_token from app.services.registration_service import registration_service if current_user.tenant_id is not None: # Multi-tenant: user already belongs to a company. # Create a NEW User record for the new tenant. - from app.core.security import create_access_token from app.models.participant import Participant new_user = User( @@ -360,6 +367,12 @@ async def join_company( final_role = current_user.role await query_dao.flush(db) await registration_service.bind_org_member(current_user) + # Refresh the pre-tenant registration token with the joined tenant. + access_token = create_access_token( + str(current_user.id), + current_user.role, + tenant_id=str(current_user.tenant_id), + ) # Increment invitation code usage code_obj.used_count += 1 @@ -377,7 +390,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 +406,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 +474,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 +484,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 +502,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 +543,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 +565,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 +608,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 +650,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 +673,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 +702,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..939ffcc3c 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -229,7 +229,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 +274,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 +327,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 +351,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 +386,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 +409,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 +498,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 +542,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 +653,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 +705,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 +757,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 +791,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 +836,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 +874,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 +1017,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 +1100,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 +1157,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 +1181,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..e97f4274c 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -52,7 +52,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 +107,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 +169,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/websocket.py b/backend/app/api/websocket.py index c8223cf1c..308b47868 100644 --- a/backend/app/api/websocket.py +++ b/backend/app/api/websocket.py @@ -1161,7 +1161,10 @@ async def _run_runtime_and_stream( await self.websocket.send_json( _runtime_error_packet( code=getattr(exc, "code", "runtime_stream_failed"), - message="Runtime execution continues, but its live event stream was interrupted.", + message=( + "The chat response could not be restored from the live event stream. " + "Reconnect or retry to load the latest run state." + ), agent_id=self.agent_id, stage="stream", run_id=intake.handle.run_id, @@ -1365,6 +1368,11 @@ async def _check_quotas(self) -> bool: async def _save_user_message(self, content: str, display_content: str, file_name: str, is_onboarding_trigger: bool): """Saves user message to the database and updates session title/time.""" + if self.agent is None or self.agent.tenant_id is None: + raise ChatRuntimeIntakeError( + "chat_connection_not_ready", + "Web Chat connection has no authenticated tenant scope", + ) has_image_marker = "[image_data:" in content if has_image_marker: saved_content = f"[file:{file_name}]\n{content}" if file_name else content @@ -1384,6 +1392,7 @@ async def _save_user_message(self, content: str, display_content: str, file_name else: async with async_session() as db: user_msg = ChatMessage( + tenant_id=self.agent.tenant_id, agent_id=self.agent_id, user_id=self.user.id, role="user", diff --git a/backend/app/api/wechat.py b/backend/app/api/wechat.py index e9d16bf9b..a040bcdfd 100644 --- a/backend/app/api/wechat.py +++ b/backend/app/api/wechat.py @@ -58,7 +58,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 +85,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 +158,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 +178,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 +197,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..b642adbbb 100644 --- a/backend/app/api/wecom.py +++ b/backend/app/api/wecom.py @@ -112,7 +112,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 +156,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 +247,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 +272,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 +282,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 +314,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 +352,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 +608,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..671f61d51 100644 --- a/backend/app/api/whatsapp.py +++ b/backend/app/api/whatsapp.py @@ -54,7 +54,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 +106,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 +122,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 +133,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 +157,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 +178,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/config.py b/backend/app/config.py index 77d8f22f9..e84106f0c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -166,6 +166,8 @@ class Settings(BaseSettings): MAX_AGENT_CYCLE_COUNT: int = Field(default=5, gt=0) # Docker (for Agent containers) + AGENT_DOCKER_ENABLED: bool = True + AGENT_CONTAINER_PREFIX: str = "clawith-agent" DOCKER_NETWORK: str = "clawith_network" OPENCLAW_IMAGE: str = "openclaw:local" OPENCLAW_GATEWAY_PORT: int = 18789 diff --git a/backend/app/dao/chat_message_dao.py b/backend/app/dao/chat_message_dao.py index 7674cd6ea..69766712c 100644 --- a/backend/app/dao/chat_message_dao.py +++ b/backend/app/dao/chat_message_dao.py @@ -1,12 +1,4 @@ -"""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-safe DAO for the unified ChatMessage model.""" import uuid from collections.abc import Sequence @@ -18,14 +10,7 @@ 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). - """ + """DAO for ChatMessage entities with explicit tenant ownership.""" def __init__(self) -> None: super().__init__(ChatMessage) @@ -82,6 +67,7 @@ async def get_last_by_conversation( async def create_message( self, *, + tenant_id: uuid.UUID, agent_id: uuid.UUID | None, user_id: uuid.UUID | None, role: str, @@ -94,6 +80,7 @@ async def create_message( """Create a single chat message.""" async with self.session() as db: msg = ChatMessage( + tenant_id=tenant_id, agent_id=agent_id, user_id=user_id, role=role, @@ -110,7 +97,19 @@ async def create_message( 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] + missing_tenant = [index for index, message in enumerate(messages) if not message.get("tenant_id")] + if missing_tenant: + raise ValueError( + "Every ChatMessage requires tenant_id; missing at indexes " + + ", ".join(str(index) for index in missing_tenant) + ) + objs = [ + ChatMessage( + tenant_id=message["tenant_id"], + **{key: value for key, value in message.items() if key != "tenant_id"}, + ) + for message in messages + ] db.add_all(objs) await db.flush() return objs diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py index 1ebaf296e..b9fc84c5b 100644 --- a/backend/app/models/audit.py +++ b/backend/app/models/audit.py @@ -17,8 +17,8 @@ class AuditLog(Base): __tenant_scoped__ = True id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - tenant_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False, index=True ) user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id")) agent_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("agents.id")) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index bad676041..269063247 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -404,8 +404,9 @@ class LLMModelCreate(BaseModel): max_tokens_per_day: int | None = None enabled: bool = True supports_vision: bool = False - max_output_tokens: int | None = None - request_timeout: int | None = None + max_output_tokens: int | None = Field(None, gt=0) + context_window_tokens: int | None = Field(None, gt=0) + request_timeout: int | None = Field(None, gt=0) class LLMModelUpdate(BaseModel): provider: str | None = None @@ -417,8 +418,9 @@ class LLMModelUpdate(BaseModel): max_tokens_per_day: int | None = None enabled: bool | None = None supports_vision: bool | None = None - max_output_tokens: int | None = None - request_timeout: int | None = None + max_output_tokens: int | None = Field(None, gt=0) + context_window_tokens: int | None = Field(None, gt=0) + request_timeout: int | None = Field(None, gt=0) class LLMModelOut(BaseModel): @@ -437,6 +439,7 @@ class LLMModelOut(BaseModel): tool_calling_checked_at: datetime | None = None tool_calling_error: str | None = None max_output_tokens: int | None = None + context_window_tokens: int | None = None request_timeout: int | None = None created_at: datetime deleted_at: datetime | None = None diff --git a/backend/app/services/agent_manager.py b/backend/app/services/agent_manager.py index af3058413..f69b974b6 100644 --- a/backend/app/services/agent_manager.py +++ b/backend/app/services/agent_manager.py @@ -53,8 +53,13 @@ class AgentManager: """Manage OpenClaw Gateway Docker containers for digital employees.""" def __init__(self): + if not settings.AGENT_DOCKER_ENABLED: + logger.info("Agent Docker management disabled — using the built-in runtime") + self.docker_client = None + return try: self.docker_client = docker.from_env() + self.docker_client.ping() except DockerException: logger.warning("Docker not available — agent containers will not be managed") self.docker_client = None @@ -286,7 +291,7 @@ async def start_container(self, db: AsyncSession, agent: Agent) -> str | None: container = self.docker_client.containers.run( settings.OPENCLAW_IMAGE, detach=True, - name=f"clawith-agent-{str(agent.id)[:8]}", + name=f"{settings.AGENT_CONTAINER_PREFIX}-{str(agent.id)[:8]}", network=settings.DOCKER_NETWORK, ports={f"{settings.OPENCLAW_GATEWAY_PORT}/tcp": container_port}, volumes={ diff --git a/backend/app/services/agent_runtime/a2a_completion.py b/backend/app/services/agent_runtime/a2a_completion.py index c0e18e0bd..9e1dfef57 100644 --- a/backend/app/services/agent_runtime/a2a_completion.py +++ b/backend/app/services/agent_runtime/a2a_completion.py @@ -209,6 +209,7 @@ async def _handle_gateway_result( db.add( ChatMessage( id=receipt_id, + tenant_id=target_run.tenant_id, agent_id=session.agent_id, user_id=target_run.origin_user_id, role="assistant", @@ -377,6 +378,7 @@ async def handle( db.add( ChatMessage( id=receipt_id, + tenant_id=target_run.tenant_id, agent_id=session.agent_id, user_id=target_run.origin_user_id, role="assistant", diff --git a/backend/app/services/agent_runtime/a2a_runtime.py b/backend/app/services/agent_runtime/a2a_runtime.py index 1754e492d..67d923d85 100644 --- a/backend/app/services/agent_runtime/a2a_runtime.py +++ b/backend/app/services/agent_runtime/a2a_runtime.py @@ -625,6 +625,7 @@ async def enqueue_gateway_a2a_runtime( db.add( ChatMessage( id=chat_message_id, + tenant_id=tenant_id, agent_id=session.agent_id, user_id=owner_user_id, role="user", @@ -855,6 +856,7 @@ async def execute( db.add( ChatMessage( id=message_id, + tenant_id=tenant_id, agent_id=session.agent_id, user_id=owner_user_id, role="user", diff --git a/backend/app/services/agent_runtime/chat_intake.py b/backend/app/services/agent_runtime/chat_intake.py index bf82d0ce7..a4a140f37 100644 --- a/backend/app/services/agent_runtime/chat_intake.py +++ b/backend/app/services/agent_runtime/chat_intake.py @@ -433,6 +433,7 @@ async def _persist_user_message( group_message = session.session_type == "group" message = ChatMessage( id=message_id, + tenant_id=session.tenant_id, agent_id=None if group_message else agent.id, user_id=None if group_message else user.id, role="user", diff --git a/backend/app/services/agent_runtime/delivery.py b/backend/app/services/agent_runtime/delivery.py index fd4ca77b5..00387e6a1 100644 --- a/backend/app/services/agent_runtime/delivery.py +++ b/backend/app/services/agent_runtime/delivery.py @@ -906,6 +906,7 @@ async def deliver_runtime_message( else: message = ChatMessage( id=message_id, + tenant_id=run.tenant_id, agent_id=run.agent_id, user_id=session.user_id if session.session_type == "direct" else None, role="system" if participant is None else "assistant", diff --git a/backend/app/services/agent_runtime/trigger_completion.py b/backend/app/services/agent_runtime/trigger_completion.py index ccf55ac56..3d47d5b45 100644 --- a/backend/app/services/agent_runtime/trigger_completion.py +++ b/backend/app/services/agent_runtime/trigger_completion.py @@ -168,6 +168,7 @@ async def handle( db.add( ChatMessage( id=receipt_id, + tenant_id=stored_run.tenant_id, agent_id=stored_run.agent_id, user_id=session.user_id, role="assistant", diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index 0224cdf65..e4289d54c 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -8393,6 +8393,7 @@ async def _send_feishu_message_to_member_outcome( ) db.add( ChatMessage( + tenant_id=session.tenant_id, agent_id=agent_id, user_id=platform_user.id, role="assistant", @@ -8633,6 +8634,7 @@ async def _sync_proactive_channel_history( ) db.add( ChatMessage( + tenant_id=session.tenant_id, agent_id=agent_id, user_id=platform_user.id, role="assistant", @@ -8933,6 +8935,7 @@ async def _send_slack_message( first_message_title=message_text[:30], ) db.add(ChatMessage( + tenant_id=sess.tenant_id, agent_id=agent_id, user_id=platform_user.id, role="assistant", @@ -9012,6 +9015,7 @@ async def _send_teams_channel_message( ) db.add(ChatMessage( + tenant_id=session.tenant_id, agent_id=agent_id, user_id=platform_user.id, role="assistant", @@ -9094,6 +9098,7 @@ async def _send_wechat_channel_message( first_message_title=message_text[:30], ) db.add(ChatMessage( + tenant_id=sess.tenant_id, agent_id=agent_id, user_id=platform_user.id, role="assistant", @@ -9151,6 +9156,7 @@ async def _send_platform_message_outcome( ) db.add( ChatMessage( + tenant_id=session.tenant_id, agent_id=agent_id, user_id=target_user.id, role="assistant", @@ -9348,6 +9354,7 @@ async def _send_file_to_agent_outcome( ) source_agent_name = source_agent.name if source_agent else "Unknown agent" source_creator_id = source_agent.creator_id if source_agent else from_agent_id + source_tenant_id = source_agent.tenant_id target_agent, target_error = await _resolve_a2a_target_by_id(db, source_agent, target_agent_id) if target_error: @@ -9472,6 +9479,8 @@ async def _send_file_to_agent_outcome( ) src_participant = src_part_r.scalar_one_or_none() chat_session = ChatSession( + tenant_id=source_tenant_id, + session_type="a2a", agent_id=session_agent_id, user_id=source_creator_id, title=f"{source_name} ↔ {target_name}", @@ -9498,6 +9507,7 @@ async def _send_file_to_agent_outcome( src_part2 = src_part_r2.scalar_one_or_none() db2.add(ChatMessage( + tenant_id=chat_session.tenant_id, agent_id=session_agent_id, user_id=source_creator_id, role="user", diff --git a/backend/app/services/chat_session_service.py b/backend/app/services/chat_session_service.py index cb28e8aae..13e6ad912 100644 --- a/backend/app/services/chat_session_service.py +++ b/backend/app/services/chat_session_service.py @@ -411,7 +411,18 @@ async def save_tool_call_log( try: async with async_session() as db: + session_result = await db.execute( + select(ChatSession.tenant_id).where( + ChatSession.id == uuid.UUID(conversation_id), + ChatSession.agent_id == agent_id, + ChatSession.deleted_at.is_(None), + ) + ) + tenant_id = session_result.scalar_one_or_none() + if tenant_id is None: + raise ValueError("Tool call log session is unavailable or outside the Agent scope") db.add(ChatMessage( + tenant_id=tenant_id, agent_id=agent_id, user_id=user_id, role="tool_call", diff --git a/backend/app/services/group_message_service.py b/backend/app/services/group_message_service.py index 4b74f505b..0ad6f357f 100644 --- a/backend/app/services/group_message_service.py +++ b/backend/app/services/group_message_service.py @@ -438,6 +438,7 @@ async def _persist_message( message = ChatMessage( id=message_id, + tenant_id=scope.session.tenant_id, agent_id=scope.agent_id, user_id=scope.user_id, role=scope.role, @@ -590,6 +591,7 @@ async def _persist_planning_configuration_failure( created_at = clock + timedelta(microseconds=1) message = ChatMessage( id=message_id, + tenant_id=scope.session.tenant_id, agent_id=None, user_id=None, role="system", diff --git a/backend/app/services/heartbeat.py b/backend/app/services/heartbeat.py index c930356bd..84f97c068 100644 --- a/backend/app/services/heartbeat.py +++ b/backend/app/services/heartbeat.py @@ -246,6 +246,16 @@ async def _heartbeat_tick(): interval = timedelta(minutes=agent.heartbeat_interval_minutes or 240) if agent.last_heartbeat_at and (now - agent.last_heartbeat_at) < interval: continue + if agent.primary_model_id is None: + # A missing model is an administrator configuration state, + # not a transient Runtime failure. Record the attempt so the + # scheduler waits for the normal heartbeat interval. + agent.last_heartbeat_at = now + logger.warning( + "Heartbeat deferred for {}: no primary model is configured", + agent_name, + ) + continue runtime_decision = decide_runtime_v2( agent_id=agent.id, @@ -298,7 +308,16 @@ async def _heartbeat_tick(): ) await db.commit() except HeartbeatRuntimeIntakeError as exc: - logger.error( + # The nested claim rolls back on intake failure. Persist an + # attempt timestamp separately so the scheduler applies the + # normal interval as backoff instead of retrying every tick. + await db.execute( + update(Agent) + .where(Agent.id == agent_id) + .values(last_heartbeat_at=now) + ) + await db.commit() + logger.warning( "Heartbeat Runtime intake failed for {} ({}): {}", agent_name, exc.code, diff --git a/backend/app/services/trigger_runtime/intake.py b/backend/app/services/trigger_runtime/intake.py index 3b06663fd..47dd17890 100644 --- a/backend/app/services/trigger_runtime/intake.py +++ b/backend/app/services/trigger_runtime/intake.py @@ -182,6 +182,7 @@ async def _ensure_trigger_session( db.add( ChatMessage( id=message_id, + tenant_id=agent.tenant_id, agent_id=agent.id, conversation_id=str(session.id), role="user", diff --git a/backend/tests/test_agent_runtime_delivery.py b/backend/tests/test_agent_runtime_delivery.py index fd4dee9ab..029e9e8ab 100644 --- a/backend/tests/test_agent_runtime_delivery.py +++ b/backend/tests/test_agent_runtime_delivery.py @@ -288,6 +288,7 @@ async def test_direct_delivery_accepts_the_session_scoped_langgraph_thread() -> assert run.runtime_thread_id == str(session.id) assert receipt.status == "delivered" assert receipt.actual_session_id == session.id + assert _added(db, ChatMessage)[0].tenant_id == tenant_id assert _added(db, ChatMessage)[0].conversation_id == str(session.id) assert _added(db, ChatMessage)[0].thinking == "Checked the requested scope" diff --git a/backend/tests/test_api_database_dependency_contract.py b/backend/tests/test_api_database_dependency_contract.py new file mode 100644 index 000000000..da8feabf3 --- /dev/null +++ b/backend/tests/test_api_database_dependency_contract.py @@ -0,0 +1,42 @@ +"""Regression contract for FastAPI database-session injection.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +def test_database_route_parameters_use_fastapi_dependency_injection() -> None: + api_root = Path(__file__).parents[1] / "app" / "api" + missing: list[str] = [] + + for path in api_root.glob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if not any( + isinstance(decorator, ast.Call) + and isinstance(decorator.func, ast.Attribute) + and isinstance(decorator.func.value, ast.Name) + and decorator.func.value.id == "router" + for decorator in node.decorator_list + ): + continue + positional = [*node.args.posonlyargs, *node.args.args] + defaults = [None] * (len(positional) - len(node.args.defaults)) + list(node.args.defaults) + parameters = [*zip(positional, defaults), *zip(node.args.kwonlyargs, node.args.kw_defaults)] + for argument, default in parameters: + if argument.arg != "db": + continue + if not ( + isinstance(default, ast.Call) + and isinstance(default.func, ast.Name) + and default.func.id == "Depends" + and len(default.args) == 1 + and isinstance(default.args[0], ast.Name) + and default.args[0].id == "get_db" + ): + missing.append(f"{path.name}:{node.lineno}") + + assert missing == [] diff --git a/backend/tests/test_chat_message_tenant_contract.py b/backend/tests/test_chat_message_tenant_contract.py new file mode 100644 index 000000000..5654a0047 --- /dev/null +++ b/backend/tests/test_chat_message_tenant_contract.py @@ -0,0 +1,104 @@ +"""Regression contracts for tenant-safe chat persistence and LLM budgets.""" + +from __future__ import annotations + +import ast +import uuid +from datetime import UTC, datetime +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from app.api import enterprise +from app.models.audit import ChatMessage +from app.models.llm import LLMModel +from app.schemas.schemas import LLMModelUpdate + + +def test_chat_message_tenant_is_required_by_the_database_model() -> None: + assert ChatMessage.__table__.c.tenant_id.nullable is False + + +def test_every_product_chat_message_constructor_has_explicit_tenant() -> None: + app_root = Path(__file__).parents[1] / "app" + missing: list[str] = [] + for path in app_root.rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if not isinstance(node.func, ast.Name) or node.func.id != "ChatMessage": + continue + if not any(keyword.arg == "tenant_id" for keyword in node.keywords): + missing.append(f"{path.relative_to(app_root)}:{node.lineno}") + assert missing == [] + + +def test_shared_context_must_leave_room_for_input() -> None: + with pytest.raises(HTTPException) as raised: + enterprise._validate_llm_token_limits( + max_output_tokens=262_144, + context_window_tokens=262_144, + ) + assert raised.value.status_code == 422 + + +class _Result: + def __init__(self, model: LLMModel) -> None: + self.model = model + + def scalar_one_or_none(self) -> LLMModel: + return self.model + + +class _DB: + def __init__(self, model: LLMModel) -> None: + self.model = model + + async def execute(self, _statement) -> _Result: + return _Result(self.model) + + async def commit(self) -> None: + return None + + async def refresh(self, _model: LLMModel) -> None: + return None + + async def rollback(self) -> None: + raise AssertionError("valid update must not roll back") + + +@pytest.mark.asyncio +async def test_optional_llm_limits_can_be_cleared() -> None: + tenant_id = uuid.uuid4() + model = LLMModel( + id=uuid.uuid4(), + tenant_id=tenant_id, + provider="custom", + model="test-model", + api_key_encrypted="stored-key", + label="Test", + enabled=True, + supports_vision=False, + max_output_tokens=8_192, + context_window_tokens=131_072, + request_timeout=120, + created_at=datetime.now(UTC), + ) + + await enterprise.update_llm_model( + model.id, + LLMModelUpdate( + max_output_tokens=None, + context_window_tokens=None, + request_timeout=None, + ), + current_user=SimpleNamespace(tenant_id=tenant_id, role="org_admin"), + db=_DB(model), # type: ignore[arg-type] + ) + + assert model.max_output_tokens is None + assert model.context_window_tokens is None + assert model.request_timeout is None diff --git a/backend/tests/test_heartbeat_runtime.py b/backend/tests/test_heartbeat_runtime.py index aa3d591db..5088c2071 100644 --- a/backend/tests/test_heartbeat_runtime.py +++ b/backend/tests/test_heartbeat_runtime.py @@ -126,6 +126,44 @@ def test_default_heartbeat_prompt_does_not_advertise_hardcoded_tools() -> None: assert hardcoded_tool not in prompt +@pytest.mark.asyncio +async def test_missing_model_heartbeat_is_deferred_until_the_next_interval() -> None: + agent = _agent() + agent.primary_model_id = None + agent.last_heartbeat_at = None + agent.heartbeat_active_hours = "00:00-23:59" + + class _Result: + def scalars(self): + return self + + def all(self): + return [agent] + + class _HeartbeatSession: + async def execute(self, _statement): + return _Result() + + async def commit(self): + return None + + @asynccontextmanager + async def fake_session(): + yield _HeartbeatSession() + + enqueue = AsyncMock() + with ( + patch("app.database.async_session", new=fake_session), + patch("app.services.timezone_utils.get_agent_timezone_sync", return_value="UTC"), + patch("app.services.heartbeat_runtime.enqueue_heartbeat_runtime", new=enqueue), + patch("app.services.audit_logger.write_audit_log", new=AsyncMock()), + ): + await heartbeat_service._heartbeat_tick() + + assert agent.last_heartbeat_at is not None + enqueue.assert_not_awaited() + + @pytest.mark.asyncio async def test_heartbeat_intake_error_does_not_read_expired_agent_identity() -> None: class _ExpiringAgent: @@ -140,6 +178,7 @@ def __init__(self) -> None: self.heartbeat_active_hours = "00:00-23:59" self.heartbeat_interval_minutes = 1 self.last_heartbeat_at = None + self.primary_model_id = uuid.uuid4() @property def id(self): diff --git a/frontend/src/pages/agent-detail/AgentDetailPage.tsx b/frontend/src/pages/agent-detail/AgentDetailPage.tsx index da9b03626..74aab2a2d 100644 --- a/frontend/src/pages/agent-detail/AgentDetailPage.tsx +++ b/frontend/src/pages/agent-detail/AgentDetailPage.tsx @@ -2216,7 +2216,7 @@ export default function AgentDetailPage() { setActiveTab, } = useAgentDetailRoute({ agentId: id }); - const { data: agent, isLoading } = useQuery({ + const { data: agent, isLoading, isError, error, refetch: refetchAgent } = useQuery({ queryKey: ['agent', id], queryFn: () => agentApi.get(id!), enabled: !!id, @@ -4792,9 +4792,23 @@ export default function AgentDetailPage() { }, }); - if (isLoading || !agent) { + if (isLoading) { return
{t('common.loading')}
; } + if (isError || !agent) { + const message = error instanceof Error ? error.message : t('agentDetail.errorMessage'); + return ( +
+
+ {t('agentDetail.errorTitle')} +
+
{message}
+ +
+ ); + } // Compute display status (including OpenClaw disconnected detection) const computeStatusKey = () => { diff --git a/frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx b/frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx index e23b407cc..68d0bb62f 100644 --- a/frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx +++ b/frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx @@ -23,6 +23,7 @@ interface LLMModel { tool_calling_checked_at?: string | null; tool_calling_error?: string | null; max_output_tokens?: number; + context_window_tokens?: number; request_timeout?: number; temperature?: number; created_at: string; @@ -84,6 +85,7 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) { label: '', supports_vision: false, max_output_tokens: '' as string, + context_window_tokens: '' as string, request_timeout: '' as string, temperature: '' as string, }); @@ -232,6 +234,7 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) { label: '', supports_vision: false, max_output_tokens: defaultSpec ? String(defaultSpec.default_max_tokens) : '4096', + context_window_tokens: '', request_timeout: '', temperature: '', }); @@ -410,9 +413,14 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) { {t('enterprise.llm.supportsVisionDesc')} +
+ + setModelForm({ ...modelForm, context_window_tokens: e.target.value })} /> +
{t('enterprise.llm.contextWindowTokensDesc', 'Total input and output capacity. Leave empty to use the Runtime fallback.')}
+
- setModelForm({ ...modelForm, max_output_tokens: e.target.value })} /> + setModelForm({ ...modelForm, max_output_tokens: e.target.value })} />
{t('enterprise.llm.maxOutputTokensDesc', 'Limits generation length')}
@@ -433,6 +441,7 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) { addModel.mutate({ ...modelForm, max_output_tokens: modelForm.max_output_tokens ? Number(modelForm.max_output_tokens) : null, + context_window_tokens: modelForm.context_window_tokens ? Number(modelForm.context_window_tokens) : null, request_timeout: modelForm.request_timeout ? Number(modelForm.request_timeout) : null, temperature: modelForm.temperature !== '' ? Number(modelForm.temperature) : null, }); @@ -487,9 +496,13 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) { {t('enterprise.llm.supportsVisionDesc')}
+
+ + setModelForm({ ...modelForm, context_window_tokens: e.target.value })} /> +
- setModelForm({ ...modelForm, max_output_tokens: e.target.value })} /> + setModelForm({ ...modelForm, max_output_tokens: e.target.value })} />
@@ -509,6 +522,7 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) { data: { ...modelForm, max_output_tokens: modelForm.max_output_tokens ? Number(modelForm.max_output_tokens) : null, + context_window_tokens: modelForm.context_window_tokens ? Number(modelForm.context_window_tokens) : null, request_timeout: modelForm.request_timeout ? Number(modelForm.request_timeout) : null, temperature: modelForm.temperature !== '' ? Number(modelForm.temperature) : null, }, @@ -526,6 +540,10 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) { {m.provider}/{m.model} {m.base_url && · {m.base_url}}
+
+ {t('enterprise.llm.contextShort', 'Context')}: {m.context_window_tokens || t('common.default', 'Default')} + {' · '}{t('enterprise.llm.outputShort', 'Output')}: {m.max_output_tokens || t('common.default', 'Default')} +