diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e1b9e97dbb6..249c2932f65 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -51,7 +51,7 @@ /api/tests/*/controllers/trigger/ @CourTeous33 /api/tests/*/core/trigger/ @CourTeous33 /api/tests/*/trigger/ @CourTeous33 -/api/tests/*/controllers/console/billing/ @hj24 @zyssyz123 +/api/tests/*/controllers/console/billing/ @zyssyz123 /api/tests/*/services/enterprise/ @GareArc /api/tests/*/migrations/ @snakevash @laipz8200 @MRZHUH @@ -128,8 +128,8 @@ /api/tasks/async_workflow_tasks.py @Mairuis # Backend - Billing -/api/services/billing_service.py @hj24 @zyssyz123 -/api/controllers/console/billing/ @hj24 @zyssyz123 +/api/services/billing_service.py @zyssyz123 +/api/controllers/console/billing/ @zyssyz123 # Backend - Enterprise /api/configs/enterprise/ @GareArc @@ -147,6 +147,12 @@ # Frontend /web/ @iamjoel +# Frontend - Tooling +/lint.config.ts @hyoban @lyzno1 +/eslint.config.mjs @hyoban @lyzno1 +/knip.config.ts @hyoban @lyzno1 +/vite.config.ts @hyoban @lyzno1 + # Frontend - Platform and Features /web/config/ @lyzno1 /web/env.ts @lyzno1 diff --git a/.github/actions/setup-web/action.yml b/.github/actions/setup-web/action.yml index 01c525bf2e8..1b52e263710 100644 --- a/.github/actions/setup-web/action.yml +++ b/.github/actions/setup-web/action.yml @@ -14,5 +14,6 @@ runs: node-manager: false package-manager: | pnpm: false + npm: false cache: true run-install: true diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index f579862eb8b..15c245ff4e9 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -15,23 +15,29 @@ concurrency: jobs: api-unit: - name: API Unit Tests + name: API Unit Tests (${{ matrix.python-version }}, shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) runs-on: depot-ubuntu-24.04 env: - COVERAGE_FILE: coverage-unit + COVERAGE_FILE: coverage-unit-${{ matrix.python-version }}-${{ matrix.shardIndex }} defaults: run: shell: bash strategy: + fail-fast: false matrix: python-version: - '3.12' + shardIndex: + - 1 + - 2 + - 3 + shardTotal: + - 3 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 persist-credentials: false - name: Setup UV and Python @@ -53,6 +59,10 @@ jobs: -p no:benchmark \ --timeout "${PYTEST_TIMEOUT:-20}" \ -n auto \ + --shard-index "${{ matrix.shardIndex }}" \ + --shard-total "${{ matrix.shardTotal }}" \ + -o 'addopts=--cov=./api --cov-branch --import-mode=importlib' \ + --cov-report= \ api/tests/unit_tests \ api/providers/vdb/*/tests/unit_tests \ api/providers/trace/*/tests/unit_tests \ @@ -60,14 +70,19 @@ jobs: # Controller tests register Flask routes at import time, so keep them out of xdist. uv run --project api pytest \ --timeout "${PYTEST_TIMEOUT:-20}" \ + --shard-index "${{ matrix.shardIndex }}" \ + --shard-total "${{ matrix.shardTotal }}" \ + -o 'addopts=--cov=./api --cov-branch --import-mode=importlib' \ + --cov-report= \ --cov-append \ api/tests/unit_tests/controllers - name: Upload unit coverage data uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: api-coverage-unit - path: coverage-unit + name: api-coverage-unit-${{ matrix.python-version }}-${{ matrix.shardIndex }} + path: ${{ env.COVERAGE_FILE }} + if-no-files-found: error retention-days: 1 api-integration: @@ -98,7 +113,6 @@ jobs: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 persist-credentials: false - name: Setup UV and Python @@ -122,6 +136,8 @@ jobs: -n auto \ --shard-index "${{ matrix.shardIndex }}" \ --shard-total "${{ matrix.shardTotal }}" \ + -o 'addopts=--cov=./api --cov-branch --import-mode=importlib' \ + --cov-report= \ --timeout "${PYTEST_TIMEOUT:-180}" \ api/tests/integration_tests/workflow \ api/tests/integration_tests/tools \ @@ -181,16 +197,19 @@ jobs: echo "Merged backend coverage report generated for Codecov project status." >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" - unit_coverage="$(find coverage-data -type f -name coverage-unit -print -quit)" + mapfile -t unit_coverage < <(find coverage-data -type f -name 'coverage-unit-*' -print | sort) mapfile -t integration_coverage < <(find coverage-data -type f -name 'coverage-integration-*' -print | sort) - : "${unit_coverage:?coverage-unit artifact not found}" + if [[ "${#unit_coverage[@]}" -ne 3 ]]; then + echo "expected 3 unit coverage artifacts, found ${#unit_coverage[@]}" >&2 + exit 1 + fi if [[ "${#integration_coverage[@]}" -ne 4 ]]; then echo "expected 4 integration coverage artifacts, found ${#integration_coverage[@]}" >&2 exit 1 fi report_file="$(mktemp)" - uv run --project api coverage combine "$unit_coverage" "${integration_coverage[@]}" + uv run --project api coverage combine "${unit_coverage[@]}" "${integration_coverage[@]}" uv run --project api coverage report --show-missing | tee "$report_file" echo "Summary: \`$(tail -n 1 "$report_file")\`" >> "$GITHUB_STEP_SUMMARY" { diff --git a/README.md b/README.md index c3f68564c85..9f74c251e8b 100644 --- a/README.md +++ b/README.md @@ -104,8 +104,7 @@ Intuitive interface for crafting prompts, comparing model performance, and addin **4. RAG Pipeline**: Extensive RAG capabilities that cover everything from document ingestion to retrieval, with out-of-box support for text extraction from PDFs, PPTs, and other common document formats. -**5. Agent capabilities**: -You can define agents based on LLM Function Calling or ReAct, and add pre-built or custom tools for the agent. Dify provides 50+ built-in tools for AI agents, such as Google Search, DALL·E, Stable Diffusion and WolframAlpha. +**5. Agent**: Autonomous agents with a sandbox of their own: they run commands, install software, and handle files to get open-ended tasks done. Describe the agent you want and it builds itself. Give it skills, connect tools from [Dify Marketplace](https://marketplace.dify.ai/?category=tool), MCP servers, or your own APIs, and put it to work as a chat app or as a step in any workflow. **6. LLMOps**: Monitor and analyze application logs and performance over time. You could continuously improve prompts, datasets, and models based on production data and annotations. diff --git a/api/.env.example b/api/.env.example index 0bf7b9493ac..7d3ff606049 100644 --- a/api/.env.example +++ b/api/.env.example @@ -460,6 +460,12 @@ UPLOAD_IMAGE_FILE_SIZE_LIMIT=10 UPLOAD_VIDEO_FILE_SIZE_LIMIT=100 UPLOAD_AUDIO_FILE_SIZE_LIMIT=50 UPLOAD_SKILL_FILE_SIZE_LIMIT=50 +SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES=209715200 +SKILL_PACKAGE_MAX_ENTRIES=5000 +SKILL_PACKAGE_MAX_SKILL_MD_BYTES=1048576 +AGENT_PACKAGE_MAX_BYTES=536870912 +AGENT_PACKAGE_MAX_MANIFEST_BYTES=5242880 +AGENT_PACKAGE_MAX_ENTRIES=5000 # Comma-separated list of file extensions blocked from upload for security reasons. # Extensions should be lowercase without dots (e.g., exe,bat,sh,dll). diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 2ff2fedaaaa..564d8b84886 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -481,6 +481,36 @@ class FileUploadConfig(BaseSettings): default=50, ) + SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES: PositiveInt = Field( + description="Maximum total uncompressed size of a Skill package in bytes", + default=200 * 1024 * 1024, + ) + + SKILL_PACKAGE_MAX_ENTRIES: PositiveInt = Field( + description="Maximum number of entries in a Skill package", + default=5000, + ) + + SKILL_PACKAGE_MAX_SKILL_MD_BYTES: PositiveInt = Field( + description="Maximum allowed SKILL.md size in bytes", + default=1 * 1024 * 1024, + ) + + AGENT_PACKAGE_MAX_BYTES: PositiveInt = Field( + description="Maximum allowed Agent package size in bytes", + default=512 * 1024 * 1024, + ) + + AGENT_PACKAGE_MAX_MANIFEST_BYTES: PositiveInt = Field( + description="Maximum allowed size of each Agent package YAML document in bytes", + default=5 * 1024 * 1024, + ) + + AGENT_PACKAGE_MAX_ENTRIES: PositiveInt = Field( + description="Maximum number of entries in an Agent package", + default=5000, + ) + BATCH_UPLOAD_LIMIT: NonNegativeInt = Field( description="Maximum number of files allowed in a batch upload operation", default=20, diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index ad6961ef074..28baa5b01a5 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -4,6 +4,7 @@ from collections.abc import Sequence from datetime import datetime from typing import Any, Literal +from flask import send_file from flask_restx import Resource from pydantic import AliasChoices, BaseModel, Field, ValidationInfo, computed_field, field_validator, model_validator from sqlalchemy import select @@ -47,10 +48,12 @@ from extensions.ext_application_services import application_services from extensions.ext_database import db from fields.base import ResponseModel from graphon.enums import WorkflowExecutionStatus +from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY from libs.helper import build_icon_url, dump_response, to_timestamp from libs.login import login_required from models import Account, App, DatasetPermissionEnum, Workflow -from models.model import IconType +from models.model import AppMode, IconType +from services.agent.roster_package_exporter import RosterAgentPackageExporter from services.app_dsl_service import AppDslService from services.app_service import ( AppListParams, @@ -180,6 +183,9 @@ class CopyAppPayload(BaseModel): class AppExportQuery(BaseModel): + format: Literal["yaml", "ifpkg"] | None = Field( + default=None, description="Export format; defaults to ifpkg for Agent Apps and yaml for other Apps" + ) include_secret: bool = Field(default=False, description="Include secrets in export") workflow_id: str | None = Field(default=None, description="Specific workflow ID to export") @@ -1027,6 +1033,10 @@ class AppExportApi(Resource): @console_ns.doc(description="Export application configuration as DSL") @console_ns.doc(params={"app_id": "Application ID to export"}) @console_ns.doc(params=query_params_from_model(AppExportQuery)) + @console_ns.doc( + produces=["application/json", "application/zip"], + vendor={BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY: ["application/zip"]}, + ) @console_ns.response(200, "App exported successfully", console_ns.models[AppExportResponse.__name__]) @console_ns.response(403, "Insufficient permissions") @setup_required @@ -1042,6 +1052,23 @@ class AppExportApi(Resource): def get(self, req_data: AppExportQuery, app_model: App): """Export app""" + if req_data.format == "ifpkg" or (req_data.format is None and app_model.mode == AppMode.AGENT): + if app_model.mode != AppMode.AGENT: + raise BadRequest("The ifpkg format is only available for Agent Apps") + agent_id = app_model.bound_agent_id_with_session(session=db.session()) + if agent_id is None: + raise NotFound("Agent not found") + exported = RosterAgentPackageExporter().export(tenant_id=app_model.tenant_id, agent_id=agent_id) + try: + archive_response = send_file( + exported.archive, mimetype="application/zip", as_attachment=True, download_name=exported.filename + ) + except Exception: + exported.close() + raise + archive_response.call_on_close(exported.close) + return archive_response + response = AppExportResponse( data=AppDslService.export_dsl( app_model=app_model, diff --git a/api/controllers/console/app/app_import.py b/api/controllers/console/app/app_import.py index 18de98f8d9a..4872947b4dd 100644 --- a/api/controllers/console/app/app_import.py +++ b/api/controllers/console/app/app_import.py @@ -1,3 +1,7 @@ +from typing import BinaryIO, Literal, cast +from uuid import uuid4 + +from flask import request from flask_restx import Resource from pydantic import BaseModel, Field from sqlalchemy.orm import Session @@ -5,23 +9,27 @@ from werkzeug.exceptions import Forbidden from configs import dify_config from controllers.common.rbac import PlainApp, RBACCheck, Workspace -from controllers.common.schema import register_enum_models, register_schema_models +from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( RBACPermission, account_initialization_required, cloud_edition_billing_resource_check, edit_permission_required, - model_validate, rbac_permission_required, setup_required, + validate_request, with_current_user, ) +from core.plugin.entities.plugin import PluginDependency from extensions.ext_database import db from extensions.ext_redis import redis_client +from fields.base import ResponseModel from libs.login import current_account_with_tenant, login_required from models.account import Account -from models.model import App +from models.model import App, AppMode +from services.agent.errors import InvalidRosterAgentPackageError +from services.agent.roster_package_importer import RosterAgentPackageImporter from services.app_dsl_service import ( IMPORT_INFO_REDIS_KEY_PREFIX, AppDslService, @@ -49,8 +57,16 @@ class AppImportPayload(BaseModel): app_id: str | None = Field(None) +class RosterAgentPackageConflictResponse(ResponseModel): + code: str + message: str + status: Literal[409] = 409 + leaked_dependencies: list[PluginDependency] = Field(default_factory=list) + + register_enum_models(console_ns, ImportStatus) register_schema_models(console_ns, AppImportPayload, Import, CheckDependenciesResult) +register_response_schema_models(console_ns, RosterAgentPackageConflictResponse) def _current_user_and_tenant_id(current_user: Account | None) -> tuple[Account, str | None]: @@ -73,19 +89,74 @@ def _current_user_and_tenant_id(current_user: Account | None) -> tuple[Account, @console_ns.route("/apps/imports") class AppImportApi(Resource): - @console_ns.expect(console_ns.models[AppImportPayload.__name__]) + @console_ns.doc( + params={ + "payload": { + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/definitions/AppImportPayload"}}, + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "Roster Agent .ifpkg archive", + } + }, + "required": ["file"], + } + }, + }, + } + }, + ) @console_ns.response(200, "Import completed", console_ns.models[Import.__name__]) @console_ns.response(202, "Import pending confirmation", console_ns.models[Import.__name__]) @console_ns.response(400, "Import failed", console_ns.models[Import.__name__]) + @console_ns.response(403, "Insufficient import or plugin installation permissions") + @console_ns.response( + 409, "Agent name conflict or missing plugins", console_ns.models[RosterAgentPackageConflictResponse.__name__] + ) + @console_ns.response(413, "Roster Agent package exceeds the size limit") @setup_required @login_required @account_initialization_required - @cloud_edition_billing_resource_check("apps") @edit_permission_required - @rbac_permission_required(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())) @with_current_user - @model_validate(AppImportPayload) - def post(self, req_data: AppImportPayload, current_user: Account | None = None): + def post(self, current_user: Account): + if request.mimetype == "multipart/form-data": + return self._import_package(current_user) + return self._import_dsl(validate_request(AppImportPayload), current_user) + + @rbac_permission_required(RBACCheck(RBACPermission.AGENT_CREATE, Workspace())) + @rbac_permission_required(RBACCheck(RBACPermission.AGENT_IMPORT_EXPORT_DSL, Workspace())) + def _import_package(self, current_user: Account): + uploaded = request.files.get("file") + if uploaded is None or not uploaded.filename: + raise InvalidRosterAgentPackageError("Roster Agent package file is required") + if not uploaded.filename.lower().endswith(".ifpkg"): + raise InvalidRosterAgentPackageError("Roster Agent package file must use the .ifpkg extension") + if request.form.get("app_id"): + raise InvalidRosterAgentPackageError("Roster Agent package import does not support overwriting an App") + account, tenant_id = _current_user_and_tenant_id(current_user) + if tenant_id is None: + raise Forbidden("Current workspace is required") + result = RosterAgentPackageImporter().import_package( + source=cast(BinaryIO, uploaded.stream), tenant_id=tenant_id, account=account + ) + return Import( + id=str(uuid4()), + status=ImportStatus.COMPLETED_WITH_WARNINGS if result.warnings else ImportStatus.COMPLETED, + app_id=result.app_id, + app_mode=AppMode.AGENT, + warnings=result.warnings, + ).model_dump(mode="json"), 200 + + @cloud_edition_billing_resource_check("apps") + @rbac_permission_required(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())) + def _import_dsl(self, req_data: AppImportPayload, current_user: Account | None = None): current_user = current_user if current_user is not None else _current_user_and_tenant_id(None)[0] # AppDslService performs internal commits for some creation paths, so use a plain diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py index 8a6ab77b3dd..d6615124cb3 100644 --- a/api/controllers/console/app/mcp_server.py +++ b/api/controllers/console/app/mcp_server.py @@ -123,7 +123,7 @@ class AppMCPServerController(Resource): status=AppMCPServerStatus.ACTIVE, app_id=app_model.id, tenant_id=current_tenant_id, - server_code=AppMCPServer.generate_server_code(16), + server_code=AppMCPServer.generate_server_code(16, session=db.session()), ) db.session.add(server) db.session.commit() @@ -201,6 +201,6 @@ class AppMCPServerRefreshController(Resource): ) if not server: raise NotFound() - server.server_code = AppMCPServer.generate_server_code(16) + server.server_code = AppMCPServer.generate_server_code(16, session=db.session()) db.session.commit() return dump_response(AppMCPServerResponse, server) diff --git a/api/controllers/console/auth/activate.py b/api/controllers/console/auth/activate.py index 69ed1899f2e..685342d5a67 100644 --- a/api/controllers/console/auth/activate.py +++ b/api/controllers/console/auth/activate.py @@ -7,6 +7,7 @@ from controllers.common.schema import query_params_from_model, register_schema_m from controllers.console import console_ns from controllers.console.auth.error import InvitationAccountMismatchError as InvitationAccountMismatchHTTPError from controllers.console.error import AccountInFreezeError, AlreadyActivateError, EmailDomainSuspendedError +from controllers.console.wraps import model_validate from extensions.ext_application_services import application_services from libs.helper import EmailStr, dump_response, timezone from libs.login import current_account_with_tenant @@ -88,8 +89,8 @@ class ActivateCheckApi(Resource): "Success", console_ns.models[ActivationCheckResponse.__name__], ) - def get(self): - args = ActivateCheckQuery.model_validate(request.args.to_dict(flat=True)) + @model_validate(ActivateCheckQuery) + def get(self, args: ActivateCheckQuery): result = application_services().account_activation.check( InvitationLookup( workspace_id=args.workspace_id, diff --git a/api/controllers/console/snippets/snippet_workflow_draft_variable.py b/api/controllers/console/snippets/snippet_workflow_draft_variable.py index ebf456edf5c..7091cd24c24 100644 --- a/api/controllers/console/snippets/snippet_workflow_draft_variable.py +++ b/api/controllers/console/snippets/snippet_workflow_draft_variable.py @@ -14,7 +14,7 @@ from collections.abc import Callable from functools import wraps from typing import Any, Concatenate -from flask import Response, request +from flask import Response from flask_restx import Resource from sqlalchemy.orm import Session, sessionmaker @@ -106,9 +106,10 @@ class SnippetWorkflowVariableCollectionApi(Resource): console_ns.models[WorkflowDraftVariableListWithoutValueResponse.__name__], ) @_snippet_draft_var_prerequisite - def get(self, current_user: Account, snippet: CustomizedSnippet) -> dict[str, Any]: - args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore - + @model_validate(WorkflowDraftVariableListQuery) + def get( + self, args: WorkflowDraftVariableListQuery, current_user: Account, snippet: CustomizedSnippet + ) -> dict[str, Any]: snippet_service = _snippet_service() if snippet_service.get_draft_workflow(snippet=snippet) is None: raise DraftWorkflowNotExist() diff --git a/api/controllers/files/upload_file_delivery.py b/api/controllers/files/upload_file_delivery.py index 044e2c1a8fa..e1424d383d7 100644 --- a/api/controllers/files/upload_file_delivery.py +++ b/api/controllers/files/upload_file_delivery.py @@ -2,7 +2,7 @@ import os from urllib.parse import quote from uuid import UUID -from flask import Response, request +from flask import Response from flask_restx import Resource from pydantic import BaseModel, Field from werkzeug.exceptions import NotFound @@ -10,6 +10,7 @@ from werkzeug.exceptions import NotFound from controllers.common.errors import UnsupportedFileTypeError from controllers.common.file_response import enforce_download_for_html from controllers.common.schema import query_params_from_model, register_schema_models +from controllers.console.wraps import model_validate from controllers.files import files_ns from extensions.ext_application_services import application_services from services.errors.file import UnsupportedFileTypeError as UnsupportedFileTypeServiceError @@ -74,13 +75,13 @@ class ImagePreviewApi(Resource): @files_ns.doc( responses={ 200: "Image preview returned successfully", - 400: "Missing or invalid query parameters", 404: "File not found or signature is invalid", 415: "Unsupported file type", + 422: "Missing or invalid query parameters", } ) - def get(self, file_id: UUID) -> Response: - args = FileSignatureQuery.model_validate(request.args.to_dict(flat=True)) + @model_validate(FileSignatureQuery) + def get(self, args: FileSignatureQuery, file_id: UUID) -> Response: try: delivery = application_services().upload_file_delivery.get_signed_image_preview( file_id=str(file_id), @@ -109,13 +110,12 @@ class FilePreviewApi(Resource): @files_ns.doc( responses={ 200: "File stream returned successfully", - 400: "Missing or invalid query parameters", 404: "File not found or signature is invalid", + 422: "Missing or invalid query parameters", } ) - def get(self, file_id: UUID) -> Response: - args = FilePreviewQuery.model_validate(request.args.to_dict(flat=True)) - + @model_validate(FilePreviewQuery) + def get(self, args: FilePreviewQuery, file_id: UUID) -> Response: try: delivery = application_services().upload_file_delivery.get_signed_file_preview( file_id=str(file_id), diff --git a/api/controllers/mcp/mcp.py b/api/controllers/mcp/mcp.py index 95aa2be8218..fb4912b0741 100644 --- a/api/controllers/mcp/mcp.py +++ b/api/controllers/mcp/mcp.py @@ -88,7 +88,7 @@ class MCPAppApi(Resource): self._validate_server_status(mcp_server) # Get user input form - user_input_form = self._get_user_input_form(app) + user_input_form = self._get_user_input_form(app, session) # Handle notification vs request differently return self._process_mcp_message( @@ -180,17 +180,19 @@ class MCPAppApi(Resource): return helper.compact_generate_response(result.model_dump(by_alias=True, mode="json", exclude_none=True)) - def _get_user_input_form(self, app: App) -> list[VariableEntity]: + def _get_user_input_form(self, app: App, session: Session) -> list[VariableEntity]: """Get and convert user input form""" # Get raw user input form based on app mode if app.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: - if not app.workflow: + workflow = app.workflow_with_session(session=session) + if not workflow: raise MCPRequestError(mcp_types.INVALID_REQUEST, "App is unavailable") - raw_user_input_form = app.workflow.user_input_form(to_old_structure=True) + raw_user_input_form = workflow.user_input_form(to_old_structure=True) else: - if not app.app_model_config: + app_model_config = app.app_model_config_with_session(session=session) + if not app_model_config: raise MCPRequestError(mcp_types.INVALID_REQUEST, "App is unavailable") - features_dict = app.app_model_config.to_dict() + features_dict = app_model_config.to_dict() raw_user_input_form = features_dict.get("user_input_form", []) # Convert to VariableEntity objects diff --git a/api/core/datasource/datasource_manager.py b/api/core/datasource/datasource_manager.py index f0dcb13b622..b96d98d3f9c 100644 --- a/api/core/datasource/datasource_manager.py +++ b/api/core/datasource/datasource_manager.py @@ -115,7 +115,7 @@ class DatasourceManager: """ get the datasource runtime - :param provider_type: the type of the provider + :param datasource_type: the type of the datasource :param provider_id: the id of the provider :param datasource_name: the name of the datasource :param tenant_id: the tenant id diff --git a/api/core/datasource/entities/datasource_entities.py b/api/core/datasource/entities/datasource_entities.py index 443b503a693..af9b63ad723 100644 --- a/api/core/datasource/entities/datasource_entities.py +++ b/api/core/datasource/entities/datasource_entities.py @@ -93,7 +93,7 @@ class DatasourceParameter(PluginParameter): :param required: if the parameter is required :param options: the options of the parameter """ - # convert options to ToolParameterOption + # convert options to PluginParameterOption # FIXME fix the type error if options: option_objs = [ diff --git a/api/core/memory/token_buffer_memory.py b/api/core/memory/token_buffer_memory.py index b83f3b84274..8b050d92c1c 100644 --- a/api/core/memory/token_buffer_memory.py +++ b/api/core/memory/token_buffer_memory.py @@ -64,7 +64,9 @@ class TokenBufferMemory: """ match self.conversation.mode: case AppMode.AGENT_CHAT | AppMode.COMPLETION | AppMode.CHAT: - file_extra_config = FileUploadConfigManager.convert(self.conversation.model_config) + file_extra_config = FileUploadConfigManager.convert( + self.conversation.model_config_with_session(session=db.session()) + ) case AppMode.ADVANCED_CHAT | AppMode.WORKFLOW: app = self.conversation.app if not app: diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index 34679afc139..c7a1fcfb512 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -1188,7 +1188,7 @@ class TraceTask: dataset_retrieval_trace_info = DatasetRetrievalTraceInfo( trace_id=self.trace_id, message_id=message_id, - inputs=message_data.query or message_data.inputs, + inputs=message_data.query or message_data.inputs_with_session(session=db.session()), documents=doc_list, start_time=timer.get("start"), end_time=timer.get("end"), diff --git a/api/core/plugin/backwards_invocation/app.py b/api/core/plugin/backwards_invocation/app.py index 2972e2be207..4165b585d1c 100644 --- a/api/core/plugin/backwards_invocation/app.py +++ b/api/core/plugin/backwards_invocation/app.py @@ -281,7 +281,10 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation): @classmethod def _get_workflow(cls, app: App) -> Workflow | None: """ - get workflow without relying on App.workflow's request-scoped session property + get workflow with an owned session, additionally scoped by tenant and app + + `App.workflow_with_session` resolves by `workflow_id` alone; a plugin + invocation must also confirm the workflow belongs to this tenant and app. """ if not app.workflow_id: return None diff --git a/api/core/rag/extractor/extract_processor.py b/api/core/rag/extractor/extract_processor.py index 1d2f98075ef..dd73b436089 100644 --- a/api/core/rag/extractor/extract_processor.py +++ b/api/core/rag/extractor/extract_processor.py @@ -83,10 +83,12 @@ class ExtractProcessor: suffix = Path(url).suffix if not suffix and suffix != ".": # get content-type - if response.headers.get("Content-Type"): - suffix = "." + response.headers.get("Content-Type").split("/")[-1] + content_type = response.headers.get("Content-Type") + if content_type: + # strip parameters such as "; charset=utf-8" before using the subtype as suffix + suffix = "." + content_type.split(";")[0].strip().split("/")[-1] else: - content_disposition = response.headers.get("Content-Disposition") + content_disposition = response.headers.get("Content-Disposition", "") filename_match = re.search(r'filename="([^"]+)"', content_disposition) if filename_match: filename = unquote(filename_match.group(1)) diff --git a/api/events/event_handlers/create_installed_app_when_app_created.py b/api/events/event_handlers/create_installed_app_when_app_created.py index 2e49c81e415..672ce6b7956 100644 --- a/api/events/event_handlers/create_installed_app_when_app_created.py +++ b/api/events/event_handlers/create_installed_app_when_app_created.py @@ -1,24 +1,13 @@ -from sqlalchemy import select from sqlalchemy.orm import Session from events.app_event import app_was_created -from models.model import InstalledApp +from services.app_creation_records import create_installed_app_record @app_was_created.connect -def handle(sender, *, session: Session, **_kwargs) -> None: +def handle(sender, *, session: Session, **kwargs) -> None: """Create an installed app when an app is created.""" - app = sender - installed_app_id = session.scalar( - select(InstalledApp.id).where(InstalledApp.tenant_id == app.tenant_id, InstalledApp.app_id == app.id).limit(1) - ) - if installed_app_id: + if kwargs.get("created_records_initialized"): return - - installed_app = InstalledApp( - tenant_id=app.tenant_id, - app_id=app.id, - app_owner_tenant_id=app.tenant_id, - ) - session.add(installed_app) - session.flush() + app = sender + create_installed_app_record(app=app, session=session) diff --git a/api/events/event_handlers/create_site_record_when_app_created.py b/api/events/event_handlers/create_site_record_when_app_created.py index 69149543fd7..9b9b0450adf 100644 --- a/api/events/event_handlers/create_site_record_when_app_created.py +++ b/api/events/event_handlers/create_site_record_when_app_created.py @@ -1,27 +1,15 @@ from sqlalchemy.orm import Session from events.app_event import app_was_created -from models.enums import CustomizeTokenStrategy -from models.model import Site +from services.app_creation_records import create_site_record @app_was_created.connect def handle(sender, *, session: Session, **kwargs) -> None: """Create site record when an app is created.""" + if kwargs.get("created_records_initialized"): + return app = sender account = kwargs.get("account") if account is not None: - site = Site( - app_id=app.id, - title=app.name, - icon_type=app.icon_type, - icon=app.icon, - icon_background=app.icon_background, - default_language=account.interface_language, - customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, - code=Site.generate_code(16, session=session), - created_by=app.created_by, - updated_by=app.updated_by, - ) - session.add(site) - session.flush() + create_site_record(app=app, account=account, session=session) diff --git a/api/libs/zip_filename_recovery.py b/api/libs/zip_filename_recovery.py new file mode 100644 index 00000000000..9b813e7b1a9 --- /dev/null +++ b/api/libs/zip_filename_recovery.py @@ -0,0 +1,158 @@ +"""Read malformed UTF-8 ZIP names without weakening payload integrity checks.""" + +from __future__ import annotations + +import io +import struct +import zipfile +from dataclasses import dataclass + +_UTF8_FLAG = 1 << 11 +_END_RECORD = struct.Struct("<4s4H2IH") +_CENTRAL_HEADER = struct.Struct("<4s6H3I5H2I") +_LOCAL_HEADER = struct.Struct("<4s5H3I2H") +_ZIP64_LOCATOR = struct.Struct("<4sIQI") +_ZIP64_END_RECORD = struct.Struct("<4sQ2H2I4Q") + + +@dataclass(frozen=True) +class _Directory: + start: int + end: int + entries: int + prefix_size: int + + +def _locate_directory(content: bytes) -> _Directory: + end = content.rfind(b"PK\x05\x06", max(0, len(content) - 65535 - _END_RECORD.size)) + if end < 0 or end + _END_RECORD.size > len(content): + raise zipfile.BadZipFile("Missing ZIP end record") + _, disk, directory_disk, disk_entries, entries, size, offset, comment_size = _END_RECORD.unpack_from(content, end) + if end + _END_RECORD.size + comment_size != len(content): + raise zipfile.BadZipFile("Invalid ZIP comment length") + + locator_position = end - _ZIP64_LOCATOR.size + if locator_position >= 0 and content[locator_position : locator_position + 4] == b"PK\x06\x07": + _, record_disk, record_offset, disks = _ZIP64_LOCATOR.unpack_from(content, locator_position) + if record_disk != 0 or disks != 1: + raise zipfile.BadZipFile("Multi-disk ZIP64 archives are not supported") + record_position = record_offset + if record_position + _ZIP64_END_RECORD.size > locator_position: + raise zipfile.BadZipFile("Invalid ZIP64 end record offset") + if content[record_position : record_position + 4] != b"PK\x06\x06": + # Match zipfile's handling of prepended data: in this case it + # locates the fixed-size ZIP64 record immediately before the locator. + record_position = locator_position - _ZIP64_END_RECORD.size + if record_position < 0: + raise zipfile.BadZipFile("Missing ZIP64 end record") + signature, record_size, _, _, disk, directory_disk, disk_entries, entries, size, offset = ( + _ZIP64_END_RECORD.unpack_from(content, record_position) + ) + if ( + signature != b"PK\x06\x06" + or record_size + 12 != locator_position - record_position + or offset + size != record_offset + ): + raise zipfile.BadZipFile("Invalid ZIP64 end record") + end = record_position + elif entries == 0xFFFF or size == 0xFFFFFFFF or offset == 0xFFFFFFFF: + raise zipfile.BadZipFile("Missing ZIP64 directory information") + + start = end - size + prefix_size = start - offset + if disk != 0 or directory_disk != 0 or disk_entries != entries: + raise zipfile.BadZipFile("Multi-disk ZIP archives are not supported") + if start < 0 or prefix_size < 0 or entries > size // _CENTRAL_HEADER.size: + raise zipfile.BadZipFile("Invalid ZIP directory bounds") + return _Directory(start=start, end=end, entries=entries, prefix_size=prefix_size) + + +def _local_header_offset(*, offset: int, file_size: int, compressed_size: int, extra: bytes) -> int: + if offset != 0xFFFFFFFF: + return offset + cursor = 0 + while cursor + 4 <= len(extra): + tag, size = struct.unpack_from(" len(extra): + raise zipfile.BadZipFile("Truncated ZIP extra field") + if tag == 1: + # ZIP64 includes only values whose corresponding 32-bit field + # contains the sentinel, in size/compressed-size/offset order. + skipped = 8 * (int(file_size == 0xFFFFFFFF) + int(compressed_size == 0xFFFFFFFF)) + if skipped + 8 > size: + raise zipfile.BadZipFile("Missing ZIP64 local header offset") + return struct.unpack_from(" zipfile.ZipFile: + """Open a bounded ZIP, replacing invalid UTF-8 name bytes with U+FFFD. + + The normal path delegates entirely to zipfile. For a damaged directory, + temporarily read affected names as CP437 (a reversible byte mapping), and + expose their lossy UTF-8 form through ZipInfo.filename. orig_filename stays + byte-preserving so zipfile still checks local/central name consistency. + Callers must reject collisions and unsafe paths in the recovered names. + """ + try: + return zipfile.ZipFile(io.BytesIO(content)) + except UnicodeDecodeError: + pass + + directory = _locate_directory(content) + + patched = bytearray(content) + recovered: dict[int, str] = {} + cursor = directory.start + for index in range(directory.entries): + if cursor + _CENTRAL_HEADER.size > directory.end: + raise zipfile.BadZipFile("Truncated ZIP directory") + header = _CENTRAL_HEADER.unpack_from(content, cursor) + if header[0] != b"PK\x01\x02": + raise zipfile.BadZipFile("Invalid ZIP directory header") + flags, name_size, extra_size, entry_comment_size = header[3], header[10], header[11], header[12] + name_start = cursor + _CENTRAL_HEADER.size + next_cursor = name_start + name_size + extra_size + entry_comment_size + if next_cursor > directory.end: + raise zipfile.BadZipFile("Truncated ZIP directory name") + raw_name = content[name_start : name_start + name_size] + if flags & _UTF8_FLAG: + try: + raw_name.decode("utf-8") + except UnicodeDecodeError: + extra_start = name_start + name_size + local_offset = ( + _local_header_offset( + offset=header[16], + file_size=header[9], + compressed_size=header[8], + extra=content[extra_start : extra_start + extra_size], + ) + + directory.prefix_size + ) + if local_offset < 0 or local_offset + _LOCAL_HEADER.size > directory.start: + raise zipfile.BadZipFile("Invalid ZIP local header offset") + local = _LOCAL_HEADER.unpack_from(content, local_offset) + local_name_start = local_offset + _LOCAL_HEADER.size + if ( + local[0] != b"PK\x03\x04" + or local[9] != name_size + or content[local_name_start : local_name_start + local[9]] != raw_name + or not local[2] & _UTF8_FLAG + ): + raise zipfile.BadZipFile("ZIP local and directory names differ") + struct.pack_into(" "DatasetProcessRule | None": if self.dataset_process_rule_id: return session.get(DatasetProcessRule, self.dataset_process_rule_id) @@ -646,17 +575,9 @@ class Document(Base): """Load the owning dataset with the caller-owned database session.""" return session.get(Dataset, self.dataset_id) - @property - def segment_count(self): - return self.get_segment_count(session=db.session()) - def get_segment_count(self, *, session: Session) -> int: return session.scalar(select(func.count(DocumentSegment.id)).where(DocumentSegment.document_id == self.id)) or 0 - @property - def hit_count(self): - return self.get_hit_count(session=db.session()) - def get_hit_count(self, *, session: Session) -> int: return ( session.scalar( @@ -707,12 +628,6 @@ class Document(Base): return metadata_list return None - @property - def process_rule_dict(self) -> ProcessRuleDict | None: - if self.dataset_process_rule_id and self.dataset_process_rule: - return self.dataset_process_rule.to_dict() - return None - def get_built_in_fields(self, *, session: Session) -> list[DocMetadataDetailItem]: built_in_fields: list[DocMetadataDetailItem] = [] built_in_fields.append( @@ -757,58 +672,6 @@ class Document(Base): ) return built_in_fields - def to_dict(self) -> DocumentDict: - result: DocumentDict = { - "id": self.id, - "tenant_id": self.tenant_id, - "dataset_id": self.dataset_id, - "position": self.position, - "data_source_type": self.data_source_type, - "data_source_info": self.data_source_info, - "dataset_process_rule_id": self.dataset_process_rule_id, - "batch": self.batch, - "name": self.name, - "created_from": self.created_from, - "created_by": self.created_by, - "created_api_request_id": self.created_api_request_id, - "created_at": self.created_at, - "processing_started_at": self.processing_started_at, - "file_id": self.file_id, - "word_count": self.word_count, - "parsing_completed_at": self.parsing_completed_at, - "cleaning_completed_at": self.cleaning_completed_at, - "splitting_completed_at": self.splitting_completed_at, - "tokens": self.tokens, - "indexing_latency": self.indexing_latency, - "completed_at": self.completed_at, - "is_paused": self.is_paused, - "paused_by": self.paused_by, - "paused_at": self.paused_at, - "error": self.error, - "stopped_at": self.stopped_at, - "indexing_status": self.indexing_status, - "enabled": self.enabled, - "disabled_at": self.disabled_at, - "disabled_by": self.disabled_by, - "archived": self.archived, - "archived_reason": self.archived_reason, - "archived_by": self.archived_by, - "archived_at": self.archived_at, - "updated_at": self.updated_at, - "doc_type": self.doc_type, - "doc_metadata": self.doc_metadata, - "doc_form": self.doc_form, - "doc_language": self.doc_language, - "display_status": self.display_status, - "data_source_info_dict": self.data_source_info_dict, - "average_segment_length": self.average_segment_length, - "dataset_process_rule": self.dataset_process_rule.to_dict() if self.dataset_process_rule else None, - "dataset": None, - "segment_count": self.segment_count, - "hit_count": self.hit_count, - } - return result - @classmethod def from_dict(cls, data: dict[str, Any]): return cls( @@ -1408,18 +1271,6 @@ class ExternalKnowledgeApis(TypeBase): DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), init=False ) - def to_dict(self) -> ExternalKnowledgeApiDict: - return { - "id": self.id, - "tenant_id": self.tenant_id, - "name": self.name, - "description": self.description, - "settings": self.settings_dict, - "dataset_bindings": self.dataset_bindings, - "created_by": self.created_by, - "created_at": self.created_at.isoformat(), - } - @property def settings_dict(self) -> dict[str, Any] | None: try: @@ -1427,10 +1278,6 @@ class ExternalKnowledgeApis(TypeBase): except JSONDecodeError: return None - @property - def dataset_bindings(self): - return self.get_dataset_bindings(session=db.session()) - def get_dataset_bindings(self, *, session: Session) -> list[DatasetBindingItem]: external_knowledge_bindings = session.scalars( select(ExternalKnowledgeBindings).where(ExternalKnowledgeBindings.external_knowledge_api_id == self.id) diff --git a/api/models/model.py b/api/models/model.py index 1af4d50c163..0cd3c2e16eb 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -449,10 +449,6 @@ class App(Base): ) use_icon_as_answer_icon: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.false()) - @property - def desc_or_prompt(self) -> str: - return self.desc_or_prompt_with_session(session=db.session()) - def desc_or_prompt_with_session(self, *, session: Session) -> str: if self.description: return self.description @@ -470,20 +466,12 @@ class App(Base): def site_with_session(self, *, session: Session) -> Site | None: return session.scalar(select(Site).where(Site.app_id == self.id)) - @property - def app_model_config(self) -> AppModelConfig | None: - return self.app_model_config_with_session(session=db.session()) - def app_model_config_with_session(self, *, session: Session) -> AppModelConfig | None: if self.app_model_config_id: return session.scalar(select(AppModelConfig).where(AppModelConfig.id == self.app_model_config_id)) return None - @property - def workflow(self) -> Workflow | None: - return self.workflow_with_session(session=db.session()) - def workflow_with_session(self, *, session: Session) -> Workflow | None: if self.workflow_id: from .workflow import Workflow @@ -979,9 +967,8 @@ class RecommendedApp(TypeBase): init=False, ) - @property - def app(self) -> App | None: - return db.session.scalar(select(App).where(App.id == self.app_id)) + def app(self, *, session: Session) -> App | None: + return session.scalar(select(App).where(App.id == self.app_id)) class InstalledApp(TypeBase): @@ -1054,13 +1041,11 @@ class AccountTrialAppRecord(TypeBase): sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False ) - @property - def app(self) -> App | None: - return db.session.scalar(select(App).where(App.id == self.app_id)) + def app(self, *, session: Session) -> App | None: + return session.scalar(select(App).where(App.id == self.app_id)) - @property - def user(self) -> Account | None: - return db.session.scalar(select(Account).where(Account.id == self.account_id)) + def user(self, *, session: Session) -> Account | None: + return session.scalar(select(Account).where(Account.id == self.account_id)) class ExporleBanner(TypeBase): @@ -1198,12 +1183,7 @@ class Conversation(Base): is_deleted: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.false()) - @property - def inputs(self) -> dict[str, Any]: - return self.inputs_with_session(session=db.session()) - - @inputs.setter - def inputs(self, value: Mapping[str, Any]): + def _set_inputs(self, value: Mapping[str, Any]): inputs = dict(value) for k, v in inputs.items(): match v: @@ -1214,6 +1194,9 @@ class Conversation(Base): inputs[k] = [item.model_dump() for item in v if isinstance(item, File)] self._inputs = inputs + # Write-only: reading requires a session, use inputs_with_session instead. + inputs = property(fset=_set_inputs) + def inputs_with_session(self, *, session: Session) -> dict[str, Any]: inputs = self._inputs.copy() # Compatibility bridge: stored input payloads may come from before or after the @@ -1257,10 +1240,6 @@ class Conversation(Base): return inputs - @property - def model_config(self) -> AppModelConfigDict: - return self.model_config_with_session(session=db.session()) - def model_config_with_session(self, *, session: Session) -> AppModelConfigDict: model_config = cast(AppModelConfigDict, {}) app_model_config: AppModelConfig | None = None @@ -1443,7 +1422,7 @@ class Conversation(Base): "mode": self.mode, "name": self.name, "summary": self.summary, - "inputs": self.inputs, + "inputs": self.inputs_with_session(session=db.session()), "introduction": self.introduction, "system_instruction": self.system_instruction, "system_instruction_tokens": self.system_instruction_tokens, @@ -1519,12 +1498,7 @@ class Message(Base): workflow_run_id: Mapped[str | None] = mapped_column(StringUUID) app_mode: Mapped[AppMode | None] = mapped_column(EnumText(AppMode, length=255), nullable=True) - @property - def inputs(self) -> dict[str, Any]: - return self.inputs_with_session(session=db.session()) - - @inputs.setter - def inputs(self, value: Mapping[str, Any]): + def _set_inputs(self, value: Mapping[str, Any]): inputs = dict(value) for k, v in inputs.items(): match v: @@ -1536,6 +1510,9 @@ class Message(Base): inputs[k] = [item.model_dump() for item in v_list if isinstance(item, File)] self._inputs = inputs + # Write-only: reading requires a session, use inputs_with_session instead. + inputs = property(fset=_set_inputs) + def inputs_with_session(self, *, session: Session) -> dict[str, Any]: inputs = self._inputs.copy() # Compatibility bridge: message inputs are persisted as JSON and must remain @@ -1814,7 +1791,7 @@ class Message(Base): "app_id": self.app_id, "conversation_id": self.conversation_id, "model_id": self.model_id, - "inputs": self.inputs, + "inputs": self.inputs_with_session(session=db.session()), "query": self.query, "total_price": self.total_price, "message": self.message, @@ -2174,11 +2151,11 @@ class AppMCPServer(TypeBase): ) @staticmethod - def generate_server_code(n: int) -> str: + def generate_server_code(n: int, *, session: Session) -> str: while True: result = generate_string(n) while ( - db.session.scalar(select(func.count(AppMCPServer.id)).where(AppMCPServer.server_code == result)) or 0 + session.scalar(select(func.count(AppMCPServer.id)).where(AppMCPServer.server_code == result)) or 0 ) > 0: result = generate_string(n) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 752c75fa9a1..1eeb5031c5d 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -1502,7 +1502,7 @@ Create a new application | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [AppImportPayload](#appimportpayload)
| +| Yes | **application/json**: [AppImportPayload](#appimportpayload)
**multipart/form-data**: { **"file"**: binary }
| **application/json**: [AppImportPayload](#appimportpayload)
**multipart/form-data**: { **"file"**: binary }
| #### Responses @@ -1511,6 +1511,9 @@ Create a new application | 200 | Import completed | **application/json**: [Import](#import)
| | 202 | Import pending confirmation | **application/json**: [Import](#import)
| | 400 | Import failed | **application/json**: [Import](#import)
| +| 403 | Insufficient import or plugin installation permissions | | +| 409 | Agent name conflict or missing plugins | **application/json**: [RosterAgentPackageConflictResponse](#rosteragentpackageconflictresponse)
| +| 413 | Roster Agent package exceeds the size limit | | ### [GET] /apps/imports/{app_id}/check-dependencies #### Parameters @@ -2670,6 +2673,7 @@ Export application configuration as DSL | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | app_id | path | Application ID to export | Yes | string (uuid) | +| format | query | Export format; defaults to ifpkg for Agent Apps and yaml for other Apps | No | string,
**Available values:** "ifpkg", "yaml" | | include_secret | query | Include secrets in export | No | boolean | | workflow_id | query | Specific workflow ID to export | No | string | @@ -2677,8 +2681,8 @@ Export application configuration as DSL | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | App exported successfully | **application/json**: [AppExportResponse](#appexportresponse)
| -| 403 | Insufficient permissions | | +| 200 | App exported successfully | **application/json**: [AppExportResponse](#appexportresponse)
**application/zip**: binary
| +| 403 | Insufficient permissions | **application/zip**: binary
| ### [POST] /apps/{app_id}/feedbacks Create or update message feedback (like/dislike) @@ -15921,6 +15925,7 @@ This class is used to store the schema information of an api based tool. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| format | string,
**Available values:** "ifpkg", "yaml" | Export format; defaults to ifpkg for Agent Apps and yaml for other Apps | No | | include_secret | boolean | Include secrets in export | No | | workflow_id | string | Specific workflow ID to export | No | @@ -22587,6 +22592,15 @@ Resource types understood by access policies. | ---- | ---- | ----------- | -------- | | data | [ [AccessPolicyRoleBinding](#accesspolicyrolebinding) ] | | No | +#### RosterAgentPackageConflictResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| leaked_dependencies | [ [PluginDependency](#plugindependency) ] | | No | +| message | string | | Yes | +| status | integer,
**Default:** 409 | | No | + #### RosterListQuery | Name | Type | Description | Required | diff --git a/api/services/agent/composer_validator.py b/api/services/agent/composer_validator.py index ea1ae673b81..5ce4c44db3f 100644 --- a/api/services/agent/composer_validator.py +++ b/api/services/agent/composer_validator.py @@ -234,6 +234,14 @@ class ComposerConfigValidator: cls._reject_plaintext_secrets(dumped, path="agent_soul") cls._validate_shell_config(dumped) + @classmethod + def validate_importable_agent_soul(cls, agent_soul: AgentSoulConfig) -> None: + """Validate persisted draft safety without requiring all assets to be publishable.""" + + dumped = agent_soul.model_dump(mode="json") + cls._reject_plaintext_secrets(dumped, path="agent_soul") + cls._validate_shell_config(dumped) + @staticmethod def _reject_missing_config_assets(agent_soul: AgentSoulConfig) -> None: missing = [f"skill:{item.name}" for item in agent_soul.config_skills if item.is_missing] diff --git a/api/services/agent/config_skill_normalize_service.py b/api/services/agent/config_skill_normalize_service.py index 7093b49ce1c..51aee675137 100644 --- a/api/services/agent/config_skill_normalize_service.py +++ b/api/services/agent/config_skill_normalize_service.py @@ -9,7 +9,7 @@ from __future__ import annotations from core.tools.tool_file_manager import ToolFileManager from models.agent_config_entities import AgentConfigSkillRefConfig, validate_config_skill_name -from services.agent.skill_package_service import NormalizedSkillPackage, SkillPackageError, SkillPackageService +from services.agent.skill_package_service import SkillPackageError, SkillPackageService class ConfigSkillNormalizeService: @@ -32,7 +32,7 @@ class ConfigSkillNormalizeService: requested_name: str | None, tenant_id: str, user_id: str, - ) -> tuple[AgentConfigSkillRefConfig, NormalizedSkillPackage]: + ) -> AgentConfigSkillRefConfig: package = self._package.validate_and_normalize(content=content, filename=filename) normalized_name = validate_config_skill_name(requested_name or package.manifest.name) if package.manifest.name != normalized_name: @@ -50,16 +50,13 @@ class ConfigSkillNormalizeService: mimetype="application/zip", filename=f"{normalized_name}.zip", ) - return ( - AgentConfigSkillRefConfig( - name=normalized_name, - description=package.manifest.description, - file_id=tool_file.id, - size=tool_file.size, - hash=package.manifest.hash, - mime_type=tool_file.mimetype, - ), - package, + return AgentConfigSkillRefConfig( + name=normalized_name, + description=package.manifest.description, + file_id=tool_file.id, + size=tool_file.size, + hash=package.manifest.hash, + mime_type=tool_file.mimetype, ) diff --git a/api/services/agent/dependency_service.py b/api/services/agent/dependency_service.py new file mode 100644 index 00000000000..b523cb3d039 --- /dev/null +++ b/api/services/agent/dependency_service.py @@ -0,0 +1,65 @@ +"""Plugin dependency discovery shared by portable Agent formats.""" + +from models.agent_config_entities import ( + AgentKnowledgeMetadataFilteringConfig, + AgentKnowledgeRetrievalConfig, + AgentSoulConfig, +) +from services.plugin.dependencies_analysis import DependenciesAnalysisService + + +def _extract_knowledge_retrieval_dependencies(retrieval: AgentKnowledgeRetrievalConfig) -> list[str]: + if retrieval.mode == "single": + if retrieval.model is None: + return [] + return [DependenciesAnalysisService.analyze_model_provider_dependency(retrieval.model.provider)] + + if not retrieval.reranking_enable: + return [] + + if retrieval.reranking_mode == "reranking_model": + if retrieval.reranking_model is None: + return [] + return [DependenciesAnalysisService.analyze_model_provider_dependency(retrieval.reranking_model.provider)] + + if retrieval.reranking_mode == "weighted_score" and retrieval.weights is not None: + vector_setting = retrieval.weights.vector_setting + embedding_provider = vector_setting.get("embedding_provider_name") if vector_setting is not None else None + if embedding_provider: + return [DependenciesAnalysisService.analyze_model_provider_dependency(embedding_provider)] + + return [] + + +def _extract_metadata_filtering_dependencies( + metadata_filtering: AgentKnowledgeMetadataFilteringConfig, +) -> list[str]: + if metadata_filtering.mode != "automatic" or metadata_filtering.metadata_model_config is None: + return [] + return [ + DependenciesAnalysisService.analyze_model_provider_dependency(metadata_filtering.metadata_model_config.provider) + ] + + +def extract_agent_soul_dependencies(soul: AgentSoulConfig) -> list[str]: + dependencies: list[str] = [] + if soul.model is not None: + dependencies.append(DependenciesAnalysisService.analyze_model_provider_dependency(soul.model.model_provider)) + suggested_questions = soul.app_features.suggested_questions_after_answer + if suggested_questions is not None and suggested_questions.model is not None: + dependencies.append( + DependenciesAnalysisService.analyze_model_provider_dependency(suggested_questions.model.provider) + ) + for tool in soul.tools.dify_tools: + provider_id = tool.provider_id or ( + f"{tool.plugin_id}/{tool.provider}" if tool.plugin_id and tool.provider else None + ) + if provider_id: + dependencies.append(DependenciesAnalysisService.analyze_tool_dependency(provider_id)) + for knowledge_set in soul.knowledge.sets: + dependencies.extend(_extract_knowledge_retrieval_dependencies(knowledge_set.retrieval)) + dependencies.extend(_extract_metadata_filtering_dependencies(knowledge_set.metadata_filtering)) + return dependencies + + +__all__ = ["extract_agent_soul_dependencies"] diff --git a/api/services/agent/dsl_entities.py b/api/services/agent/dsl_entities.py index 5fda1cfd92b..4231e6c4033 100644 --- a/api/services/agent/dsl_entities.py +++ b/api/services/agent/dsl_entities.py @@ -3,12 +3,15 @@ from __future__ import annotations import hashlib -from typing import Any, Literal +from typing import Any, Literal, Self -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator +from core.plugin.entities.plugin import PluginDependency from models.agent import Agent from models.agent_config_entities import AgentSoulConfig +from models.model import App +from services.entities.dsl_entities import make_app_dsl AGENT_PACKAGE_SCHEMA_VERSION = 1 AGENT_PACKAGE_REF_KEY = "package_ref" @@ -59,6 +62,54 @@ class AgentPackage(BaseModel): workspace_skills: list[AgentPackageWorkspaceSkill] = Field(default_factory=list) +class AgentAppReference(BaseModel): + model_config = ConfigDict(extra="forbid") + + package_ref: str = Field(min_length=1) + + +class AgentAppDsl(BaseModel): + """The existing standalone Agent App DSL, also stored as archive app.yaml.""" + + model_config = ConfigDict(extra="forbid") + + version: str = Field(min_length=1) + kind: Literal["app"] + app: dict[str, Any] + agent: AgentAppReference + agent_packages: dict[str, AgentPackage] + dependencies: list[PluginDependency] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_agent_app(self) -> Self: + if self.app.get("mode") != "agent": + raise ValueError("Agent App DSL requires app.mode=agent") + if not isinstance(self.app.get("name"), str) or not self.app["name"]: + raise ValueError("Agent App DSL requires an app name") + if set(self.agent_packages) != {self.agent.package_ref}: + raise ValueError("Agent App DSL must contain exactly the referenced Agent package") + return self + + @property + def package(self) -> AgentPackage: + return self.agent_packages[self.agent.package_ref] + + +def make_agent_app_dsl( + app: App, + *, + package_ref: str, + packages: dict[str, AgentPackage], + dependencies: list[PluginDependency], +) -> AgentAppDsl: + return AgentAppDsl( + **make_app_dsl(app), + agent=AgentAppReference(package_ref=package_ref), + agent_packages=packages, + dependencies=dependencies, + ) + + def portable_ref(prefix: str, value: str) -> str: digest = hashlib.sha256(value.encode()).hexdigest()[:16] return f"{prefix}-{digest}" @@ -87,40 +138,15 @@ def _strip_sensitive_values(value: Any) -> Any: return result -def make_portable_agent_package( - agent: Agent, - agent_soul: AgentSoulConfig, - workspace_skills: list[AgentPackageWorkspaceSkill] | None = None, -) -> AgentPackage: - """Return a package safe to place in YAML or the system clipboard.""" +def make_portable_agent_soul(agent_soul: AgentSoulConfig) -> AgentSoulConfig: + """Return an Agent Soul without workspace-local credentials or identities. + + Resource references are intentionally preserved. Container formats decide + whether to omit their payloads, as YAML DSL does, or remap them to + package-local identifiers, as Roster packages do. + """ soul_data = agent_soul.model_dump(mode="json") - omitted_assets = [ - AgentPackageOmittedAsset( - kind="skill", - name=item.name, - size=item.size, - hash=item.hash, - mime_type=item.mime_type, - ) - for item in agent_soul.config_skills - ] - omitted_assets.extend( - AgentPackageOmittedAsset( - kind="file", - name=item.name, - size=item.size, - hash=item.hash, - mime_type=item.mime_type, - ) - for item in agent_soul.config_files - ) - for item in soul_data.get("config_skills", []): - item["file_id"] = "" - item["is_missing"] = True - for item in soul_data.get("config_files", []): - item["file_id"] = "" - item["is_missing"] = True if soul_data.get("model"): soul_data["model"]["credential_ref"] = None @@ -155,7 +181,53 @@ def make_portable_agent_package( for key in ("id", "contact_id", "human_id", "tenant_id"): contact[key] = None - portable_soul = AgentSoulConfig.model_validate(soul_data) + return AgentSoulConfig.model_validate(soul_data) + + +def make_portable_agent_package( + agent: Agent, + agent_soul: AgentSoulConfig, + workspace_skills: list[AgentPackageWorkspaceSkill] | None = None, + *, + include_assets: bool = False, +) -> AgentPackage: + """Return a portable package for YAML, clipboard, or an asset-bearing archive. + + Archives preserve package-local references to included assets; standalone + YAML and clipboard exports mark resources missing because they omit payloads. + """ + + portable_soul = make_portable_agent_soul(agent_soul) + omitted_assets = [ + AgentPackageOmittedAsset( + kind="skill", + name=item.name, + size=item.size, + hash=item.hash, + mime_type=item.mime_type, + ) + for item in agent_soul.config_skills + if not include_assets or item.is_missing + ] + omitted_assets.extend( + AgentPackageOmittedAsset( + kind="file", + name=item.name, + size=item.size, + hash=item.hash, + mime_type=item.mime_type, + ) + for item in agent_soul.config_files + if not include_assets or item.is_missing + ) + for skill_ref in portable_soul.config_skills: + if not include_assets or skill_ref.is_missing: + skill_ref.file_id = "" + skill_ref.is_missing = True + for file_ref in portable_soul.config_files: + if not include_assets or file_ref.is_missing: + file_ref.file_id = "" + file_ref.is_missing = True icon_type = agent.icon_type.value if agent.icon_type is not None else None return AgentPackage( metadata=AgentPackageMetadata( diff --git a/api/services/agent/dsl_service.py b/api/services/agent/dsl_service.py index 3c13ee0112b..a03f25ef14d 100644 --- a/api/services/agent/dsl_service.py +++ b/api/services/agent/dsl_service.py @@ -41,6 +41,7 @@ from models.model import App, AppModelConfig from models.skill import AgentSkillBinding, AgentSkillBindingSnapshot, Skill from models.workflow import Workflow from services.agent.agent_soul_state import agent_soul_has_model +from services.agent.dependency_service import extract_agent_soul_dependencies from services.agent.dsl_entities import ( AGENT_NODE_JOB_DSL_KEY, AGENT_PACKAGE_REF_KEY, @@ -53,7 +54,6 @@ from services.agent.dsl_entities import ( from services.agent.knowledge_datasets import get_tenant_knowledge_dataset_rows from services.agent.roster_service import AgentRosterService from services.entities.dsl_entities import DslImportWarning -from services.plugin.dependencies_analysis import DependenciesAnalysisService class AgentPackageImportResult(BaseModel): @@ -198,12 +198,12 @@ class AgentDslService: ) -> AgentPackageImportResult: """Create the imported backing Agent and its editable unpublished draft.""" - soul, warnings = self._resolve_package_soul( + soul, warnings = self.resolve_package_soul( tenant_id=app.tenant_id, package=package, package_path="agent", ) - if app.app_model_config is None: + if app.app_model_config_with_session(session=self.session) is None: model_config = AppModelConfig(app_id=app.id, created_by=account.id, updated_by=account.id) self.session.add(model_config) self.session.flush() @@ -375,29 +375,7 @@ class AgentDslService: def extract_package_dependencies(self, packages: Mapping[str, AgentPackage]) -> list[str]: dependencies: list[str] = [] for package in packages.values(): - soul = package.soul - if soul.model is not None: - dependencies.append( - DependenciesAnalysisService.analyze_model_provider_dependency(soul.model.model_provider) - ) - for tool in soul.tools.dify_tools: - provider_id = tool.provider_id or ( - f"{tool.plugin_id}/{tool.provider}" if tool.plugin_id and tool.provider else None - ) - if provider_id: - dependencies.append(DependenciesAnalysisService.analyze_tool_dependency(provider_id)) - for knowledge_set in soul.knowledge.sets: - retrieval = knowledge_set.retrieval - if retrieval.model is not None: - dependencies.append( - DependenciesAnalysisService.analyze_model_provider_dependency(retrieval.model.provider) - ) - if retrieval.reranking_model is not None: - dependencies.append( - DependenciesAnalysisService.analyze_model_provider_dependency( - retrieval.reranking_model.provider - ) - ) + dependencies.extend(extract_agent_soul_dependencies(package.soul)) return dependencies def _workspace_skills_for_export( @@ -505,7 +483,7 @@ class AgentDslService: package: AgentPackage, package_path: str, ) -> AgentPackageImportResult: - soul, warnings = self._resolve_package_soul( + soul, warnings = self.resolve_package_soul( tenant_id=workflow.tenant_id, package=package, package_path=package_path, @@ -583,7 +561,7 @@ class AgentDslService: self.session.flush() return agent, snapshot - def _resolve_package_soul( + def resolve_package_soul( self, *, tenant_id: str, diff --git a/api/services/agent/errors.py b/api/services/agent/errors.py index 89875e4dc74..9534930b223 100644 --- a/api/services/agent/errors.py +++ b/api/services/agent/errors.py @@ -53,6 +53,42 @@ class AgentHomeSnapshotTooLargeError(BaseHTTPException): code = 413 +class InvalidRosterAgentPackageError(BaseHTTPException): + error_code = "invalid_roster_agent_package" + description = "The Roster Agent package is invalid." + code = 400 + + +class RosterAgentPackageTooLargeError(BaseHTTPException): + error_code = "roster_agent_package_too_large" + description = "The Roster Agent package exceeds the size limit." + code = 413 + + +class RosterAgentPackageExportFailedError(BaseHTTPException): + error_code = "roster_agent_package_export_failed" + description = "The Roster Agent package could not be exported." + code = 500 + + +class RosterAgentPackageImportFailedError(BaseHTTPException): + error_code = "roster_agent_package_import_failed" + description = "The Roster Agent package could not be imported." + code = 500 + + +class RosterAgentPackageDependenciesMissingError(BaseHTTPException): + error_code = "roster_agent_package_dependencies_missing" + description = "Install the missing plugins before importing the Roster Agent package." + code = 409 + + +class RosterAgentPackageResourceUnavailableError(BaseHTTPException): + error_code = "roster_agent_package_resource_unavailable" + description = "The Roster Agent package resources could not be stored." + code = 503 + + class AgentSoulLockedError(BadRequest): description = "Agent Soul is locked for this workflow node." diff --git a/api/services/agent/roster_package_dependencies.py b/api/services/agent/roster_package_dependencies.py new file mode 100644 index 00000000000..a571d224e4b --- /dev/null +++ b/api/services/agent/roster_package_dependencies.py @@ -0,0 +1,40 @@ +"""Preflight plugin availability and installation permission for package imports.""" + +from werkzeug.exceptions import Forbidden + +from configs import dify_config +from core.db.session_factory import session_factory +from core.plugin.entities.plugin import PluginDependency +from core.rbac import RBACPermission +from models import Account +from models.account import TenantPluginInstallPermission +from services.agent.errors import RosterAgentPackageDependenciesMissingError +from services.enterprise.rbac_service import RBACService +from services.plugin.dependencies_analysis import DependenciesAnalysisService +from services.plugin.plugin_permission_service import PluginPermissionService + + +def check_package_dependencies(*, tenant_id: str, account: Account, dependencies: list[PluginDependency]) -> None: + if not dependencies: + return + missing = DependenciesAnalysisService.get_leaked_dependencies(tenant_id=tenant_id, dependencies=dependencies) + if not missing: + return + if dify_config.RBAC_ENABLED: + allowed = RBACService.CheckAccess.check( + tenant_id, account.id, scene=RBACPermission.PLUGIN_INSTALL, resource_type=None, resource_id=None + ) + if not allowed: + raise Forbidden("Plugin installation permission is required to import this Agent package") + else: + with session_factory.create_session() as session: + permission = PluginPermissionService.get_permission(tenant_id, session=session) + install_permission = permission.install_permission if permission is not None else None + if install_permission == TenantPluginInstallPermission.NOBODY or ( + install_permission == TenantPluginInstallPermission.ADMINS and not account.is_admin_or_owner + ): + raise Forbidden("Plugin installation permission is required to import this Agent package") + error = RosterAgentPackageDependenciesMissingError() + assert error.data is not None + error.data["leaked_dependencies"] = [item.model_dump(mode="json") for item in missing] + raise error diff --git a/api/services/agent/roster_package_entities.py b/api/services/agent/roster_package_entities.py new file mode 100644 index 00000000000..232a08e9675 --- /dev/null +++ b/api/services/agent/roster_package_entities.py @@ -0,0 +1,202 @@ +"""Strict data contract for portable Roster Agent archives.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import BinaryIO, Final, Literal, Self + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from services.agent.dsl_entities import AgentAppDsl + +ROSTER_AGENT_PACKAGE_FORMAT: Final[Literal["dify.roster-agent"]] = "dify.roster-agent" +ROSTER_AGENT_PACKAGE_FORMAT_VERSION: Final[Literal[1]] = 1 +ROSTER_AGENT_PACKAGE_MAX_SIGNATURE_BYTES = 64 * 1024 + +_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") + + +@dataclass(frozen=True) +class RosterAgentPackageMember: + size: int + sha256: str + + +class RosterAgentPackageAudit(BaseModel): + model_config = ConfigDict(extra="forbid") + + ref: str = Field(min_length=1, max_length=255) + + +class _RosterAgentPackageResource(BaseModel): + model_config = ConfigDict(extra="forbid") + + path: str = Field(min_length=1, max_length=255) + size: int = Field(ge=0) + sha256: str + audit: RosterAgentPackageAudit | None = None + + @field_validator("sha256") + @classmethod + def validate_sha256(cls, value: str) -> str: + if _SHA256_PATTERN.fullmatch(value) is None: + raise ValueError("sha256 must be a lowercase hexadecimal SHA-256 digest") + return value + + +class RosterAgentPackageApp(_RosterAgentPackageResource): + @field_validator("path") + @classmethod + def validate_app_path(cls, value: str) -> str: + if "/" in value or "\\" in value or not value.endswith((".yaml", ".yml")): + raise ValueError("app path must be a root-level YAML member") + if value.casefold() == "manifest.yaml": + raise ValueError("app path must not use the package manifest name") + return value + + +class RosterAgentPackageSkill(_RosterAgentPackageResource): + id: str = Field(pattern=r"^s_[0-9]{6}$") + scope: Literal["agent_config", "workspace"] + name: str = Field(min_length=1, max_length=64) + + @model_validator(mode="after") + def validate_skill_path(self) -> Self: + if self.path != f"{self.id}.zip": + raise ValueError("skill path must be the resource id with a .zip extension") + return self + + +class RosterAgentPackageFile(_RosterAgentPackageResource): + id: str = Field(pattern=r"^f_[0-9]{6}$") + + @model_validator(mode="after") + def validate_file_metadata(self) -> Self: + if self.path == self.id or not self.path.startswith(f"{self.id}."): + raise ValueError("file path must start with the resource id and include an extension") + if "/" in self.path or "\\" in self.path: + raise ValueError("file path must be a root-level archive member") + return self + + +class RosterAgentPackageManifest(BaseModel): + """Package metadata and resource index stored in ``manifest.yaml``.""" + + model_config = ConfigDict(extra="forbid") + + format: Literal["dify.roster-agent"] + format_version: Literal[1] + audit: RosterAgentPackageAudit | None = None + apps: list[RosterAgentPackageApp] = Field(min_length=1) + skills: list[RosterAgentPackageSkill] = Field(default_factory=list) + files: list[RosterAgentPackageFile] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_resource_index(self) -> Self: + ids = [item.id for item in self.skills] + [item.id for item in self.files] + if len(ids) != len(set(ids)): + raise ValueError("resource ids must be unique") + paths = [item.path.casefold() for item in [*self.apps, *self.skills, *self.files]] + if len(paths) != len(set(paths)): + raise ValueError("resource paths must be unique") + skill_names = [item.name for item in self.skills] + if len(skill_names) != len(set(skill_names)): + raise ValueError("skill names must be unique after workspace Skills are localized") + + return self + + def validate_apps(self, apps: Mapping[str, AgentAppDsl]) -> None: + """Resolve all application references against the shared resource index.""" + if set(apps) != {item.path for item in self.apps}: + raise ValueError("app members must match the package app index") + skill_by_id = {item.id: item for item in self.skills} + file_by_id = {item.id: item for item in self.files} + workspace_names = {item.name for item in self.skills if item.scope == "workspace"} + referenced_skill_ids: set[str] = set() + referenced_file_ids: set[str] = set() + referenced_workspace_names: set[str] = set() + for app in apps.values(): + if app.package.soul.schema_version != 1: + raise ValueError("unsupported Agent Soul schema version") + for skill_ref in app.package.soul.config_skills: + if skill_ref.is_missing: + continue + skill_resource = skill_by_id.get(skill_ref.file_id) + if skill_resource is None or skill_resource.scope != "agent_config": + raise ValueError("config skill reference must resolve to an agent_config skill") + if skill_resource.name != skill_ref.name: + raise ValueError("config skill name must match its resource metadata") + referenced_skill_ids.add(skill_resource.id) + app_workspace_names = [item.name for item in app.package.workspace_skills] + if len(app_workspace_names) != len(set(app_workspace_names)): + raise ValueError("workspace skill names must be unique in the Agent DSL") + if set(app_workspace_names) - workspace_names: + raise ValueError("workspace skill references must match the package resource index") + referenced_workspace_names.update(app_workspace_names) + for file_ref in app.package.soul.config_files: + if file_ref.is_missing: + continue + if file_ref.file_id not in file_by_id: + raise ValueError("config file reference must resolve to a package file") + referenced_file_ids.add(file_ref.file_id) + if {item.id for item in self.skills if item.scope == "agent_config"} - referenced_skill_ids: + raise ValueError("agent_config skill resources must be referenced by the Agent Soul") + if workspace_names != referenced_workspace_names: + raise ValueError("workspace skill references must match the package resource index") + if set(file_by_id) - referenced_file_ids: + raise ValueError("file resources must be referenced by the Agent Soul") + + +@dataclass +class PreparedRosterAgentPackage: + """Validated archive retained in a bounded spool for later materialization.""" + + archive: BinaryIO + manifest: RosterAgentPackageManifest + apps: dict[str, AgentAppDsl] + members: dict[str, RosterAgentPackageMember] + invalid_skills: dict[str, str] = field(default_factory=dict) + + def close(self) -> None: + self.archive.close() + + def __enter__(self) -> PreparedRosterAgentPackage: + return self + + def __exit__(self, *_args: object) -> None: + self.close() + + +@dataclass +class RosterAgentPackageExport: + """Download artifact owning its archive until the response closes.""" + + archive: BinaryIO + filename: str + size: int + + def close(self) -> None: + self.archive.close() + + def __enter__(self) -> RosterAgentPackageExport: + return self + + def __exit__(self, *_args: object) -> None: + self.close() + + +__all__ = [ + "ROSTER_AGENT_PACKAGE_FORMAT", + "ROSTER_AGENT_PACKAGE_FORMAT_VERSION", + "ROSTER_AGENT_PACKAGE_MAX_SIGNATURE_BYTES", + "PreparedRosterAgentPackage", + "RosterAgentPackageApp", + "RosterAgentPackageAudit", + "RosterAgentPackageExport", + "RosterAgentPackageFile", + "RosterAgentPackageManifest", + "RosterAgentPackageMember", + "RosterAgentPackageSkill", +] diff --git a/api/services/agent/roster_package_exporter.py b/api/services/agent/roster_package_exporter.py new file mode 100644 index 00000000000..05d4f93e447 --- /dev/null +++ b/api/services/agent/roster_package_exporter.py @@ -0,0 +1,468 @@ +"""Export active Roster Agents as portable ``.ifpkg`` archives.""" + +from __future__ import annotations + +import hashlib +import os +import re +import tempfile +import zipfile +from collections.abc import Callable, Generator, Sequence +from dataclasses import dataclass +from typing import BinaryIO, Literal, Protocol, cast + +import yaml +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from configs import dify_config +from core.db.session_factory import session_factory +from core.plugin.entities.plugin import PluginDependency +from extensions.ext_storage import storage +from models.agent import ( + APP_BACKED_AGENT_SOURCES, + Agent, + AgentConfigDraft, + AgentConfigDraftType, + AgentConfigSnapshot, + AgentScope, + AgentStatus, +) +from models.agent_config_entities import AgentSoulConfig +from models.enums import AppStatus +from models.model import App, AppMode, UploadFile +from models.tools import ToolFile +from services.agent.dependency_service import extract_agent_soul_dependencies +from services.agent.dsl_entities import ( + AgentAppDsl, + AgentPackageWorkspaceSkill, + make_agent_app_dsl, + make_portable_agent_package, +) +from services.agent.errors import ( + AgentNotFoundError, + AgentVersionNotFoundError, + RosterAgentPackageExportFailedError, + RosterAgentPackageTooLargeError, +) +from services.agent.roster_package_entities import ( + ROSTER_AGENT_PACKAGE_FORMAT, + ROSTER_AGENT_PACKAGE_FORMAT_VERSION, + RosterAgentPackageApp, + RosterAgentPackageAudit, + RosterAgentPackageExport, + RosterAgentPackageFile, + RosterAgentPackageManifest, + RosterAgentPackageSkill, +) +from services.agent.skill_package_service import SkillPackageError, SkillPackageService +from services.plugin.dependencies_analysis import DependenciesAnalysisService +from services.skill_management_service import RuntimeAgentSkillArchive, SkillManagementService + + +class _Storage(Protocol): + def load_stream(self, filename: str) -> Generator[bytes, None, None]: ... + + +@dataclass(frozen=True) +class _SkillSource: + path: str + storage_key: str + id: str + scope: Literal["agent_config", "workspace"] + name: str + display_name: str | None + description: str + priority: int | None + audit_ref: str + + +@dataclass(frozen=True) +class _FileSource: + path: str + storage_key: str + id: str + audit_ref: str + + +class RosterAgentPackageExporter: + """Collect a Roster Agent through a dedicated read Session and build its archive.""" + + def __init__( + self, + *, + storage_backend: _Storage = storage, + dependency_provider: Callable[[str, list[str]], list[PluginDependency]] | None = None, + ) -> None: + self._storage = storage_backend + self._dependency_provider = dependency_provider or DependenciesAnalysisService.generate_dependencies + + def export(self, *, tenant_id: str, agent_id: str) -> RosterAgentPackageExport: + with session_factory.create_session() as session: + row = session.execute( + select(Agent, App) + .join( + App, + (App.id == Agent.app_id) & (App.tenant_id == Agent.tenant_id), + ) + .where( + Agent.id == agent_id, + Agent.tenant_id == tenant_id, + Agent.scope == AgentScope.ROSTER, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), + Agent.status == AgentStatus.ACTIVE, + Agent.app_id.is_not(None), + or_(Agent.backing_app_id == Agent.app_id, Agent.backing_app_id.is_(None)), + App.mode == AppMode.AGENT, + App.status == AppStatus.NORMAL, + ) + .limit(1) + ).one_or_none() + if row is None: + raise AgentNotFoundError() + agent, app_model = row + + draft = session.scalar( + select(AgentConfigDraft) + .where( + AgentConfigDraft.tenant_id == tenant_id, + AgentConfigDraft.agent_id == agent.id, + AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT, + AgentConfigDraft.draft_owner_key == "", + ) + .limit(1) + ) + if draft is not None: + soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict) + else: + snapshot = session.scalar( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.id == agent.active_config_snapshot_id, + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent.id, + ) + .limit(1) + ) + if snapshot is None: + raise AgentVersionNotFoundError() + soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) + + resource_soul, skill_sources, file_sources = self._collect_payloads( + session=session, + tenant_id=tenant_id, + soul=soul, + ) + package = make_portable_agent_package(agent, resource_soul, include_assets=True) + app = make_agent_app_dsl(app_model, package_ref="agent_1", packages={"agent_1": package}, dependencies=[]) + audit = RosterAgentPackageAudit(ref=agent.id) + dependency_ids = extract_agent_soul_dependencies(package.soul) + + workspace_skills = SkillManagementService().list_runtime_agent_skill_archives( + tenant_id=tenant_id, + agent_id=agent_id, + include_draft=draft is not None, + ) + skill_sources.extend( + self._workspace_skill_sources( + soul=package.soul, + archives=workspace_skills, + start_index=len(skill_sources), + ) + ) + package.workspace_skills = [ + AgentPackageWorkspaceSkill( + name=item.name, + display_name=item.display_name or "", + description=item.description, + priority=item.priority, + ) + for item in skill_sources + if item.scope == "workspace" and item.priority is not None + ] + app.dependencies = self._dependency_provider(tenant_id, dependency_ids) + return self._build_archive( + app=app, + audit=audit, + skill_sources=skill_sources, + file_sources=file_sources, + ) + + def _build_archive( + self, + *, + app: AgentAppDsl, + skill_sources: Sequence[_SkillSource], + file_sources: Sequence[_FileSource], + audit: RosterAgentPackageAudit | None = None, + ) -> RosterAgentPackageExport: + required_entries = len(skill_sources) + len(file_sources) + 2 + if required_entries > dify_config.AGENT_PACKAGE_MAX_ENTRIES: + raise RosterAgentPackageTooLargeError("Roster Agent package has too many members") + + # Ownership is transferred to RosterAgentPackageExport. + output = cast( + BinaryIO, + tempfile.SpooledTemporaryFile(max_size=16 * 1024 * 1024, mode="w+b"), # noqa: SIM115 + ) + try: + skills: list[RosterAgentPackageSkill] = [] + files: list[RosterAgentPackageFile] = [] + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as archive: + total_size = 0 + nested_uncompressed_size = 0 + skill_packages = SkillPackageService() + sources: list[_SkillSource | _FileSource] = [*skill_sources, *file_sources] + for source in sources: + remaining_bytes = dify_config.AGENT_PACKAGE_MAX_BYTES - total_size + if isinstance(source, _SkillSource): + remaining_bytes = min(remaining_bytes, dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024) + member_size, member_digest = self._write_storage_member( + archive, path=source.path, storage_key=source.storage_key, max_bytes=remaining_bytes + ) + total_size += member_size + if isinstance(source, _SkillSource): + # Inspect only the embedded Skill; our writer already owns + # the outer container, resource sizes, and digests. + try: + inspection = skill_packages.inspect(content=archive.read(source.path), filename=source.path) + except SkillPackageError as exc: + raise RosterAgentPackageExportFailedError( + f"Roster Agent package contains unusable Skill {source.name!r}" + ) from exc + if inspection.name != source.name: + raise RosterAgentPackageExportFailedError( + f"Roster Agent package contains unusable Skill {source.name!r}: name mismatch" + ) + nested_uncompressed_size += inspection.uncompressed_size + if nested_uncompressed_size > dify_config.AGENT_PACKAGE_MAX_BYTES: + raise RosterAgentPackageTooLargeError( + "Roster Agent package nested Skill contents exceed the size limit" + ) + + skills.append( + RosterAgentPackageSkill( + id=source.id, + scope=source.scope, + name=source.name, + path=source.path, + size=member_size, + sha256=member_digest, + audit=RosterAgentPackageAudit(ref=source.audit_ref), + ) + ) + else: + files.append( + RosterAgentPackageFile( + id=source.id, + path=source.path, + size=member_size, + sha256=member_digest, + audit=RosterAgentPackageAudit(ref=source.audit_ref), + ) + ) + + app_bytes = yaml.safe_dump( + app.model_dump(mode="json", exclude_none=True), allow_unicode=True, sort_keys=False + ).encode("utf-8") + manifest = RosterAgentPackageManifest( + format=ROSTER_AGENT_PACKAGE_FORMAT, + format_version=ROSTER_AGENT_PACKAGE_FORMAT_VERSION, + audit=audit, + apps=[ + RosterAgentPackageApp( + path="app.yaml", size=len(app_bytes), sha256=hashlib.sha256(app_bytes).hexdigest() + ) + ], + skills=skills, + files=files, + ) + manifest.validate_apps({"app.yaml": app}) + manifest_bytes = yaml.safe_dump( + manifest.model_dump(mode="json", exclude_none=True), allow_unicode=True, sort_keys=False + ).encode("utf-8") + for path, document_bytes in (("app.yaml", app_bytes), ("manifest.yaml", manifest_bytes)): + if len(document_bytes) > dify_config.AGENT_PACKAGE_MAX_MANIFEST_BYTES: + raise RosterAgentPackageTooLargeError(f"Roster Agent package {path} exceeds the size limit") + total_size += len(document_bytes) + if total_size > dify_config.AGENT_PACKAGE_MAX_BYTES: + raise RosterAgentPackageTooLargeError("Roster Agent package exceeds the size limit") + archive.writestr(path, document_bytes) + + size = output.tell() + if size > dify_config.AGENT_PACKAGE_MAX_BYTES: + raise RosterAgentPackageTooLargeError("Roster Agent package exceeds the archive size limit") + output.seek(0) + return RosterAgentPackageExport( + archive=output, + filename=f"{self._safe_slug(app.package.metadata.name)}.ifpkg", + size=size, + ) + except Exception: + output.close() + raise + + def _collect_payloads( + self, + *, + session: Session, + tenant_id: str, + soul: AgentSoulConfig, + ) -> tuple[AgentSoulConfig, list[_SkillSource], list[_FileSource]]: + resource_data = soul.model_dump(mode="json") + skill_sources: list[_SkillSource] = [] + file_sources: list[_FileSource] = [] + + skill_file_ids = [item.file_id for item in soul.config_skills if not item.is_missing] + tool_files = self._tool_files(session=session, tenant_id=tenant_id, file_ids=skill_file_ids) + for skill_ref, portable_skill_ref in zip(soul.config_skills, resource_data["config_skills"]): + if skill_ref.is_missing: + continue + resource_id = f"s_{len(skill_sources) + 1:06d}" + tool_file = tool_files.get(skill_ref.file_id) + if tool_file is None: + raise RosterAgentPackageExportFailedError(f"Config skill {skill_ref.name!r} payload is unavailable") + path = f"{resource_id}.zip" + portable_skill_ref["file_id"] = resource_id + portable_skill_ref["is_missing"] = False + skill_sources.append( + _SkillSource( + path=path, + storage_key=tool_file.file_key, + id=resource_id, + scope="agent_config", + name=skill_ref.name, + display_name=None, + description=skill_ref.description, + priority=None, + audit_ref=tool_file.id, + ) + ) + + tool_file_refs = [ + item.file_id for item in soul.config_files if not item.is_missing and item.file_kind == "tool_file" + ] + upload_file_refs = [ + item.file_id for item in soul.config_files if not item.is_missing and item.file_kind == "upload_file" + ] + file_tool_files = self._tool_files(session=session, tenant_id=tenant_id, file_ids=tool_file_refs) + upload_files = self._upload_files(session=session, tenant_id=tenant_id, file_ids=upload_file_refs) + for file_ref, portable_file_ref in zip(soul.config_files, resource_data["config_files"]): + if file_ref.is_missing: + continue + resource_id = f"f_{len(file_sources) + 1:06d}" + extension = self._safe_extension(file_ref.name) + path = f"{resource_id}{extension}" + if file_ref.file_kind == "tool_file": + tool_file = file_tool_files.get(file_ref.file_id) + if tool_file is None: + raise RosterAgentPackageExportFailedError(f"Config file {file_ref.name!r} payload is unavailable") + storage_key = tool_file.file_key + mime_type = file_ref.mime_type or tool_file.mimetype + audit_ref = tool_file.id + else: + upload_file = upload_files.get(file_ref.file_id) + if upload_file is None: + raise RosterAgentPackageExportFailedError(f"Config file {file_ref.name!r} payload is unavailable") + storage_key = upload_file.key + mime_type = file_ref.mime_type or upload_file.mime_type + audit_ref = upload_file.id + portable_file_ref["file_id"] = resource_id + portable_file_ref["is_missing"] = False + portable_file_ref["mime_type"] = mime_type or "application/octet-stream" + file_sources.append( + _FileSource( + path=path, + storage_key=storage_key, + id=resource_id, + audit_ref=audit_ref, + ) + ) + + return AgentSoulConfig.model_validate(resource_data), skill_sources, file_sources + + @staticmethod + def _workspace_skill_sources( + *, soul: AgentSoulConfig, archives: Sequence[RuntimeAgentSkillArchive], start_index: int + ) -> list[_SkillSource]: + sources: list[_SkillSource] = [] + effective_skill_names = {item.name for item in soul.config_skills if not item.is_missing} + for archive in sorted(archives, key=lambda item: item.priority): + if archive.name in effective_skill_names: + continue + effective_skill_names.add(archive.name) + resource_id = f"s_{start_index + len(sources) + 1:06d}" + path = f"{resource_id}.zip" + sources.append( + _SkillSource( + path=path, + storage_key=archive.storage_key, + id=resource_id, + scope="workspace", + name=archive.name, + display_name=archive.display_name, + description=archive.description, + priority=archive.priority, + audit_ref=archive.version_id, + ) + ) + return sources + + @staticmethod + def _tool_files(*, session: Session, tenant_id: str, file_ids: Sequence[str]) -> dict[str, ToolFile]: + if not file_ids: + return {} + rows = session.scalars( + select(ToolFile).where(ToolFile.tenant_id == tenant_id, ToolFile.id.in_(set(file_ids))) + ).all() + return {item.id: item for item in rows} + + @staticmethod + def _upload_files(*, session: Session, tenant_id: str, file_ids: Sequence[str]) -> dict[str, UploadFile]: + if not file_ids: + return {} + rows = session.scalars( + select(UploadFile).where(UploadFile.tenant_id == tenant_id, UploadFile.id.in_(set(file_ids))) + ).all() + return {item.id: item for item in rows} + + def _write_storage_member( + self, + archive: zipfile.ZipFile, + *, + path: str, + storage_key: str, + max_bytes: int, + ) -> tuple[int, str]: + digest = hashlib.sha256() + size = 0 + try: + with archive.open(path, "w", force_zip64=True) as target: + for chunk in self._storage.load_stream(storage_key): + if not isinstance(chunk, bytes): + raise TypeError("storage stream returned a non-bytes chunk") + if size + len(chunk) > max_bytes: + raise RosterAgentPackageTooLargeError("Roster Agent package payload exceeds the size limit") + size += len(chunk) + digest.update(chunk) + target.write(chunk) + except RosterAgentPackageTooLargeError: + raise + except Exception as exc: + raise RosterAgentPackageExportFailedError(f"Unable to read package resource {path!r}") from exc + return size, digest.hexdigest() + + @staticmethod + def _safe_extension(filename: str) -> str: + extension = os.path.splitext(filename)[1].lower() + if not extension or not re.fullmatch(r"\.[a-z0-9]{1,16}", extension): + return ".bin" + return extension + + @staticmethod + def _safe_slug(name: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return slug[:80] or "agent" + + +__all__ = ["RosterAgentPackageExporter"] diff --git a/api/services/agent/roster_package_importer.py b/api/services/agent/roster_package_importer.py new file mode 100644 index 00000000000..bdc9bfe7b5b --- /dev/null +++ b/api/services/agent/roster_package_importer.py @@ -0,0 +1,434 @@ +"""Materialize validated Roster Agent packages into a new Agent App.""" + +from __future__ import annotations + +import hashlib +import logging +import os +from dataclasses import dataclass +from typing import BinaryIO, Protocol +from uuid import uuid4 + +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError + +from configs import dify_config +from constants.model_template import default_app_templates +from core.db.session_factory import session_factory +from extensions.ext_storage import storage +from extensions.storage.storage_type import StorageType +from libs.datetime_utils import naive_utc_now +from models import Account +from models.agent import ( + Agent, + AgentConfigDraft, + AgentConfigDraftType, + AgentConfigRevisionOperation, + AgentConfigSnapshot, + AgentScope, + AgentSource, + AgentStatus, +) +from models.agent_config_entities import AgentSoulConfig +from models.enums import CreatorUserRole +from models.model import App, AppMode, AppModelConfig, UploadFile +from models.tools import ToolFile +from services.agent.composer_validator import ComposerConfigValidator +from services.agent.dsl_entities import AgentPackage, AgentPackageMetadata, make_portable_agent_soul +from services.agent.dsl_service import AgentDslService +from services.agent.errors import ( + AgentNameConflictError, + InvalidComposerConfigError, + InvalidRosterAgentPackageError, + PlaintextSecretNotAllowedError, + RosterAgentPackageImportFailedError, + RosterAgentPackageResourceUnavailableError, + RosterAgentPackageTooLargeError, +) +from services.agent.roster_package_dependencies import check_package_dependencies +from services.agent.roster_package_entities import PreparedRosterAgentPackage +from services.agent.roster_package_reader import RosterAgentPackageReader +from services.agent.roster_service import AgentRosterService +from services.agent.skill_package_service import SkillPackageError, SkillPackageService +from services.app_creation_records import create_installed_app_record, create_site_record +from services.app_service import AppService +from services.entities.dsl_entities import DslImportWarning +from services.file_service import FileService +from services.icon_configuration import DEFAULT_ICON, DEFAULT_ICON_BACKGROUND, DEFAULT_ICON_TYPE + +logger = logging.getLogger(__name__) + + +class _Storage(Protocol): + def save(self, filename: str, data: bytes) -> None: ... + + +@dataclass(frozen=True) +class RosterAgentPackageImportResult: + app_id: str + agent_id: str + warnings: list[DslImportWarning] + + +class RosterAgentPackageImporter: + """Commit uploaded file records before creating the Agent in a separate transaction.""" + + def __init__( + self, + *, + storage_backend: _Storage = storage, + ) -> None: + self._reader = RosterAgentPackageReader() + self._skill_packages = SkillPackageService() + self._storage = storage_backend + + def import_package( + self, + *, + source: BinaryIO, + tenant_id: str, + account: Account, + ) -> RosterAgentPackageImportResult: + with self._reader.read(source) as package: + if len(package.apps) != 1: + raise InvalidRosterAgentPackageError("Import requires exactly one Agent App") + app_dsl = next(iter(package.apps.values())) + agent_package = app_dsl.package + agent_package.soul = make_portable_agent_soul(agent_package.soul) + self._validate_supported_resources(package, soul=agent_package.soul) + try: + ComposerConfigValidator.validate_importable_agent_soul(agent_package.soul) + except (InvalidComposerConfigError, PlaintextSecretNotAllowedError) as exc: + raise InvalidRosterAgentPackageError("Roster Agent package Soul is invalid") from exc + + check_package_dependencies(tenant_id=tenant_id, account=account, dependencies=app_dsl.dependencies) + try: + self._ensure_name_available(tenant_id=tenant_id, name=agent_package.metadata.name) + soul, skill_warnings = self._upload_resources( + package=package, + agent_package=agent_package, + tenant_id=tenant_id, + account_id=account.id, + ) + with session_factory.create_session() as session: + resolved_soul, warnings = AgentDslService(session).resolve_package_soul( + tenant_id=tenant_id, + package=AgentPackage(metadata=agent_package.metadata, soul=soul), + package_path="agent", + ) + warnings = [*skill_warnings, *warnings] + app_id, agent_id = self._persist_import( + tenant_id=tenant_id, + account=account, + metadata=agent_package.metadata, + soul=resolved_soul, + ) + except Exception as exc: + if isinstance(exc, IntegrityError) and "roster_unique_name" in str(exc): + raise AgentNameConflictError() from exc + if isinstance( + exc, + ( + AgentNameConflictError, + InvalidRosterAgentPackageError, + RosterAgentPackageTooLargeError, + RosterAgentPackageResourceUnavailableError, + ), + ): + raise + raise RosterAgentPackageImportFailedError() from exc + + try: + self._finalize_app(app_id=app_id, agent_id=agent_id, tenant_id=tenant_id, account=account) + except Exception: + logger.warning( + "Imported Agent App post-commit initialization failed: tenant_id=%s app_id=%s", + tenant_id, + app_id, + exc_info=True, + ) + return RosterAgentPackageImportResult(app_id=app_id, agent_id=agent_id, warnings=warnings) + + @classmethod + def _validate_supported_resources(cls, package: PreparedRosterAgentPackage, *, soul: AgentSoulConfig) -> None: + skill_names = [item.name for item in soul.config_skills] + skill_names.extend(item.name for item in package.manifest.skills if item.scope == "workspace") + if len(skill_names) != len(set(skill_names)): + raise InvalidRosterAgentPackageError("Roster Agent package contains duplicate effective Skill names") + file_names = [item.name for item in soul.config_files] + if len(file_names) != len(set(file_names)): + raise InvalidRosterAgentPackageError("Roster Agent package contains duplicate config file names") + file_refs = {item.file_id: item for item in soul.config_files if not item.is_missing} + for resource in package.manifest.files: + filename = file_refs[resource.id].name + extension = cls._extension(filename) + limit = FileService.file_size_limit(extension=extension) + if resource.size > limit: + raise InvalidRosterAgentPackageError( + f"Roster Agent package file {filename!r} exceeds its file size limit" + ) + if extension and extension in dify_config.UPLOAD_FILE_EXTENSION_BLACKLIST: + raise InvalidRosterAgentPackageError( + f"Roster Agent package file extension '.{extension}' is not allowed" + ) + + @staticmethod + def _ensure_name_available(*, tenant_id: str, name: str) -> None: + with session_factory.create_session() as session: + exists = session.scalar( + select(Agent.id) + .where( + Agent.tenant_id == tenant_id, + Agent.scope == AgentScope.ROSTER, + Agent.status == AgentStatus.ACTIVE, + Agent.name == name, + ) + .limit(1) + ) + if exists is not None: + raise AgentNameConflictError() + + def _upload_resources( + self, + *, + package: PreparedRosterAgentPackage, + agent_package: AgentPackage, + tenant_id: str, + account_id: str, + ) -> tuple[AgentSoulConfig, list[DslImportWarning]]: + """Upload outside database transactions, then commit all file records together.""" + files: list[ToolFile | UploadFile] = [] + warnings: list[DslImportWarning] = [] + soul_data = agent_package.soul.model_dump(mode="json") + skill_refs_by_package_id = { + item["file_id"]: item for item in soul_data["config_skills"] if not item["is_missing"] + } + file_refs_by_package_id = { + item["file_id"]: item for item in soul_data["config_files"] if not item["is_missing"] + } + + skill_descriptions = {item.name: item.description for item in agent_package.workspace_skills} + skill_descriptions.update({item.name: item.description for item in agent_package.soul.config_skills}) + for skill_resource in package.manifest.skills: + target_ref = skill_refs_by_package_id.get(skill_resource.id) + if target_ref is None: + target_ref = {} + soul_data["config_skills"].append(target_ref) + target_ref.update( + { + "name": skill_resource.name, + "description": skill_descriptions.get(skill_resource.name, ""), + "file_kind": "tool_file", + "file_id": "", + "is_missing": True, + "size": skill_resource.size, + "hash": skill_resource.sha256, + "mime_type": "application/zip", + } + ) + reason = package.invalid_skills.get(skill_resource.id) + normalized = None + try: + if reason is None: + payload = self._reader.read_member_bytes( + package, + skill_resource.path, + max_bytes=dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024, + ) + normalized = self._skill_packages.validate_and_normalize( + content=payload, filename=skill_resource.path + ) + except SkillPackageError as exc: + reason = exc.code + except InvalidRosterAgentPackageError: + reason = "archive_integrity_failed" + if normalized is None: + warnings.append( + DslImportWarning( + code="agent_skill_missing", + path=f"skills.{skill_resource.id}", + message=f"Skill {skill_resource.name!r} is damaged and must be uploaded again.", + details={"name": skill_resource.name, "reason": reason, "path": skill_resource.path}, + ) + ) + continue + storage_key = f"tools/{tenant_id}/{uuid4().hex}.zip" + tool_file = ToolFile( + user_id=account_id, + tenant_id=tenant_id, + conversation_id=None, + file_key=storage_key, + mimetype="application/zip", + name=f"{skill_resource.name}.zip", + size=len(normalized.archive_bytes), + original_url=None, + ) + self._save_resource(storage_key=storage_key, payload=normalized.archive_bytes, files=files, row=tool_file) + target_ref.update( + { + "description": normalized.manifest.description, + "file_id": tool_file.id, + "is_missing": False, + "size": tool_file.size, + "hash": normalized.manifest.hash, + } + ) + + for file_resource in package.manifest.files: + package_ref = file_refs_by_package_id[file_resource.id] + file_kind = package_ref["file_kind"] + mime_type = package_ref["mime_type"] or "application/octet-stream" + extension = self._extension(package_ref["name"]) + payload = self._reader.read_member_bytes(package, file_resource.path, max_bytes=file_resource.size) + if file_kind == "tool_file": + storage_key = f"tools/{tenant_id}/{uuid4().hex}.{extension or 'bin'}" + row: ToolFile | UploadFile = ToolFile( + user_id=account_id, + tenant_id=tenant_id, + conversation_id=None, + file_key=storage_key, + mimetype=mime_type, + name=package_ref["name"], + size=len(payload), + original_url=None, + ) + hash_value = None + else: + storage_key = f"upload_files/{tenant_id}/{uuid4()}.{extension or 'bin'}" + now = naive_utc_now() + row = UploadFile( + tenant_id=tenant_id, + storage_type=StorageType(dify_config.STORAGE_TYPE), + key=storage_key, + name=package_ref["name"], + size=len(payload), + extension=extension, + mime_type=mime_type, + created_by_role=CreatorUserRole.ACCOUNT, + created_by=account_id, + created_at=now, + used=False, + hash=hashlib.sha3_256(payload).hexdigest(), + ) + hash_value = row.hash + self._save_resource(storage_key=storage_key, payload=payload, files=files, row=row) + package_ref.update( + { + "file_id": row.id, + "is_missing": False, + "size": row.size, + "hash": hash_value, + "mime_type": mime_type, + } + ) + + soul = AgentSoulConfig.model_validate(soul_data) + with session_factory.create_session() as session, session.begin(): + session.add_all(files) + return soul, warnings + + def _save_resource( + self, + *, + storage_key: str, + payload: bytes, + files: list[ToolFile | UploadFile], + row: ToolFile | UploadFile, + ) -> None: + try: + self._storage.save(storage_key, payload) + except Exception as exc: + raise RosterAgentPackageResourceUnavailableError() from exc + files.append(row) + + @staticmethod + def _persist_import( + *, + tenant_id: str, + account: Account, + metadata: AgentPackageMetadata, + soul: AgentSoulConfig, + ) -> tuple[str, str]: + with session_factory.create_session() as session, session.begin(): + app = App(**default_app_templates[AppMode.AGENT]["app"]) + app.id = str(uuid4()) + app.tenant_id = tenant_id + app.name = metadata.name + app.description = metadata.description + app.icon_type = DEFAULT_ICON_TYPE + app.icon = DEFAULT_ICON + app.icon_background = DEFAULT_ICON_BACKGROUND + app.api_rph = 0 + app.api_rpm = 0 + app.max_active_requests = None + app.created_by = account.id + app.maintainer = account.id + app.updated_by = account.id + session.add(app) + + app_model_config = AppModelConfig(app_id=app.id, created_by=account.id, updated_by=account.id) + session.add(app_model_config) + app.app_model_config_id = app_model_config.id + + agent = AgentRosterService(session).create_backing_agent_for_app( + tenant_id=tenant_id, + account_id=account.id, + app_id=app.id, + name=metadata.name, + description=metadata.description, + role=metadata.role, + source=AgentSource.IMPORTED, + initial_soul=soul, + revision_operation=AgentConfigRevisionOperation.IMPORT_PACKAGE, + ) + snapshot = session.get(AgentConfigSnapshot, agent.active_config_snapshot_id) + if snapshot is None: + raise RuntimeError("Imported Agent snapshot was not created") + session.add( + AgentConfigDraft( + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", + base_snapshot_id=snapshot.id, + home_snapshot_id=snapshot.home_snapshot_id, + config_snapshot=soul, + created_by=account.id, + updated_by=account.id, + ) + ) + agent.active_config_is_published = False + upload_file_ids = [ + item.file_id for item in soul.config_files if item.file_kind == "upload_file" and not item.is_missing + ] + if upload_file_ids: + session.execute( + update(UploadFile) + .where(UploadFile.tenant_id == tenant_id, UploadFile.id.in_(upload_file_ids)) + .values(used=True, used_by=account.id, used_at=naive_utc_now()) + ) + create_site_record(app=app, account=account, session=session) + create_installed_app_record(app=app, session=session) + return app.id, agent.id + + @staticmethod + def _finalize_app(*, app_id: str, agent_id: str, tenant_id: str, account: Account) -> None: + with session_factory.create_session() as session: + app = session.scalar(select(App).where(App.id == app_id, App.tenant_id == tenant_id).limit(1)) + if app is None: + raise RuntimeError("Imported Agent App is unavailable after commit") + AppService().finalize_created_app( + app=app, + backing_agent_id=agent_id, + account=account, + session=session, + created_records_initialized=True, + ) + + @staticmethod + def _extension(filename: str) -> str: + return os.path.splitext(filename)[1].lstrip(".").lower() + + +__all__ = ["RosterAgentPackageImportResult", "RosterAgentPackageImporter"] diff --git a/api/services/agent/roster_package_reader.py b/api/services/agent/roster_package_reader.py new file mode 100644 index 00000000000..2ef00ce3572 --- /dev/null +++ b/api/services/agent/roster_package_reader.py @@ -0,0 +1,346 @@ +"""Read and validate portable Roster Agent ``.ifpkg`` archives.""" + +from __future__ import annotations + +import hashlib +import io +import posixpath +import stat +import tempfile +import zipfile +import zlib +from collections.abc import Hashable +from typing import Any, BinaryIO, cast, override + +import yaml +from pydantic import ValidationError + +from configs import dify_config +from constants.dsl_version import CURRENT_APP_DSL_VERSION +from services.agent.dsl_entities import AgentAppDsl +from services.agent.errors import InvalidRosterAgentPackageError, RosterAgentPackageTooLargeError +from services.agent.roster_package_entities import ( + ROSTER_AGENT_PACKAGE_MAX_SIGNATURE_BYTES, + PreparedRosterAgentPackage, + RosterAgentPackageApp, + RosterAgentPackageManifest, + RosterAgentPackageMember, + RosterAgentPackageSkill, +) +from services.agent.skill_package_service import SkillPackageError, SkillPackageService +from services.dsl_version import check_version_compatibility +from services.entities.dsl_entities import ImportStatus + +_COPY_CHUNK_SIZE = 1024 * 1024 +_ALLOWED_COMPRESSIONS = {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED} + + +class RosterAgentPackageReader: + """Produce a validated, bounded package without external side effects.""" + + def __init__(self) -> None: + self._skill_packages = SkillPackageService() + + def read(self, source: BinaryIO) -> PreparedRosterAgentPackage: + """Validate the container and record unusable Skill payloads for import warnings.""" + # Ownership is transferred to PreparedRosterAgentPackage. + spool = cast( + BinaryIO, + tempfile.SpooledTemporaryFile(max_size=16 * 1024 * 1024, mode="w+b"), # noqa: SIM115 + ) + try: + self._copy_bounded(source, spool) + spool.seek(0) + invalid_skills: dict[str, str] = {} + manifest, apps, members = self._validate_archive(spool, invalid_skills=invalid_skills) + spool.seek(0) + return PreparedRosterAgentPackage( + archive=spool, manifest=manifest, apps=apps, members=members, invalid_skills=invalid_skills + ) + except Exception: + spool.close() + raise + + def read_member_bytes(self, package: PreparedRosterAgentPackage, path: str, *, max_bytes: int) -> bytes: + """Read one already-validated member while rechecking its size and digest.""" + + member = package.members.get(path) + if member is None: + raise InvalidRosterAgentPackageError("Roster Agent package member is unavailable") + try: + package.archive.seek(0) + with zipfile.ZipFile(package.archive) as archive: + info = archive.getinfo(path) + payload, digest, _ = self._read_member( + archive, + info, + collect=True, + max_bytes=max_bytes, + expected_size=member.size, + ) + except (KeyError, OSError, zipfile.BadZipFile, EOFError, RuntimeError, ValueError, zlib.error) as exc: + raise InvalidRosterAgentPackageError("Roster Agent package member is unavailable") from exc + if digest != member.sha256: + raise InvalidRosterAgentPackageError("Roster Agent package member failed integrity checks") + return payload + + def _validate_archive( + self, archive_file: BinaryIO, *, invalid_skills: dict[str, str] + ) -> tuple[RosterAgentPackageManifest, dict[str, AgentAppDsl], dict[str, RosterAgentPackageMember]]: + try: + with zipfile.ZipFile(archive_file) as archive: + infos = archive.infolist() + if not infos: + raise InvalidRosterAgentPackageError("Roster Agent package is empty") + if len(infos) > dify_config.AGENT_PACKAGE_MAX_ENTRIES: + raise InvalidRosterAgentPackageError("Roster Agent package has too many members") + + info_by_path: dict[str, zipfile.ZipInfo] = {} + casefold_paths: set[str] = set() + total_uncompressed = 0 + for info in infos: + path = self._validate_member_info(info) + if path in info_by_path or path.casefold() in casefold_paths: + raise InvalidRosterAgentPackageError("Roster Agent package contains duplicate member paths") + info_by_path[path] = info + casefold_paths.add(path.casefold()) + total_uncompressed += info.file_size + if total_uncompressed > dify_config.AGENT_PACKAGE_MAX_BYTES: + raise RosterAgentPackageTooLargeError("Roster Agent package uncompressed size exceeds the limit") + + manifest_data, manifest_size = self._read_yaml_document(archive, info_by_path, "manifest.yaml") + try: + manifest = RosterAgentPackageManifest.model_validate(manifest_data) + except ValidationError as exc: + raise InvalidRosterAgentPackageError("Roster Agent package manifest is invalid") from exc + apps: dict[str, AgentAppDsl] = {} + members: dict[str, RosterAgentPackageMember] = {} + streamed_size = manifest_size + for app_resource in manifest.apps: + app_data, app_size = self._read_yaml_document( + archive, info_by_path, app_resource.path, resource=app_resource + ) + streamed_size += app_size + if streamed_size > dify_config.AGENT_PACKAGE_MAX_BYTES: + raise RosterAgentPackageTooLargeError( + "Roster Agent package uncompressed size exceeds the limit" + ) + try: + app = AgentAppDsl.model_validate(app_data) + if check_version_compatibility(app.version, CURRENT_APP_DSL_VERSION) in { + ImportStatus.FAILED, + ImportStatus.PENDING, + }: + raise ValueError("unsupported App DSL version") + except ValueError as exc: + raise InvalidRosterAgentPackageError("Roster Agent package app is invalid") from exc + apps[app_resource.path] = app + members[app_resource.path] = RosterAgentPackageMember(size=app_size, sha256=app_resource.sha256) + try: + manifest.validate_apps(apps) + except ValueError as exc: + raise InvalidRosterAgentPackageError("Roster Agent package app is invalid") from exc + + expected_paths = { + "manifest.yaml", + *(item.path for item in manifest.apps), + *(item.path for item in manifest.skills), + *(item.path for item in manifest.files), + } + actual_paths = set(info_by_path) + if "signature.sig" in actual_paths: + if info_by_path["signature.sig"].file_size > ROSTER_AGENT_PACKAGE_MAX_SIGNATURE_BYTES: + raise RosterAgentPackageTooLargeError("Roster Agent package signature is too large") + actual_paths.remove("signature.sig") + if actual_paths != expected_paths: + raise InvalidRosterAgentPackageError("Roster Agent package members do not match the manifest") + + nested_uncompressed_size = 0 + signature_info = info_by_path.get("signature.sig") + if signature_info is not None: + _, _, signature_size = self._read_member( + archive, + signature_info, + collect=False, + max_bytes=min( + ROSTER_AGENT_PACKAGE_MAX_SIGNATURE_BYTES, + dify_config.AGENT_PACKAGE_MAX_BYTES - streamed_size, + ), + expected_size=signature_info.file_size, + ) + streamed_size += signature_size + for resource in [*manifest.skills, *manifest.files]: + info = info_by_path[resource.path] + payload = b"" + remaining_package_bytes = dify_config.AGENT_PACKAGE_MAX_BYTES - streamed_size + if isinstance(resource, RosterAgentPackageSkill): + max_skill_bytes = dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024 + if info.file_size > max_skill_bytes: + raise RosterAgentPackageTooLargeError( + f"Roster Agent package Skill {resource.name!r} exceeds the size limit" + ) + try: + payload, digest, actual_size = self._read_member( + archive, + info, + collect=True, + max_bytes=min(max_skill_bytes, remaining_package_bytes), + expected_size=resource.size, + ) + except (InvalidRosterAgentPackageError, zipfile.BadZipFile, EOFError, zlib.error): + invalid_skills[resource.id] = "archive_integrity_failed" + streamed_size += info.file_size + continue + else: + _, digest, actual_size = self._read_member( + archive, + info, + collect=False, + max_bytes=remaining_package_bytes, + expected_size=resource.size, + ) + streamed_size += actual_size + if digest != resource.sha256: + if isinstance(resource, RosterAgentPackageSkill): + invalid_skills[resource.id] = "checksum_mismatch" + continue + raise InvalidRosterAgentPackageError( + f"Roster Agent package resource {resource.path!r} failed integrity checks", + ) + members[resource.path] = RosterAgentPackageMember( + size=resource.size, + sha256=digest, + ) + if isinstance(resource, RosterAgentPackageSkill): + try: + inspection = self._skill_packages.inspect(content=payload, filename=resource.path) + except SkillPackageError as exc: + invalid_skills[resource.id] = exc.code + continue + nested_uncompressed_size += inspection.uncompressed_size + if nested_uncompressed_size > dify_config.AGENT_PACKAGE_MAX_BYTES: + raise RosterAgentPackageTooLargeError( + "Roster Agent package nested Skill contents exceed the size limit" + ) + if inspection.name != resource.name: + invalid_skills[resource.id] = "skill_name_mismatch" + return manifest, apps, members + except (InvalidRosterAgentPackageError, RosterAgentPackageTooLargeError): + raise + except (OSError, zipfile.BadZipFile, EOFError, RuntimeError, ValueError, zlib.error) as exc: + raise InvalidRosterAgentPackageError("Roster Agent package is not a valid ZIP") from exc + + @staticmethod + def _copy_bounded(source: BinaryIO, target: BinaryIO) -> None: + size = 0 + while True: + chunk = source.read(_COPY_CHUNK_SIZE) + if not chunk: + break + if not isinstance(chunk, bytes): + raise InvalidRosterAgentPackageError("Roster Agent package must be binary") + size += len(chunk) + if size > dify_config.AGENT_PACKAGE_MAX_BYTES: + raise RosterAgentPackageTooLargeError("Roster Agent package exceeds the archive size limit") + target.write(chunk) + + @staticmethod + def _validate_member_info(info: zipfile.ZipInfo) -> str: + name = info.filename + if info.is_dir(): + raise InvalidRosterAgentPackageError("Roster Agent package must use root-level files") + if "\x00" in name or "\\" in name or any(ord(char) < 0x20 for char in name): + raise InvalidRosterAgentPackageError("Roster Agent package contains an unsafe path") + normalized = posixpath.normpath(name) + if normalized != name or normalized in {"", ".", ".."} or normalized.startswith("/") or "/" in normalized: + raise InvalidRosterAgentPackageError("Roster Agent package contains an unsafe path") + if stat.S_ISLNK(info.external_attr >> 16): + raise InvalidRosterAgentPackageError("Roster Agent package must not contain symbolic links") + if info.flag_bits & 0x1: + raise InvalidRosterAgentPackageError("Roster Agent package must not contain encrypted members") + if info.compress_type not in _ALLOWED_COMPRESSIONS: + raise InvalidRosterAgentPackageError("Roster Agent package uses unsupported compression") + if info.file_size and info.compress_size == 0: + raise InvalidRosterAgentPackageError("Roster Agent package contains invalid ZIP metadata") + return normalized + + @staticmethod + def _read_member( + archive: zipfile.ZipFile, + info: zipfile.ZipInfo, + *, + collect: bool, + max_bytes: int, + expected_size: int, + ) -> tuple[bytes, str, int]: + digest = hashlib.sha256() + output = io.BytesIO() if collect else None + size = 0 + with archive.open(info) as member: + while chunk := member.read(_COPY_CHUNK_SIZE): + size += len(chunk) + if size > max_bytes: + raise RosterAgentPackageTooLargeError("Roster Agent package member exceeds the size limit") + if size > expected_size: + raise InvalidRosterAgentPackageError("Roster Agent package member failed integrity checks") + digest.update(chunk) + if output is not None: + output.write(chunk) + if size != expected_size: + raise InvalidRosterAgentPackageError("Roster Agent package member failed integrity checks") + return output.getvalue() if output is not None else b"", digest.hexdigest(), size + + def _read_yaml_document( + self, + archive: zipfile.ZipFile, + infos: dict[str, zipfile.ZipInfo], + path: str, + *, + resource: RosterAgentPackageApp | None = None, + ) -> tuple[Any, int]: + info = infos.get(path) + if info is None: + raise InvalidRosterAgentPackageError(f"Roster Agent package is missing {path}") + if info.file_size > dify_config.AGENT_PACKAGE_MAX_MANIFEST_BYTES: + raise RosterAgentPackageTooLargeError(f"Roster Agent package {path} exceeds the size limit") + payload, digest, size = self._read_member( + archive, + info, + collect=True, + max_bytes=dify_config.AGENT_PACKAGE_MAX_MANIFEST_BYTES, + expected_size=resource.size if resource is not None else info.file_size, + ) + if resource is not None and digest != resource.sha256: + raise InvalidRosterAgentPackageError(f"Roster Agent package {path} failed integrity checks") + try: + # The custom SafeLoader also rejects duplicate keys and aliases. + return yaml.load(payload, Loader=_PackageYamlLoader), size # noqa: S506 + except (UnicodeDecodeError, yaml.YAMLError, ValueError, RecursionError) as exc: + raise InvalidRosterAgentPackageError( + f"Roster Agent package {path.removesuffix('.yaml')} is invalid" + ) from exc + + +class _PackageYamlLoader(yaml.SafeLoader): + """Reject ambiguous mappings and aliases in untrusted package documents.""" + + @override + def compose_node(self, parent: yaml.Node | None, index: Any) -> yaml.Node | None: + if self.check_event(yaml.AliasEvent): + raise ValueError("YAML aliases are not supported") + return super().compose_node(parent, index) + + @override + def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[Hashable, Any]: + result: dict[Hashable, Any] = {} + for key_node, value_node in node.value: + key = self.construct_object(key_node, deep=deep) + if not isinstance(key, str): + raise ValueError("YAML mapping keys must be strings") + if key in result: + raise ValueError(f"duplicate YAML key: {key}") + result[key] = self.construct_object(value_node, deep=deep) + return result + + +__all__ = ["RosterAgentPackageReader"] diff --git a/api/services/agent/skill_package_service.py b/api/services/agent/skill_package_service.py index 42c48d2f26d..c275b2a169d 100644 --- a/api/services/agent/skill_package_service.py +++ b/api/services/agent/skill_package_service.py @@ -20,16 +20,14 @@ import io import posixpath import zipfile import zlib +from dataclasses import dataclass import yaml from pydantic import BaseModel, Field, ValidationError, field_validator from configs import dify_config +from libs.zip_filename_recovery import open_zip_with_replacement_names -# Bounds — generous but finite so a hostile upload can't exhaust memory/disk. -_MAX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024 -_MAX_SKILL_MD_BYTES = 1 * 1024 * 1024 -_MAX_ENTRIES = 5000 _ALLOWED_EXTENSIONS = (".zip", ".skill") _SKILL_MD_NAME = "SKILL.md" _SKILL_NAME_PATTERN = r"^[a-z0-9]+(?:-[a-z0-9]+)*$" @@ -50,15 +48,9 @@ class SkillPackageError(Exception): self.status_code = status_code -class SkillManifest(BaseModel): - """Validated metadata extracted from a Skill package.""" - +class _SkillMetadata(BaseModel): name: str = Field(min_length=1, max_length=64, pattern=_SKILL_NAME_PATTERN) description: str = Field(min_length=1, max_length=_MAX_SKILL_DESCRIPTION_LENGTH) - entry_path: str # path of SKILL.md inside the archive - files: list[str] # all (safe) file paths inside the archive - size: int # total uncompressed bytes - hash: str # sha256 of the archive bytes @field_validator("name", "description", mode="before") @classmethod @@ -66,18 +58,44 @@ class SkillManifest(BaseModel): return value.strip() if isinstance(value, str) else value +class SkillPackageInspection(_SkillMetadata): + """Metadata read without rebuilding or fully extracting a Skill archive.""" + + uncompressed_size: int = Field(ge=0) + + +class SkillManifest(_SkillMetadata): + """Validated metadata extracted from a Skill package.""" + + entry_path: str # path of SKILL.md inside the archive + files: list[str] # all (safe) file paths inside the archive + size: int # total uncompressed bytes + hash: str # sha256 of the archive bytes + + class NormalizedSkillPackage(BaseModel): """Canonical skill package bytes and metadata ready to store as Agent config.""" manifest: SkillManifest archive_bytes: bytes + + +@dataclass(frozen=True) +class _InspectedSkillArchive: + metadata: SkillPackageInspection + normalized_members: dict[str, zipfile.ZipInfo] skill_md_bytes: bytes - strip_prefix: str | None class SkillPackageService: """Validate Skill archives and produce a normalized package.""" + def inspect(self, *, content: bytes, filename: str) -> SkillPackageInspection: + """Validate archive metadata and SKILL.md without rebuilding the archive.""" + archive = self._open_archive(content=content, filename=filename) + with archive: + return self._inspect_archive(archive).metadata + def validate_and_normalize(self, *, content: bytes, filename: str) -> NormalizedSkillPackage: """Return the canonical package for an uploaded skill archive. @@ -89,43 +107,30 @@ class SkillPackageService: The returned manifest is normalized to archive-root ``SKILL.md`` and its hash describes the rebuilt archive bytes. Member read/decompression failures while consuming the archive are mapped to ``invalid_archive``. + Malformed UTF-8 member names are decoded with replacement characters; + recovered path collisions still fail the normal duplicate-path check. """ archive = self._open_archive(content=content, filename=filename) with archive: - members = self._collect_file_members(archive) - member_paths = [safe_path for _, safe_path in members] - entry_path = self._find_skill_md(member_paths) - strip_prefix = self._skill_root_prefix(entry_path) - normalized_members = self._normalize_members( - members=members, - skill_root_prefix=strip_prefix, - ignore_outside_selected_root=self._can_strip_single_top_level_folder( - paths=member_paths, entry_path=entry_path - ), - ) - skill_md_member = normalized_members[_SKILL_MD_NAME] - self._validate_skill_md_size(skill_md_member) - raw_skill_md_bytes = self._read_member_bytes_from_archive(archive, member_info=skill_md_member) - skill_md = self._decode_skill_md(raw_skill_md_bytes) - skill_md_bytes = skill_md.encode("utf-8") + inspection = self._inspect_archive(archive) normalized_archive_bytes = self._build_normalized_archive( archive=archive, - normalized_members=normalized_members, - skill_md_bytes=skill_md_bytes, - ) - normalized_size = sum( - len(skill_md_bytes) if path == _SKILL_MD_NAME else max(info.file_size, 0) - for path, info in normalized_members.items() + normalized_members=inspection.normalized_members, + skill_md_bytes=inspection.skill_md_bytes, ) - name, description = self._parse_skill_md(skill_md) + if len(normalized_archive_bytes) > dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024: + raise SkillPackageError("archive_too_large", "normalized skill archive exceeds size limit", status_code=400) try: manifest = SkillManifest( - name=name, - description=description, + name=inspection.metadata.name, + description=inspection.metadata.description, entry_path=_SKILL_MD_NAME, - files=sorted(normalized_members), - size=normalized_size, + files=sorted(inspection.normalized_members), + size=sum( + len(inspection.skill_md_bytes) if path == _SKILL_MD_NAME else info.file_size + for path, info in inspection.normalized_members.items() + ), hash=hashlib.sha256(normalized_archive_bytes).hexdigest(), ) except ValidationError as exc: @@ -133,8 +138,38 @@ class SkillPackageService: return NormalizedSkillPackage( manifest=manifest, archive_bytes=normalized_archive_bytes, + ) + + def _inspect_archive(self, archive: zipfile.ZipFile) -> _InspectedSkillArchive: + members = self._collect_file_members(archive) + member_paths = [safe_path for _, safe_path in members] + entry_path = self._find_skill_md(member_paths) + strip_prefix = self._skill_root_prefix(entry_path) + normalized_members = self._normalize_members( + members=members, + skill_root_prefix=strip_prefix, + ignore_outside_selected_root=self._can_strip_single_top_level_folder( + paths=member_paths, entry_path=entry_path + ), + ) + skill_md_member = normalized_members[_SKILL_MD_NAME] + self._validate_skill_md_size(skill_md_member) + raw_skill_md_bytes = self._read_member_bytes_from_archive(archive, member_info=skill_md_member) + skill_md = self._decode_skill_md(raw_skill_md_bytes) + skill_md_bytes = skill_md.encode("utf-8") + name, description = self._parse_skill_md(skill_md) + try: + metadata = SkillPackageInspection( + name=name, + description=description, + uncompressed_size=sum(info.file_size for info, _ in members), + ) + except ValidationError as exc: + raise self._manifest_validation_error(exc) from exc + return _InspectedSkillArchive( + metadata=metadata, + normalized_members=normalized_members, skill_md_bytes=skill_md_bytes, - strip_prefix=strip_prefix, ) @staticmethod @@ -171,21 +206,24 @@ class SkillPackageService: raise SkillPackageError("archive_too_large", "skill archive exceeds size limit", status_code=400) try: - return zipfile.ZipFile(io.BytesIO(content)) + return open_zip_with_replacement_names(content) except zipfile.BadZipFile as exc: raise SkillPackageError("invalid_archive", "skill archive is not a valid zip", status_code=400) from exc def _collect_file_members(self, archive: zipfile.ZipFile) -> list[tuple[zipfile.ZipInfo, str]]: - infos = [info for info in archive.infolist() if not info.is_dir()] - if len(infos) > _MAX_ENTRIES: + archive_infos = archive.infolist() + if len(archive_infos) > dify_config.SKILL_PACKAGE_MAX_ENTRIES: raise SkillPackageError("too_many_entries", "skill archive has too many files", status_code=400) + infos = [info for info in archive_infos if not info.is_dir()] members: list[tuple[zipfile.ZipInfo, str]] = [] total_uncompressed = 0 for info in infos: members.append((info, self._safe_member_path(info.filename))) - total_uncompressed += max(info.file_size, 0) - if total_uncompressed > _MAX_UNCOMPRESSED_BYTES: + total_uncompressed += info.file_size + if info.file_size and info.compress_size == 0: + raise SkillPackageError("invalid_archive", "skill package has invalid ZIP metadata", status_code=400) + if total_uncompressed > dify_config.SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES: raise SkillPackageError( "archive_too_large", "skill archive uncompressed size exceeds limit", @@ -225,12 +263,8 @@ class SkillPackageService: else: normalized_path = safe_path - if ( - not normalized_path - or normalized_path in {".", ".."} - or normalized_path.startswith("/") - or "\\" in normalized_path - ): + # Paths are already normalized and safe; "." is not a file. + if normalized_path == ".": raise SkillPackageError("unsafe_path", "skill archive contains an unsafe path", status_code=400) if normalized_path in normalized_members: raise SkillPackageError( @@ -306,7 +340,7 @@ class SkillPackageService: @staticmethod def _validate_skill_md_size(member_info: zipfile.ZipInfo) -> None: - if member_info.file_size > _MAX_SKILL_MD_BYTES: + if member_info.file_size > dify_config.SKILL_PACKAGE_MAX_SKILL_MD_BYTES: raise SkillPackageError("skill_md_too_large", "SKILL.md exceeds size limit", status_code=400) @staticmethod @@ -343,4 +377,10 @@ class SkillPackageService: return loaded if isinstance(loaded, dict) else {} -__all__ = ["NormalizedSkillPackage", "SkillManifest", "SkillPackageError", "SkillPackageService"] +__all__ = [ + "NormalizedSkillPackage", + "SkillManifest", + "SkillPackageError", + "SkillPackageInspection", + "SkillPackageService", +] diff --git a/api/services/agent_config_service.py b/api/services/agent_config_service.py index e4bfe2a0b9e..56a1ecb342b 100644 --- a/api/services/agent_config_service.py +++ b/api/services/agent_config_service.py @@ -609,7 +609,7 @@ class AgentConfigService: ) self._require_writable(target, surface=surface) try: - skill_ref, _package = self._skill_normalizer.normalize( + skill_ref = self._skill_normalizer.normalize( content=content, filename=filename, requested_name=None, @@ -1064,7 +1064,7 @@ class AgentConfigService: ) archive_bytes = storage.load_once(tool_file.file_key) try: - skill_ref, _package = self._skill_normalizer.normalize( + skill_ref = self._skill_normalizer.normalize( content=archive_bytes, filename=tool_file.name, requested_name=name, diff --git a/api/services/app_creation_records.py b/api/services/app_creation_records.py new file mode 100644 index 00000000000..f79d42d3d6f --- /dev/null +++ b/api/services/app_creation_records.py @@ -0,0 +1,45 @@ +"""Database records required for a newly created App.""" + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from models import Account +from models.enums import CustomizeTokenStrategy +from models.model import App, InstalledApp, Site + + +def create_site_record(*, app: App, account: Account, session: Session) -> None: + site = Site( + app_id=app.id, + title=app.name, + icon_type=app.icon_type, + icon=app.icon, + icon_background=app.icon_background, + default_language=account.interface_language or "en-US", + customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, + code=Site.generate_code(16, session=session), + created_by=app.created_by, + updated_by=app.updated_by, + ) + session.add(site) + session.flush() + + +def create_installed_app_record(*, app: App, session: Session) -> None: + installed_app_id = session.scalar( + select(InstalledApp.id).where(InstalledApp.tenant_id == app.tenant_id, InstalledApp.app_id == app.id).limit(1) + ) + if installed_app_id: + return + + session.add( + InstalledApp( + tenant_id=app.tenant_id, + app_id=app.id, + app_owner_tenant_id=app.tenant_id, + ) + ) + session.flush() + + +__all__ = ["create_installed_app_record", "create_site_record"] diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index 4c9f10729a0..75b74b9a42e 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -47,7 +47,8 @@ from models import Account, App, AppMode from models.agent import AgentScope from models.model import AppModelConfig, AppModelConfigDict, IconType, load_annotation_reply_config from models.workflow import Workflow -from services.agent.dsl_service import AgentDslService, AgentPackage +from services.agent.dsl_entities import AgentPackage, make_agent_app_dsl +from services.agent.dsl_service import AgentDslService from services.agent.retirement_service import WorkflowAgentRetirementService from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.dsl_content import DSL_MAX_SIZE, dsl_content_size @@ -60,6 +61,7 @@ from services.entities.dsl_entities import ( ImportMode, ImportStatus, PendingImportOwner, + make_app_dsl, ) from services.errors.account import NoPermissionError from services.errors.app import WorkflowNotFoundError @@ -762,44 +764,29 @@ class AppDslService: """ app_mode = AppMode.value_of(app_model.mode) - export_data: dict[str, Any] = { - "version": CURRENT_DSL_VERSION, - "kind": "app", - "app": { - "name": app_model.name, - "mode": app_model.mode.value if isinstance(app_model.mode, AppMode) else app_model.mode, - "icon": app_model.icon, - "icon_type": ( - app_model.icon_type.value if isinstance(app_model.icon_type, IconType) else app_model.icon_type - ), - "icon_background": app_model.icon_background, - "description": app_model.description, - "use_icon_as_answer_icon": app_model.use_icon_as_answer_icon, - }, - } - - if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: - cls._append_workflow_export_data( - export_data=export_data, - app_model=app_model, - include_secret=include_secret, - workflow_id=workflow_id, - session=session, - ) - elif app_mode == AppMode.AGENT: + if app_mode == AppMode.AGENT: package_ref, packages = AgentDslService(session).export_agent_app(app=app_model) - export_data["agent"] = {"package_ref": package_ref} - export_data["agent_packages"] = {key: package.model_dump(mode="json") for key, package in packages.items()} dependencies = AgentDslService(session).extract_package_dependencies(packages) - export_data["dependencies"] = [ - jsonable_encoder(item.model_dump()) - for item in DependenciesAnalysisService.generate_dependencies( - tenant_id=app_model.tenant_id, - dependencies=dependencies, - ) - ] + export_data = make_agent_app_dsl( + app_model, + package_ref=package_ref, + packages=packages, + dependencies=DependenciesAnalysisService.generate_dependencies( + tenant_id=app_model.tenant_id, dependencies=dependencies + ), + ).model_dump(mode="json") else: - cls._append_model_config_export_data(export_data, app_model, session=session) + export_data = make_app_dsl(app_model) + if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: + cls._append_workflow_export_data( + export_data=export_data, + app_model=app_model, + include_secret=include_secret, + workflow_id=workflow_id, + session=session, + ) + else: + cls._append_model_config_export_data(export_data, app_model, session=session) return yaml.dump(export_data, allow_unicode=True) diff --git a/api/services/app_service.py b/api/services/app_service.py index e90e962cf2b..859f87c09a7 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -728,15 +728,40 @@ class AppService: # Preserve the original commit-before-signal ordering for telemetry. session.commit() - app_was_created.send(app, account=account, session=session) + self.finalize_created_app( + app=app, + backing_agent_id=backing_agent.id if backing_agent else None, + account=account, + session=session, + ) + return app + + def finalize_created_app( + self, + *, + app: App, + backing_agent_id: str | None, + account: Account, + session: Session, + created_records_initialized: bool = False, + ) -> None: + """Run post-commit App creation hooks and external access initialization.""" + + app_was_created.send( + app, + account=account, + session=session, + created_records_initialized=created_records_initialized, + ) session.commit() + app_mode = app.mode initialize_access = _CREATED_APP_ACCESS_INITIALIZERS.get(app_mode, _initialize_created_app_access) initialize_access( _CreatedApp( - tenant_id=tenant_id, + tenant_id=app.tenant_id, creator_account_id=account.id, app_id=app.id, - backing_agent_id=backing_agent.id if backing_agent else None, + backing_agent_id=backing_agent_id, ) ) @@ -747,8 +772,6 @@ class AppService: if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: BillingService.clean_billing_info_cache(app.tenant_id) - return app - def get_app(self, app: App, *, session: Session) -> App: """ Get App @@ -809,9 +832,12 @@ class AppService: def __init__(self, app): self.__dict__.update(app.__dict__) - @property @override - def app_model_config(self): + def app_model_config_with_session(self, *, session: Session) -> AppModelConfig | None: + # Hand back the in-memory config the masking pass above produced, and + # deliberately ignore `session`: re-reading the row here would undo the + # masking. Response paths resolve the config through this accessor + # (`AppResponseView.app_model_config`), so the override has to sit here. return model_config app = ModifiedApp(app) diff --git a/api/services/entities/dsl_entities.py b/api/services/entities/dsl_entities.py index 67bada2207e..3cdb0d5c42e 100644 --- a/api/services/entities/dsl_entities.py +++ b/api/services/entities/dsl_entities.py @@ -3,7 +3,9 @@ from typing import Any from pydantic import BaseModel, ConfigDict, Field +from constants.dsl_version import CURRENT_APP_DSL_VERSION from core.plugin.entities.plugin import PluginDependency +from models.model import App, AppMode, IconType class ImportMode(StrEnum): @@ -39,3 +41,20 @@ class DslImportWarning(BaseModel): class CheckDependenciesResult(BaseModel): leaked_dependencies: list[PluginDependency] = Field(default_factory=list) + + +def make_app_dsl(app: App) -> dict[str, Any]: + """Build the shared App DSL envelope for standalone and archive exports.""" + return { + "version": CURRENT_APP_DSL_VERSION, + "kind": "app", + "app": { + "name": app.name, + "mode": app.mode.value if isinstance(app.mode, AppMode) else app.mode, + "icon": app.icon, + "icon_type": app.icon_type.value if isinstance(app.icon_type, IconType) else app.icon_type, + "icon_background": app.icon_background, + "description": app.description, + "use_icon_as_answer_icon": app.use_icon_as_answer_icon, + }, + } diff --git a/api/services/skill_management_service.py b/api/services/skill_management_service.py index cb2df162927..d89be84067c 100644 --- a/api/services/skill_management_service.py +++ b/api/services/skill_management_service.py @@ -60,6 +60,7 @@ from models.account import Account from models.agent import ( Agent, AgentConfigDraft, + AgentConfigDraftType, AgentConfigSnapshot, AgentKind, AgentScope, @@ -97,9 +98,6 @@ from services.file_service import FileService logger = logging.getLogger(__name__) _SKILL_MD = "SKILL.md" -_MAX_SKILL_BYTES = 200 * 1024 * 1024 -_MAX_FILES_PER_SKILL = 5000 -_MAX_ZIP_COMPRESSION_RATIO = 1000 _MAX_FILE_CHECK_ITEMS = 100 _MAX_SKILLS_PER_WORKSPACE = 500 _MAX_AGENT_SKILLS = 20 @@ -508,6 +506,20 @@ class PublishedSkillArchive: payload: bytes +@dataclass(frozen=True, slots=True) +class RuntimeAgentSkillArchive: + skill_id: str + version_id: str + tool_file_id: str + storage_key: str + name: str + display_name: str + description: str + priority: int + size: int + hash: str + + @dataclass(frozen=True, slots=True) class SkillFileContent: filename: str @@ -2268,6 +2280,34 @@ class SkillManagementService: config_snapshot_id: str | None = None, ) -> list[dict[str, Any]]: """Return workspace Skills from the Agent draft or a published snapshot.""" + return [ + { + "id": item.skill_id, + "name": item.name, + "file_id": item.tool_file_id, + "description": item.description, + "size": item.size, + "hash": item.hash, + "mime_type": "application/zip", + } + for item in self.list_runtime_agent_skill_archives( + tenant_id=tenant_id, + agent_id=agent_id, + include_draft=include_draft, + config_snapshot_id=config_snapshot_id, + ) + ] + + def list_runtime_agent_skill_archives( + self, + *, + tenant_id: str, + agent_id: str, + include_draft: bool = False, + config_snapshot_id: str | None = None, + ) -> list[RuntimeAgentSkillArchive]: + """Return export-ready workspace Skill archives with published identity compatibility.""" + with self._session_scope() as session: binding_model = AgentSkillBinding if include_draft else AgentSkillBindingSnapshot conditions = [ @@ -2283,29 +2323,37 @@ class SkillManagementService: conditions.append(AgentSkillBindingSnapshot.config_snapshot_id == config_snapshot_id) rows = list( session.execute( - select(binding_model, Skill, SkillVersion) + select(binding_model, Skill, SkillVersion, ToolFile) .join(Skill, Skill.id == binding_model.skill_id) .join(SkillVersion, SkillVersion.id == Skill.latest_published_version_id) .join(Agent, Agent.id == binding_model.agent_id) + .join( + ToolFile, + (ToolFile.id == SkillVersion.archive_tool_file_id) & (ToolFile.tenant_id == tenant_id), + ) .where(*conditions) .order_by(Skill.name) ) ) - return [ - { - "id": skill.id, - "name": published_name, - "file_id": version.archive_tool_file_id, - "description": published_description, - "size": version.archive_size, - "hash": version.hash_code, - "mime_type": "application/zip", - } - for _binding, skill, version in rows - for published_name, _published_display_name, published_description in [ - self._published_skill_identity(skill, version) - ] + + return [ + RuntimeAgentSkillArchive( + skill_id=skill.id, + version_id=version.id, + tool_file_id=version.archive_tool_file_id, + storage_key=tool_file.file_key, + name=published_name, + display_name=published_display_name, + description=published_description, + priority=binding.priority, + size=version.archive_size, + hash=version.hash_code, + ) + for binding, skill, version, tool_file in rows + for published_name, published_display_name, published_description in [ + self._published_skill_identity(skill, version) ] + ] def pull_runtime_agent_skill( self, @@ -2638,36 +2686,35 @@ class SkillManagementService: ) ) ) - configured_names: set[str] = set() - snapshot = session.scalar( - select(AgentConfigSnapshot).where( - AgentConfigSnapshot.tenant_id == tenant_id, - AgentConfigSnapshot.agent_id == agent_id, - AgentConfigSnapshot.id - == select(Agent.active_config_snapshot_id) - .where(Agent.tenant_id == tenant_id, Agent.id == agent_id) - .scalar_subquery(), - ) - ) - if snapshot is not None: - configured_names.update( - skill.name - for skill in AgentSoulConfig.model_validate(snapshot.config_snapshot_dict).config_skills - if not skill.is_missing - ) - drafts = session.scalars( + # Bindings are edited against the normal draft, including unpublished removals. + config: AgentConfigDraft | AgentConfigSnapshot | None = session.scalar( select(AgentConfigDraft).where( AgentConfigDraft.tenant_id == tenant_id, AgentConfigDraft.agent_id == agent_id, + AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT, + AgentConfigDraft.account_id.is_(None), ) ) - for draft in drafts: - configured_names.update( - skill.name - for skill in AgentSoulConfig.model_validate(draft.config_snapshot_dict).config_skills - if not skill.is_missing + if config is None: + config = session.scalar( + select(AgentConfigSnapshot).where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id + == select(Agent.active_config_snapshot_id) + .where(Agent.tenant_id == tenant_id, Agent.id == agent_id) + .scalar_subquery(), + ) ) - + configured_names = ( + { + skill.name + for skill in AgentSoulConfig.model_validate(config.config_snapshot_dict).config_skills + if not skill.is_missing + } + if config is not None + else set() + ) conflicts = sorted(set(selected_skill_names) & (configured_names - current_bound_names)) if conflicts: raise SkillManagementServiceError( @@ -3772,7 +3819,8 @@ class SkillManagementService: return {path: path for path in paths} root = next(iter(skill_md_roots)) prefix = f"{root}/" - return {path: path.removeprefix(prefix) for path in paths if path == root or path.startswith(prefix)} + # The explicit wrapper directory represents the new root, not a draft item. + return {path: path.removeprefix(prefix) for path in paths if path.startswith(prefix)} def _draft_payload_from_zip( self, @@ -3899,7 +3947,7 @@ class SkillManagementService: def _validate_archive_limits(archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]: """Validate ZIP metadata before any member is decompressed or persisted.""" infos = archive.infolist() - if len(infos) > _MAX_FILES_PER_SKILL: + if len(infos) > dify_config.SKILL_PACKAGE_MAX_ENTRIES: raise SkillManagementServiceError("too_many_files", "skill file count limit exceeded") total_uncompressed = 0 @@ -3908,16 +3956,13 @@ class SkillManagementService: raise SkillManagementServiceError("invalid_skill_package", "skill package has invalid ZIP metadata") total_uncompressed += info.file_size - if total_uncompressed > _MAX_SKILL_BYTES: - raise SkillManagementServiceError("skill_too_large", "skill exceeds 200MB limit") + if total_uncompressed > dify_config.SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES: + raise SkillManagementServiceError("skill_too_large", "skill exceeds uncompressed size limit") if info.is_dir() or info.file_size == 0: continue - if info.compress_size == 0 or info.file_size / info.compress_size > _MAX_ZIP_COMPRESSION_RATIO: - raise SkillManagementServiceError( - "invalid_skill_package", - "skill package compression ratio exceeds the allowed limit", - ) + if info.compress_size == 0: + raise SkillManagementServiceError("invalid_skill_package", "skill package has invalid ZIP metadata") return infos def _draft_rows_from_archive_bytes( @@ -4107,7 +4152,7 @@ class SkillManagementService: ) parent = posixpath.dirname(parent) - if len(entries_by_path) > _MAX_FILES_PER_SKILL: + if len(entries_by_path) > dify_config.SKILL_PACKAGE_MAX_ENTRIES: raise SkillManagementServiceError("too_many_files", "skill file count limit exceeded") rows: list[SkillDraftFile] = [] @@ -4140,8 +4185,8 @@ class SkillManagementService: ) ) - if total_size > _MAX_SKILL_BYTES: - raise SkillManagementServiceError("skill_too_large", "skill exceeds 200MB limit") + if total_size > dify_config.SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES: + raise SkillManagementServiceError("skill_too_large", "skill exceeds uncompressed size limit") return rows def _sync_skill_metadata_from_draft_skill_md(self, *, skill: Skill, content: str) -> None: @@ -4401,8 +4446,8 @@ class SkillManagementService: @staticmethod def _enforce_total_size(files: list[SkillDraftFile]) -> None: total = sum(file.size or 0 for file in {file.path: file for file in files}.values()) - if total > _MAX_SKILL_BYTES: - raise SkillManagementServiceError("skill_too_large", "skill exceeds 200MB limit") + if total > dify_config.SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES: + raise SkillManagementServiceError("skill_too_large", "skill exceeds uncompressed size limit") @staticmethod def _load_tool_file_bytes(*, tenant_id: str, file_id: str) -> bytes: @@ -4410,14 +4455,15 @@ class SkillManagementService: tool_file = session.scalar(select(ToolFile).where(ToolFile.tenant_id == tenant_id, ToolFile.id == file_id)) if tool_file is None: raise SkillManagementServiceError("skill_archive_missing", "skill archive is missing", status_code=404) - try: - return storage.load_once(tool_file.file_key) - except (OSError, SQLAlchemyError) as exc: - raise SkillManagementServiceError( - "skill_archive_missing", - "skill archive is missing", - status_code=404, - ) from exc + file_key = tool_file.file_key + try: + return storage.load_once(file_key) + except (OSError, SQLAlchemyError) as exc: + raise SkillManagementServiceError( + "skill_archive_missing", + "skill archive is missing", + status_code=404, + ) from exc @staticmethod def _load_assistant_tool_file_bytes(*, tenant_id: str, file_id: str) -> bytes: diff --git a/api/services/tools/workflow_tools_manage_service.py b/api/services/tools/workflow_tools_manage_service.py index b094d4231d1..99dda57dae8 100644 --- a/api/services/tools/workflow_tools_manage_service.py +++ b/api/services/tools/workflow_tools_manage_service.py @@ -61,18 +61,18 @@ class WorkflowToolManageService: if existing_workflow_tool_provider is not None: raise ValueError(f"Tool with name {name} or app_id {workflow_app_id} already exists") - # query the app + # query the app and its published workflow in the same session app: App | None = None + workflow: Workflow | None = None with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: app = _session.scalar(select(App).where(App.id == workflow_app_id, App.tenant_id == tenant_id).limit(1)) + if app is not None: + workflow = app.workflow_with_session(session=_session) # if not found raise error if app is None: raise ValueError(f"App {workflow_app_id} not found") - # query the workflow - workflow: Workflow | None = app.workflow - # if not found raise error if workflow is None: raise ValueError(f"Workflow not found for app {workflow_app_id}") @@ -172,20 +172,20 @@ class WorkflowToolManageService: if workflow_tool_provider is None: raise ValueError(f"Tool {workflow_tool_id} not found") - # query the app + # query the app and its published workflow in the same session app: App | None = None + workflow: Workflow | None = None with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: app = _session.scalar( select(App).where(App.id == workflow_tool_provider.app_id, App.tenant_id == tenant_id).limit(1) ) + if app is not None: + workflow = app.workflow_with_session(session=_session) # if not found raise error if app is None: raise ValueError(f"App {workflow_tool_provider.app_id} not found") - # query the workflow - workflow: Workflow | None = app.workflow - # if not found raise error if workflow is None: raise ValueError(f"Workflow not found for app {workflow_tool_provider.app_id}") @@ -342,15 +342,17 @@ class WorkflowToolManageService: raise ValueError("Tool not found") workflow_app: App | None = None + workflow: Workflow | None = None with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: workflow_app = _session.scalar( select(App).where(App.id == db_tool.app_id, App.tenant_id == db_tool.tenant_id).limit(1) ) + if workflow_app is not None: + workflow = workflow_app.workflow_with_session(session=_session) if workflow_app is None: raise ValueError(f"App {db_tool.app_id} not found") - workflow = workflow_app.workflow if not workflow: raise ValueError("Workflow not found") diff --git a/api/tests/test_containers_integration_tests/models/test_conversation_message_inputs.py b/api/tests/test_containers_integration_tests/models/test_conversation_message_inputs.py index f10f519e250..45cb28cd169 100644 --- a/api/tests/test_containers_integration_tests/models/test_conversation_message_inputs.py +++ b/api/tests/test_containers_integration_tests/models/test_conversation_message_inputs.py @@ -80,7 +80,7 @@ class TestConversationMessageInputsTenantResolution: owner = owner_cls(app_id=app.id) owner.inputs = {"file": _build_local_file_mapping("upload-1")} - restored_inputs = owner.inputs + restored_inputs = owner.inputs_with_session(session=db_session_with_containers) # The tenant_id should come from the real App row in the DB assert restored_inputs["file"] == {"tenant_id": app.tenant_id, "upload_file_id": "upload-1"} @@ -108,7 +108,7 @@ class TestConversationMessageInputsTenantResolution: owner = owner_cls(app_id=app.id) owner.inputs = {"file": _build_local_file_mapping("upload-1", tenant_id=payload_tenant_id)} - restored_inputs = owner.inputs + restored_inputs = owner.inputs_with_session(session=db_session_with_containers) assert restored_inputs["file"] == {"tenant_id": payload_tenant_id, "upload_file_id": "upload-1"} assert len(build_calls) == 1 @@ -139,7 +139,7 @@ class TestConversationMessageInputsTenantResolution: ] } - restored_inputs = owner.inputs + restored_inputs = owner.inputs_with_session(session=db_session_with_containers) assert len(build_calls) == 2 assert all(call[1] == app.tenant_id for call in build_calls) diff --git a/api/tests/test_containers_integration_tests/models/test_dataset_models.py b/api/tests/test_containers_integration_tests/models/test_dataset_models.py index 0f25a543c9c..73097625552 100644 --- a/api/tests/test_containers_integration_tests/models/test_dataset_models.py +++ b/api/tests/test_containers_integration_tests/models/test_dataset_models.py @@ -228,7 +228,7 @@ class TestDatasetDocumentProperties: db_session_with_containers.add(seg) db_session_with_containers.flush() - assert doc.segment_count == 3 + assert doc.get_segment_count(session=db_session_with_containers) == 3 def test_document_hit_count_aggregation(self, db_session_with_containers: Session) -> None: """Test document can aggregate hit count from segments.""" @@ -269,7 +269,7 @@ class TestDatasetDocumentProperties: db_session_with_containers.add(seg) db_session_with_containers.flush() - assert doc.hit_count == 25 + assert doc.get_hit_count(session=db_session_with_containers) == 25 class TestDocumentSegmentNavigationProperties: diff --git a/api/tests/test_containers_integration_tests/services/test_agent_service.py b/api/tests/test_containers_integration_tests/services/test_agent_service.py index d6e0ff103c1..4217e573b5a 100644 --- a/api/tests/test_containers_integration_tests/services/test_agent_service.py +++ b/api/tests/test_containers_integration_tests/services/test_agent_service.py @@ -133,8 +133,9 @@ class TestAgentService: app = app_service.create_app(tenant.id, app_args, account, session=db_session_with_containers) # Update the app model config to set agent_mode for agent-chat mode - if app.mode == AppMode.AGENT_CHAT and app.app_model_config: - app.app_model_config.agent_mode = json.dumps({"enabled": True, "strategy": "react", "tools": []}) + app_model_config = app.app_model_config_with_session(session=db_session_with_containers) + if app.mode == AppMode.AGENT_CHAT and app_model_config: + app_model_config.agent_mode = json.dumps({"enabled": True, "strategy": "react", "tools": []}) db_session_with_containers.commit() diff --git a/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py b/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py index c2c5fe838fb..f05a0fb28da 100644 --- a/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py +++ b/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py @@ -537,6 +537,9 @@ class TestWorkflowToolManageService: ] } ) + # The service reads the published workflow in its own session, so the graph has + # to be committed — matching test_update_workflow_tool_human_input_node_error. + db_session_with_containers.commit() tool_parameters = self._create_test_workflow_tool_parameters() with pytest.raises(WorkflowToolHumanInputNotSupportedError) as exc_info: @@ -877,6 +880,9 @@ class TestWorkflowToolManageService: ] } workflow.graph = json.dumps(workflow_graph) + # The service reads the published workflow in its own session, so the graph has + # to be committed for this setup to reach it. + db_session_with_containers.commit() # Setup workflow tool parameters with FILE type file_parameters = [ @@ -952,6 +958,9 @@ class TestWorkflowToolManageService: ] } workflow.graph = json.dumps(workflow_graph) + # The service reads the published workflow in its own session, so the graph has + # to be committed for this setup to reach it. + db_session_with_containers.commit() # Setup workflow tool parameters with FILES type files_parameters = [ diff --git a/api/tests/test_containers_integration_tests/services/workflow/test_workflow_converter.py b/api/tests/test_containers_integration_tests/services/workflow/test_workflow_converter.py index 8cd9526f6a0..fa102683cdf 100644 --- a/api/tests/test_containers_integration_tests/services/workflow/test_workflow_converter.py +++ b/api/tests/test_containers_integration_tests/services/workflow/test_workflow_converter.py @@ -325,7 +325,7 @@ class TestWorkflowConverter: workflow_converter = WorkflowConverter() workflow = workflow_converter.convert_app_model_config_to_workflow( app_model=app, - app_model_config=app.app_model_config, + app_model_config=app.app_model_config_with_session(session=db_session_with_containers), account_id=account.id, session=db_session_with_containers, ) diff --git a/api/tests/unit_tests/commands/test_generate_swagger_specs.py b/api/tests/unit_tests/commands/test_generate_swagger_specs.py index 06bc6cae4f1..caa2f7134b9 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_specs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_specs.py @@ -774,6 +774,24 @@ def test_generate_specs_include_console_contract_shapes_for_schema_migration(tmp assert file_upload_schema["properties"]["file"]["type"] == "string" assert file_upload_schema["properties"]["source"]["enum"] == ["datasets"] + package_import = paths["/apps/imports"]["post"] + assert _request_schema(package_import, "multipart/form-data")["required"] == ["file"] + assert _request_schema(package_import, "application/json")["$ref"] == "#/components/schemas/AppImportPayload" + assert "mode" in schemas["AppImportPayload"]["required"] + conflict = package_import["responses"]["409"]["content"]["application/json"]["schema"] + assert conflict["$ref"] == "#/components/schemas/RosterAgentPackageConflictResponse" + assert "leaked_dependencies" in schemas["RosterAgentPackageConflictResponse"]["properties"] + assert "403" in package_import["responses"] + export = paths["/apps/{app_id}/export"]["get"] + assert export["responses"]["200"]["content"]["application/zip"]["schema"] == {"type": "string", "format": "binary"} + assert export["responses"]["200"]["content"]["application/json"]["schema"]["$ref"] == ( + "#/components/schemas/AppExportResponse" + ) + export_format = next(param for param in export["parameters"] if param["name"] == "format") + assert set(export_format["schema"]["enum"]) == {"yaml", "ifpkg"} + assert export_format["schema"].get("default") is None + assert "defaults to ifpkg for Agent Apps and yaml for other Apps" in export_format["description"] + api_key_auth_binding_schema = _request_schema(paths["/api-key-auth/data-source/binding"]["post"]) assert api_key_auth_binding_schema["$ref"] == "#/components/schemas/ApiKeyAuthBindingPayload" assert schemas["ApiKeyAuthBindingPayload"]["properties"]["credentials"]["$ref"] == ( diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py index 8e9547ad68f..dd9f3313534 100644 --- a/api/tests/unit_tests/configs/test_dify_config.py +++ b/api/tests/unit_tests/configs/test_dify_config.py @@ -64,7 +64,12 @@ class _IsolatedDifyConfig(DifyConfig): def _make_config(**values: object) -> DifyConfig: - return _IsolatedDifyConfig(**values) + # Pydantic exposes a generated field-wise signature, while this helper intentionally forwards heterogeneous input. + return _IsolatedDifyConfig(**values) # pyrefly: ignore[bad-argument-type] + + +def test_skill_package_default_limit_matches_documentation() -> None: + assert _make_config().UPLOAD_SKILL_FILE_SIZE_LIMIT == 50 def test_dify_config_keeps_secret_key_empty_when_missing(tmp_path) -> None: diff --git a/api/tests/unit_tests/conftest.py b/api/tests/unit_tests/conftest.py index 9ca5f9774e6..7b4dff1cf76 100644 --- a/api/tests/unit_tests/conftest.py +++ b/api/tests/unit_tests/conftest.py @@ -70,7 +70,7 @@ def _provide_app_context(app: Flask) -> Iterator[None]: @pytest.fixture(autouse=True) def _patch_redis_clients() -> Iterator[None]: - """Patch redis_client to MagicMock only for unit test executions.""" + """Patch and rebind loaded Redis clients to the shared mock for each unit test.""" with ( patch.object(ext_redis, "redis_client", redis_mock), @@ -81,8 +81,8 @@ def _patch_redis_clients() -> Iterator[None]: @pytest.fixture(autouse=True) -def reset_redis_mock() -> None: - """reset the Redis mock before each test""" +def reset_redis_mock(_patch_redis_clients: None) -> None: + """Reset the shared Redis mock after per-test client rebinding.""" redis_mock.reset_mock() redis_mock.get.return_value = None redis_mock.setex.return_value = None @@ -95,9 +95,6 @@ def reset_redis_mock() -> None: redis_mock.hdel.return_value = None redis_mock.incr.return_value = 1 - # Keep any imported modules pointing at the mock between tests - _patch_redis_clients_on_loaded_modules() - @pytest.fixture(autouse=True) def reset_secret_key(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py index 96739b27060..c09d24cffcc 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py @@ -146,7 +146,7 @@ class TestAppImportApi: sqlite_app_engine: Engine, transaction_events: TransactionEvents, ) -> None: - method = unwrap(api.post) + method = unwrap(api._import_dsl) _install_features(monkeypatch, enabled=False) app_id = _install_persisting_service_result( @@ -172,7 +172,7 @@ class TestAppImportApi: sqlite_app_engine: Engine, transaction_events: TransactionEvents, ) -> None: - method = unwrap(api.post) + method = unwrap(api._import_dsl) _install_features(monkeypatch, enabled=False) app_id = _install_persisting_service_result( @@ -198,7 +198,7 @@ class TestAppImportApi: sqlite_app_engine: Engine, transaction_events: TransactionEvents, ) -> None: - method = unwrap(api.post) + method = unwrap(api._import_dsl) _install_features(monkeypatch, enabled=True) app_id = _install_persisting_service_result( @@ -227,7 +227,7 @@ class TestAppImportApi: sqlite_app_engine: Engine, transaction_events: TransactionEvents, ) -> None: - method = _unwrap(api.post) + method = _unwrap(api._import_dsl) _install_features(monkeypatch, enabled=False) monkeypatch.setattr( @@ -263,7 +263,7 @@ class TestAppImportApi: sqlite_app_engine: Engine, transaction_events: TransactionEvents, ) -> None: - method = _unwrap(api.post) + method = _unwrap(api._import_dsl) _install_features(monkeypatch, enabled=False) monkeypatch.setattr( diff --git a/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py b/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py index 5db0cbe2a4c..0f53d0de7d8 100644 --- a/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py +++ b/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from flask import Flask from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, scoped_session, sessionmaker from werkzeug.exceptions import NotFound from controllers.console import console_ns @@ -156,7 +156,7 @@ class TestAppMCPServerController: assert response == {} - def test_post_returns_201(self, sqlite_session: Session) -> None: + def test_post_returns_201(self, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]) -> None: api = AppMCPServerController() method = unwrap(api.post) payload = {"parameters": {"timeout": 30}} @@ -166,7 +166,7 @@ class TestAppMCPServerController: with ( app.test_request_context("/", json=payload), - patch("controllers.console.app.mcp_server.db.session", sqlite_session), + patch("controllers.console.app.mcp_server.db.session", scoped_session(sqlite_session_factory)), patch("controllers.console.app.mcp_server.AppMCPServer.generate_server_code", return_value="server-code"), ): response, status_code = method( @@ -257,7 +257,9 @@ class TestAppMCPServerController: class TestAppMCPServerRefreshController: - def test_post_refreshes_server_bound_to_app_and_tenant(self, sqlite_session: Session) -> None: + def test_post_refreshes_server_bound_to_app_and_tenant( + self, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session] + ) -> None: api = AppMCPServerRefreshController() method = unwrap(api.post) server = _server(server_code="old-code") @@ -270,7 +272,7 @@ class TestAppMCPServerRefreshController: sqlite_session.commit() with ( - patch("controllers.console.app.mcp_server.db.session", sqlite_session), + patch("controllers.console.app.mcp_server.db.session", scoped_session(sqlite_session_factory)), patch("controllers.console.app.mcp_server.AppMCPServer.generate_server_code", return_value="new-code"), ): response = method(api, "tenant-1", app_model=_app()) diff --git a/api/tests/unit_tests/controllers/console/app/test_model_config_api.py b/api/tests/unit_tests/controllers/console/app/test_model_config_api.py index 43d4925e165..d9d99221dff 100644 --- a/api/tests/unit_tests/controllers/console/app/test_model_config_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_model_config_api.py @@ -4,7 +4,6 @@ import importlib import json import os from inspect import unwrap -from typing import Never from unittest.mock import MagicMock from uuid import uuid4 @@ -21,13 +20,13 @@ from models.model import App, AppMode, AppModelConfig app_wraps_module = importlib.import_module("controllers.console.app.wraps") -def _poison_implicit_app_config_properties(monkeypatch: pytest.MonkeyPatch) -> None: - def fail(_app: App) -> Never: - raise AssertionError("implicit App model-config property was accessed") - - # `App.is_agent` used to be poisoned here too. It no longer exists as an - # implicit property, so its absence enforces the same thing outright. - monkeypatch.setattr(App, "app_model_config", property(fail)) +def _assert_no_implicit_app_config_properties() -> None: + # `App.is_agent` and `App.app_model_config` used to be poisoned here with a + # raising property. Neither exists as an implicit accessor any more, so their + # absence enforces the same thing outright — assert it instead of + # monkeypatching an attribute that is gone. + assert not hasattr(App, "is_agent") + assert not hasattr(App, "app_model_config") @pytest.mark.parametrize("app_mode", [AppMode.CHAT, AppMode.COMPLETION]) @@ -54,7 +53,7 @@ def test_post_updates_non_agent_model_config_without_implicit_properties( original_config.agent_mode = None sqlite_session.add(original_config) sqlite_session.commit() - _poison_implicit_app_config_properties(monkeypatch) + _assert_no_implicit_app_config_properties() monkeypatch.setattr( model_config_module.AppModelConfigService, "validate_configuration", @@ -184,7 +183,7 @@ def test_post_encrypts_agent_tool_parameters( updated_by=None, updated_at=None, ) - _poison_implicit_app_config_properties(monkeypatch) + _assert_no_implicit_app_config_properties() original_config = AppModelConfig(app_id="app-1", created_by="u1", updated_by="u1") original_config.id = "config-0" diff --git a/api/tests/unit_tests/controllers/console/app/test_roster_package_routes.py b/api/tests/unit_tests/controllers/console/app/test_roster_package_routes.py new file mode 100644 index 00000000000..bb8b9c8ab06 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_roster_package_routes.py @@ -0,0 +1,157 @@ +import io +from collections.abc import Callable +from inspect import unwrap +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from flask import Flask +from werkzeug.exceptions import BadRequest, Forbidden + +from controllers.console.app import app as app_module +from controllers.console.app import app_import as import_module +from core.rbac import RBACPermission +from models.account import Account, Tenant +from models.model import App, AppMode +from services.agent.errors import InvalidRosterAgentPackageError +from services.agent.roster_package_importer import RosterAgentPackageImportResult +from services.entities.dsl_entities import DslImportWarning + + +def _account() -> Account: + account = Account(name="Importer", email="importer@example.com") + account._current_tenant = Tenant(name="Workspace") + account._current_tenant.id = "tenant-1" + return account + + +def test_existing_import_route_dispatches_json_without_changing_payload( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + api = import_module.AppImportApi() + import_dsl = Mock(return_value=({"status": "completed"}, 200)) + monkeypatch.setattr(api, "_import_dsl", import_dsl) + account = _account() + with app.test_request_context( + "/console/api/apps/imports", method="POST", json={"mode": "yaml-content", "yaml_content": "app: {}"} + ): + result = unwrap(api.post)(api, account) + assert result == ({"status": "completed"}, 200) + assert import_dsl.call_args.args == ( + import_module.AppImportPayload(mode="yaml-content", yaml_content="app: {}"), + account, + ) + + +@pytest.mark.parametrize("has_warning", [False, True]) +def test_existing_import_route_accepts_package_and_preserves_import_response( + app: Flask, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None], has_warning: bool +) -> None: + config_overrides(RBAC_ENABLED=False) + account = _account() + warnings = ( + [DslImportWarning(code="agent_skill_missing", path="skills.s_000001", message="Missing Skill")] + if has_warning + else [] + ) + import_package = Mock( + return_value=RosterAgentPackageImportResult(app_id="app-1", agent_id="agent-1", warnings=warnings) + ) + monkeypatch.setattr( + import_module, "RosterAgentPackageImporter", lambda: SimpleNamespace(import_package=import_package) + ) + api = import_module.AppImportApi() + with app.test_request_context( + "/console/api/apps/imports", method="POST", data={"file": (io.BytesIO(b"package"), "agent.ifpkg")} + ): + data, status = unwrap(api.post)(api, account) + assert import_package.call_args.kwargs["source"].read() == b"package" + assert status == 200 + assert data["app_id"] == "app-1" + assert data["app_mode"] == "agent" + assert data["status"] == ("completed-with-warnings" if has_warning else "completed") + assert data["warnings"] == [warning.model_dump(mode="json") for warning in warnings] + assert data["id"] + assert import_package.call_args.kwargs["tenant_id"] == "tenant-1" + + +@pytest.mark.parametrize("denied", [RBACPermission.AGENT_CREATE, RBACPermission.AGENT_IMPORT_EXPORT_DSL]) +def test_package_import_checks_agent_permissions_before_reading_payload( + app: Flask, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None], denied: RBACPermission +) -> None: + config_overrides(RBAC_ENABLED=True) + account = _account() + monkeypatch.setattr("controllers.common.wraps.current_account_with_tenant", lambda: (account, "tenant-1")) + scenes = [] + + def check(*_args, **kwargs: object) -> bool: + scenes.append(kwargs["scene"]) + return kwargs["scene"] != denied + + monkeypatch.setattr("controllers.common.rbac.checks.RBACService.CheckAccess.check", check) + importer = Mock() + monkeypatch.setattr(import_module, "RosterAgentPackageImporter", importer) + api = import_module.AppImportApi() + with app.test_request_context( + "/console/api/apps/imports", method="POST", data={"file": (io.BytesIO(b"package"), "agent.ifpkg")} + ): + with pytest.raises(Forbidden): + unwrap(api.post)(api, account) + assert denied in scenes + assert RBACPermission.APP_IMPORT_EXPORT_DSL not in scenes + importer.assert_not_called() + + +def test_package_import_rejects_overwrite(app: Flask, config_overrides: Callable[..., None]) -> None: + config_overrides(RBAC_ENABLED=False) + api = import_module.AppImportApi() + with app.test_request_context( + "/console/api/apps/imports", + method="POST", + data={"app_id": "existing", "file": (io.BytesIO(b"package"), "agent.ifpkg")}, + ): + with pytest.raises(InvalidRosterAgentPackageError, match="overwriting"): + unwrap(api.post)(api, _account()) + + +@pytest.mark.parametrize("query", [{}, {"format": "ifpkg"}]) +def test_existing_export_route_returns_ifpkg_for_agent( + app: Flask, monkeypatch: pytest.MonkeyPatch, query: dict[str, str] +) -> None: + model = App(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT) + monkeypatch.setattr(App, "bound_agent_id_with_session", lambda _self, **_kwargs: "agent-1") + monkeypatch.setattr(app_module, "db", SimpleNamespace(session=lambda: object())) + archive = io.BytesIO(b"package") + close = Mock() + export = Mock(return_value=SimpleNamespace(archive=archive, filename="agent.ifpkg", close=close)) + monkeypatch.setattr(app_module, "RosterAgentPackageExporter", lambda: SimpleNamespace(export=export)) + with app.test_request_context("/console/api/apps/app-1/export", query_string=query): + response = unwrap(app_module.AppExportApi.get)( + app_module.AppExportApi(), app_module.AppExportQuery.model_validate(query), model + ) + response.direct_passthrough = False + assert response.get_data() == b"package" + assert response.mimetype == "application/zip" + assert "agent.ifpkg" in response.headers["Content-Disposition"] + response.close() + export.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + close.assert_called_once_with() + + +def test_ifpkg_export_rejects_non_agent_apps() -> None: + model = App(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW) + with pytest.raises(BadRequest): + unwrap(app_module.AppExportApi.get)(app_module.AppExportApi(), app_module.AppExportQuery(format="ifpkg"), model) + + +@pytest.mark.parametrize( + ("mode", "query"), + [(AppMode.AGENT, {"format": "yaml"}), (AppMode.WORKFLOW, {}), (AppMode.WORKFLOW, {"format": "yaml"})], +) +def test_yaml_export_remains_available(monkeypatch: pytest.MonkeyPatch, mode: AppMode, query: dict[str, str]) -> None: + model = App(id="app-1", tenant_id="tenant-1", mode=mode) + monkeypatch.setattr(app_module, "db", SimpleNamespace(session=lambda: object())) + monkeypatch.setattr(app_module.AppDslService, "export_dsl", lambda **_kwargs: "app: {}") + assert unwrap(app_module.AppExportApi.get)( + app_module.AppExportApi(), app_module.AppExportQuery.model_validate(query), model + ) == {"data": "app: {}"} diff --git a/api/tests/unit_tests/controllers/console/auth/test_account_activation.py b/api/tests/unit_tests/controllers/console/auth/test_account_activation.py index 6f5ba50e56a..f3ca84a309f 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_account_activation.py +++ b/api/tests/unit_tests/controllers/console/auth/test_account_activation.py @@ -6,6 +6,7 @@ from unittest.mock import Mock, patch import pytest from flask import Flask +from werkzeug.exceptions import UnprocessableEntity from controllers.console.auth.activate import ActivateApi, ActivateCheckApi from controllers.console.auth.error import InvitationAccountMismatchError as InvitationAccountMismatchHTTPError @@ -74,7 +75,7 @@ class TestActivateCheckApi: return_value=_services(activation_service), ), ): - response = unwrap(ActivateCheckApi.get)(ActivateCheckApi()) + response = ActivateCheckApi().get() assert response == { "is_valid": True, @@ -104,10 +105,24 @@ class TestActivateCheckApi: return_value=_services(activation_service), ), ): - response = unwrap(ActivateCheckApi.get)(ActivateCheckApi()) + response = ActivateCheckApi().get() assert response == {"is_valid": False} + def test_rejects_request_without_token(self, app: Flask, activation_service: Mock) -> None: + """`token` is required, so a tokenless query must not reach the application service.""" + with ( + app.test_request_context("/activate/check?workspace_id=workspace-123"), + patch( + "controllers.console.auth.activate.application_services", + return_value=_services(activation_service), + ), + pytest.raises(UnprocessableEntity), + ): + ActivateCheckApi().get() + + activation_service.check.assert_not_called() + class TestActivateApi: def test_passes_parsed_command_to_application_service( diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py index 3d7d8900c94..fb41206f65c 100644 --- a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py +++ b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py @@ -187,9 +187,14 @@ def test_variable_collection_get_raises_when_draft_workflow_missing( api = module.SnippetWorkflowVariableCollectionApi() handler = unwrap(api.get) - with app.test_request_context("/?page=1&limit=20"): + with app.test_request_context("/"): with pytest.raises(module.DraftWorkflowNotExist): - handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1")) + handler( + api, + module.WorkflowDraftVariableListQuery(page=1, limit=20), + _make_account(), + snippet=SimpleNamespace(id="snippet-1"), + ) def test_node_variable_collection_get_lists_persisted_node_variables( diff --git a/api/tests/unit_tests/controllers/files/test_upload_file_delivery.py b/api/tests/unit_tests/controllers/files/test_upload_file_delivery.py index b390c526ac4..fcb05f3f564 100644 --- a/api/tests/unit_tests/controllers/files/test_upload_file_delivery.py +++ b/api/tests/unit_tests/controllers/files/test_upload_file_delivery.py @@ -1,9 +1,9 @@ -import types from inspect import unwrap from unittest.mock import patch import pytest -from werkzeug.exceptions import NotFound +from flask import Flask +from werkzeug.exceptions import NotFound, UnprocessableEntity import controllers.files.upload_file_delivery as module from services.errors.file import UnsupportedFileTypeError as UnsupportedFileTypeServiceError @@ -34,18 +34,17 @@ def _delivery( ) -def _fake_request(args: dict[str, object]): - return types.SimpleNamespace(args=types.SimpleNamespace(to_dict=lambda **_kwargs: args)) +_SIGNED_QUERY = "/?timestamp=123&nonce=abc&sign=sig" class TestImagePreviewApi: @patch.object(module, "application_services") - def test_success(self, mock_application_services): - module.request = _fake_request({"timestamp": "123", "nonce": "abc", "sign": "sig"}) + def test_success(self, mock_application_services, app: Flask): service = mock_application_services.return_value.upload_file_delivery service.get_signed_image_preview.return_value = _delivery(mime_type="image/png", extension="png") - response = unwrap(module.ImagePreviewApi().get)("file-id") + with app.test_request_context(_SIGNED_QUERY): + response = module.ImagePreviewApi().get("file-id") assert response.mimetype == "image/png" service.get_signed_image_preview.assert_called_once_with( @@ -56,31 +55,39 @@ class TestImagePreviewApi: ) @patch.object(module, "application_services") - def test_not_found(self, mock_application_services): - module.request = _fake_request({"timestamp": "123", "nonce": "abc", "sign": "sig"}) + def test_not_found(self, mock_application_services, app: Flask): service = mock_application_services.return_value.upload_file_delivery service.get_signed_image_preview.side_effect = UploadFileDeliveryNotFoundError( "File not found or signature is invalid" ) - with pytest.raises(NotFound, match="File not found or signature is invalid"): - unwrap(module.ImagePreviewApi().get)("file-id") + with ( + app.test_request_context(_SIGNED_QUERY), + pytest.raises(NotFound, match="File not found or signature is invalid"), + ): + module.ImagePreviewApi().get("file-id") @patch.object(module, "application_services") - def test_unsupported_file_type(self, mock_application_services): - module.request = _fake_request({"timestamp": "123", "nonce": "abc", "sign": "sig"}) + def test_unsupported_file_type(self, mock_application_services, app: Flask): service = mock_application_services.return_value.upload_file_delivery service.get_signed_image_preview.side_effect = UnsupportedFileTypeServiceError() - with pytest.raises(module.UnsupportedFileTypeError): - unwrap(module.ImagePreviewApi().get)("file-id") + with app.test_request_context(_SIGNED_QUERY), pytest.raises(module.UnsupportedFileTypeError): + module.ImagePreviewApi().get("file-id") + + @patch.object(module, "application_services") + def test_missing_signature_params_are_rejected(self, mock_application_services, app: Flask): + """An incomplete signature must fail validation before the service is reached.""" + with app.test_request_context("/?timestamp=123"), pytest.raises(UnprocessableEntity): + module.ImagePreviewApi().get("file-id") + + mock_application_services.return_value.upload_file_delivery.get_signed_image_preview.assert_not_called() class TestFilePreviewApi: @patch.object(module, "enforce_download_for_html") @patch.object(module, "application_services") - def test_inline_preview_uses_file_metadata(self, mock_application_services, mock_enforce): - module.request = _fake_request({"timestamp": "123", "nonce": "abc", "sign": "sig", "as_attachment": False}) + def test_inline_preview_uses_file_metadata(self, mock_application_services, mock_enforce, app: Flask): service = mock_application_services.return_value.upload_file_delivery service.get_signed_file_preview.return_value = _delivery( mime_type="application/pdf", @@ -89,7 +96,8 @@ class TestFilePreviewApi: extension="pdf", ) - response = unwrap(module.FilePreviewApi().get)("file-id") + with app.test_request_context(_SIGNED_QUERY): + response = module.FilePreviewApi().get("file-id") assert response.mimetype == "application/pdf" assert response.headers["Content-Type"] == "application/pdf" @@ -103,25 +111,25 @@ class TestFilePreviewApi: ) @patch.object(module, "application_services") - def test_audio_preview_supports_ranges(self, mock_application_services): - module.request = _fake_request({"timestamp": "123", "nonce": "abc", "sign": "sig", "as_attachment": False}) + def test_audio_preview_supports_ranges(self, mock_application_services, app: Flask): mock_application_services.return_value.upload_file_delivery.get_signed_file_preview.return_value = _delivery( mime_type="audio/mpeg", extension="mp3", ) - response = unwrap(module.FilePreviewApi().get)("file-id") + with app.test_request_context(_SIGNED_QUERY): + response = module.FilePreviewApi().get("file-id") assert response.headers["Accept-Ranges"] == "bytes" @patch.object(module, "application_services") - def test_zero_size_omits_content_length(self, mock_application_services): - module.request = _fake_request({"timestamp": "123", "nonce": "abc", "sign": "sig", "as_attachment": False}) + def test_zero_size_omits_content_length(self, mock_application_services, app: Flask): mock_application_services.return_value.upload_file_delivery.get_signed_file_preview.return_value = _delivery( size=0 ) - response = unwrap(module.FilePreviewApi().get)("file-id") + with app.test_request_context(_SIGNED_QUERY): + response = module.FilePreviewApi().get("file-id") assert "Content-Length" not in response.headers @@ -135,8 +143,7 @@ class TestFilePreviewApi: ids=("mime-type", "filename", "extension"), ) @patch.object(module, "application_services") - def test_svg_preview_forces_download(self, mock_application_services, mime_type, name, extension): - module.request = _fake_request({"timestamp": "123", "nonce": "abc", "sign": "sig", "as_attachment": False}) + def test_svg_preview_forces_download(self, mock_application_services, mime_type, name, extension, app: Flask): mock_application_services.return_value.upload_file_delivery.get_signed_file_preview.return_value = _delivery( mime_type=mime_type, size=11, @@ -144,15 +151,15 @@ class TestFilePreviewApi: extension=extension, ) - response = unwrap(module.FilePreviewApi().get)("file-id") + with app.test_request_context(_SIGNED_QUERY): + response = module.FilePreviewApi().get("file-id") assert response.headers["Content-Disposition"].startswith("attachment") assert response.headers["Content-Type"] == "application/octet-stream" assert response.headers["X-Content-Type-Options"] == "nosniff" @patch.object(module, "application_services") - def test_html_preview_still_forces_download(self, mock_application_services): - module.request = _fake_request({"timestamp": "123", "nonce": "abc", "sign": "sig", "as_attachment": False}) + def test_html_preview_still_forces_download(self, mock_application_services, app: Flask): mock_application_services.return_value.upload_file_delivery.get_signed_file_preview.return_value = _delivery( mime_type="text/html", size=25, @@ -160,35 +167,46 @@ class TestFilePreviewApi: extension="html", ) - response = unwrap(module.FilePreviewApi().get)("file-id") + with app.test_request_context(_SIGNED_QUERY): + response = module.FilePreviewApi().get("file-id") assert response.headers["Content-Disposition"].startswith("attachment") assert response.headers["Content-Type"] == "application/octet-stream" assert response.headers["X-Content-Type-Options"] == "nosniff" @patch.object(module, "application_services") - def test_as_attachment_encodes_filename(self, mock_application_services): - module.request = _fake_request({"timestamp": "123", "nonce": "abc", "sign": "sig", "as_attachment": True}) + def test_as_attachment_encodes_filename(self, mock_application_services, app: Flask): mock_application_services.return_value.upload_file_delivery.get_signed_file_preview.return_value = _delivery( mime_type="application/pdf", name="报告.pdf", extension="pdf", ) - response = unwrap(module.FilePreviewApi().get)("file-id") + with app.test_request_context(f"{_SIGNED_QUERY}&as_attachment=true"): + response = module.FilePreviewApi().get("file-id") assert response.headers["Content-Disposition"] == "attachment; filename*=UTF-8''%E6%8A%A5%E5%91%8A.pdf" assert response.headers["Content-Type"] == "application/octet-stream" @patch.object(module, "application_services") - def test_not_found(self, mock_application_services): - module.request = _fake_request({"timestamp": "123", "nonce": "abc", "sign": "sig", "as_attachment": False}) + def test_not_found(self, mock_application_services, app: Flask): mock_application_services.return_value.upload_file_delivery.get_signed_file_preview.side_effect = ( UploadFileDeliveryNotFoundError("File not found or signature is invalid") ) - with pytest.raises(NotFound, match="File not found or signature is invalid"): - unwrap(module.FilePreviewApi().get)("file-id") + with ( + app.test_request_context(_SIGNED_QUERY), + pytest.raises(NotFound, match="File not found or signature is invalid"), + ): + module.FilePreviewApi().get("file-id") + + @patch.object(module, "application_services") + def test_missing_signature_params_are_rejected(self, mock_application_services, app: Flask): + """An incomplete signature must fail validation before the service is reached.""" + with app.test_request_context("/?nonce=abc"), pytest.raises(UnprocessableEntity): + module.FilePreviewApi().get("file-id") + + mock_application_services.return_value.upload_file_delivery.get_signed_file_preview.assert_not_called() class TestWorkspaceWebappLogoApi: diff --git a/api/tests/unit_tests/core/rag/extractor/test_extract_processor.py b/api/tests/unit_tests/core/rag/extractor/test_extract_processor.py index 6b81f24fcb8..2f00fda06db 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_extract_processor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_extract_processor.py @@ -105,6 +105,16 @@ class TestExtractProcessorLoaders: [ ("https://example.com/file.txt", {"Content-Type": "text/plain"}, ".txt"), ("https://example.com/no_suffix", {"Content-Type": "application/pdf"}, ".pdf"), + ( + "https://example.com/no_suffix", + {"Content-Type": "application/pdf; charset=binary"}, + ".pdf", + ), + ( + "https://example.com/no_suffix", + {"Content-Type": "text/html; charset=utf-8"}, + ".html", + ), ( "https://example.com/no_suffix", {"Content-Disposition": 'attachment; filename="report.md"'}, @@ -115,6 +125,7 @@ class TestExtractProcessorLoaders: {"Content-Disposition": 'attachment; filename="report"'}, "", ), + ("https://example.com/no_suffix", {}, ""), ], ) def test_load_from_url_builds_temp_file_with_correct_suffix( @@ -167,6 +178,21 @@ class TestExtractProcessorLoaders: assert args[1] is None assert args[2] is None + def test_load_from_url_routes_parameterized_content_type(self, monkeypatch: pytest.MonkeyPatch): + # Servers commonly append parameters such as "; charset=binary" to + # Content-Type; the media type must be parsed before deriving the suffix. + response = SimpleNamespace( + headers={"Content-Type": "application/pdf; charset=binary"}, content=b"%PDF-1.1 body" + ) + monkeypatch.setattr(processor_module.remote_fetcher, "make_request", lambda *args, **kwargs: response) + factory = _patch_all_extractors(monkeypatch) + apply_config_overrides(monkeypatch, ETL_TYPE="dify") + + text = ExtractProcessor.load_from_url("https://example.com/report", return_text=True) + + assert text == "extracted-by-PdfExtractor" + assert factory.calls[-1][0] == "PdfExtractor" + class TestExtractProcessorFileRouting: @pytest.fixture(autouse=True) diff --git a/api/tests/unit_tests/events/test_app_event_signals.py b/api/tests/unit_tests/events/test_app_event_signals.py index cde404407a2..f0ce7014b50 100644 --- a/api/tests/unit_tests/events/test_app_event_signals.py +++ b/api/tests/unit_tests/events/test_app_event_signals.py @@ -11,7 +11,7 @@ from events.app_event import app_was_deleted, app_was_updated from models.account import Account from models.agent import Agent, AgentWorkspace from models.dataset import AppDatasetJoin -from models.model import App, AppMode, AppModelConfig, IconType, InstalledApp +from models.model import App, AppMode, AppModelConfig, IconType, InstalledApp, Site from services.app_service import AppService @@ -270,6 +270,13 @@ class TestAppModelConfigWasUpdatedSignal: @pytest.mark.parametrize("sqlite_session", [(App, Account, InstalledApp)], indirect=True) class TestCreateInstalledAppWhenAppCreated: + def test_skips_when_records_were_initialized_transactionally(self, app_model: App, sqlite_session: Session) -> None: + from events.event_handlers.create_installed_app_when_app_created import handle + + handle(app_model, session=sqlite_session, created_records_initialized=True) + + assert sqlite_session.scalar(select(InstalledApp.id).where(InstalledApp.app_id == app_model.id)) is None + def test_skips_existing_installation(self, app_model: App, sqlite_session: Session) -> None: from events.event_handlers.create_installed_app_when_app_created import handle @@ -324,3 +331,19 @@ class TestCreateInstalledAppWhenAppCreated: assert installed_app.app_id == app_model.id assert installed_app.tenant_id == app_model.tenant_id assert commits == [] + + +@pytest.mark.parametrize("sqlite_session", [(App, Account, Site)], indirect=True) +def test_create_site_handler_skips_transactionally_initialized_records( + app_model: App, account: Account, sqlite_session: Session +) -> None: + from events.event_handlers.create_site_record_when_app_created import handle + + handle( + app_model, + account=account, + session=sqlite_session, + created_records_initialized=True, + ) + + assert sqlite_session.scalar(select(Site.id).where(Site.app_id == app_model.id)) is None diff --git a/api/tests/unit_tests/libs/test_zip_filename_recovery.py b/api/tests/unit_tests/libs/test_zip_filename_recovery.py new file mode 100644 index 00000000000..c06581434c0 --- /dev/null +++ b/api/tests/unit_tests/libs/test_zip_filename_recovery.py @@ -0,0 +1,94 @@ +import io +import struct +import zipfile + +import pytest + +from libs.zip_filename_recovery import open_zip_with_replacement_names + + +def _zip64(monkeypatch: pytest.MonkeyPatch, limit: int) -> bytes: + output = io.BytesIO() + with monkeypatch.context() as scoped: + scoped.setattr(zipfile, "ZIP64_LIMIT", limit) + with zipfile.ZipFile(output, "w", allowZip64=True) as archive: + archive.writestr("padding.txt", b"p" * 200) + archive.writestr("scripts/\u00e9.py", b"print('ok')\n") + content = output.getvalue() + assert len(content) < 2048 + assert b"PK\x06\x06" in content + return content + + +@pytest.mark.parametrize("limit", [0, 100]) +@pytest.mark.parametrize("prefix", [b"", b"self-extracting-prefix"]) +def test_zip64_replaces_names_and_preserves_payload(monkeypatch: pytest.MonkeyPatch, limit: int, prefix: bytes) -> None: + original = prefix + _zip64(monkeypatch, limit) + with zipfile.ZipFile(io.BytesIO(original)) as archive: + assert archive.read("scripts/\u00e9.py") == b"print('ok')\n" + damaged = original.replace(b"\xc3\xa9.py", b"\xffa.py") + with open_zip_with_replacement_names(damaged) as archive: + assert archive.read("scripts/\ufffda.py") == b"print('ok')\n" + assert archive.testzip() is None + + +def test_zip64_uses_extended_values_when_legacy_fields_are_sentinels(monkeypatch: pytest.MonkeyPatch) -> None: + content = bytearray(_zip64(monkeypatch, 0)) + end = content.rfind(b"PK\x05\x06") + struct.pack_into(" None: + content = bytearray(_zip64(monkeypatch, 0)) + record = content.rfind(b"PK\x06\x06") + locator = content.rfind(b"PK\x06\x07") + extension = b"extension" + struct.pack_into(" None: + content = _zip64(monkeypatch, 0) + damaged = content.replace(b"\xc3\xa9.py", b"\xffa.py").replace(b"print('ok')", b"print('NO')") + with open_zip_with_replacement_names(damaged) as archive: + with pytest.raises(zipfile.BadZipFile, match="CRC"): + archive.read("scripts/\ufffda.py") + + +def test_zip64_recovery_rejects_mismatched_local_name(monkeypatch: pytest.MonkeyPatch) -> None: + original = _zip64(monkeypatch, 0) + directory = original.index(b"PK\x01\x02") + damaged = original[:directory] + original[directory:].replace(b"\xc3\xa9.py", b"\xffa.py") + with pytest.raises(zipfile.BadZipFile, match="names differ"): + open_zip_with_replacement_names(damaged) + + +def _damaged_utf8_zip() -> bytearray: + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as archive: + archive.writestr("scripts/\u00e9.py", b"print('ok')\n") + return bytearray(output.getvalue().replace(b"\xc3\xa9.py", b"\xffa.py")) + + +@pytest.mark.parametrize("offset", [0xFFFFFFFF, 0xFFFFFFFE]) +def test_recovery_rejects_unresolvable_local_header_offsets(offset: int) -> None: + content = _damaged_utf8_zip() + directory = content.index(b"PK\x01\x02") + struct.pack_into(" None: + content = _damaged_utf8_zip() + end = content.rfind(b"PK\x05\x06") + struct.pack_into(" AppMCPServer: + server = AppMCPServer( + tenant_id=str(uuid4()), + app_id=str(uuid4()), + name="Test MCP Server", + description="Test server", + server_code=server_code, + status=AppMCPServerStatus.ACTIVE, + parameters="{}", + ) + session.add(server) + session.flush() + return server + + +def test_generate_server_code_retries_a_taken_code_through_the_given_session(sqlite_session: Session) -> None: + _persist_server(sqlite_session, server_code="taken-code") + + with patch("models.model.generate_string", side_effect=["taken-code", "fresh-code"]): + result = AppMCPServer.generate_server_code(16, session=sqlite_session) + + assert result == "fresh-code" diff --git a/api/tests/unit_tests/models/test_app_models.py b/api/tests/unit_tests/models/test_app_models.py index 7485031575a..50e439fb7d0 100644 --- a/api/tests/unit_tests/models/test_app_models.py +++ b/api/tests/unit_tests/models/test_app_models.py @@ -33,6 +33,7 @@ from models.model import ( Site, load_annotation_reply_config, ) +from models.workflow import Workflow, WorkflowType class TestAppModelValidation: @@ -122,8 +123,9 @@ class TestAppModelValidation: # Assert assert {t.value for t in IconType} == {"image", "emoji", "link"} - def test_app_desc_or_prompt_with_description(self): - """Test desc_or_prompt property when description exists.""" + @pytest.mark.parametrize("sqlite_session", [(App, AppModelConfig)], indirect=True) + def test_app_desc_or_prompt_with_description(self, sqlite_session: Session): + """Test desc_or_prompt_with_session when description exists.""" # Arrange app = App( tenant_id=str(uuid4()), @@ -136,7 +138,7 @@ class TestAppModelValidation: ) # Act - result = app.desc_or_prompt + result = app.desc_or_prompt_with_session(session=sqlite_session) # Assert assert result == "App description" @@ -267,6 +269,110 @@ class TestAppModelValidation: # Assert assert result == AppMode.AGENT_CHAT + @pytest.mark.parametrize("sqlite_session", [(App, AppModelConfig)], indirect=True) + def test_app_model_config_with_session_reads_through_the_caller_session(self, sqlite_session: Session): + """`app_model_config_with_session` resolves the linked config through the caller's session.""" + # Arrange + app = App( + tenant_id=str(uuid4()), + name="Test App", + mode=AppMode.CHAT, + enable_site=True, + enable_api=False, + created_by=str(uuid4()), + ) + sqlite_session.add(app) + sqlite_session.flush() + model_config = AppModelConfig(app_id=app.id, agent_mode=json.dumps({"enabled": False})) + sqlite_session.add(model_config) + sqlite_session.flush() + app.app_model_config_id = model_config.id + sqlite_session.commit() + + # Act: a session the model never owns, proving the lookup does not reach for a global one + with Session(sqlite_session.get_bind(), expire_on_commit=False) as caller_session: + result = app.app_model_config_with_session(session=caller_session) + + # Assert + assert result is not None + assert result.id == model_config.id + assert result in caller_session + + @pytest.mark.parametrize("sqlite_session", [(App, AppModelConfig)], indirect=True) + def test_app_model_config_with_session_returns_none_when_unlinked(self, sqlite_session: Session): + """An app without `app_model_config_id` resolves to None without querying.""" + # Arrange + app = App( + tenant_id=str(uuid4()), + name="Test App", + mode=AppMode.CHAT, + enable_site=True, + enable_api=False, + created_by=str(uuid4()), + ) + sqlite_session.add(app) + sqlite_session.flush() + + # Act / Assert: an unbound session would raise if the guard were dropped + with Session(expire_on_commit=False) as unbound_session: + assert app.app_model_config_with_session(session=unbound_session) is None + + @pytest.mark.parametrize("sqlite_session", [(App, AppModelConfig)], indirect=True) + def test_workflow_with_session_reads_through_the_caller_session(self, sqlite_session: Session): + """`workflow_with_session` resolves the published workflow through the caller's session.""" + # Arrange + app = App( + tenant_id=str(uuid4()), + name="Test App", + mode=AppMode.WORKFLOW, + enable_site=True, + enable_api=False, + created_by=str(uuid4()), + ) + sqlite_session.add(app) + sqlite_session.flush() + workflow = Workflow( + tenant_id=app.tenant_id, + app_id=app.id, + type=WorkflowType.WORKFLOW, + version="1", + graph=json.dumps({"nodes": [], "edges": []}), + created_by=app.created_by, + ) + workflow._features = "{}" + sqlite_session.add(workflow) + sqlite_session.flush() + app.workflow_id = workflow.id + sqlite_session.commit() + + # Act: a session the model never owns, proving the lookup does not reach for a global one + with Session(sqlite_session.get_bind(), expire_on_commit=False) as caller_session: + result = app.workflow_with_session(session=caller_session) + + # Assert + assert result is not None + assert result.id == workflow.id + assert result in caller_session + + @pytest.mark.parametrize("sqlite_session", [(App, AppModelConfig)], indirect=True) + def test_workflow_with_session_returns_none_when_unpublished(self, sqlite_session: Session): + """An app without `workflow_id` resolves to None without querying.""" + # Arrange + app = App( + tenant_id=str(uuid4()), + name="Test App", + mode=AppMode.WORKFLOW, + enable_site=True, + enable_api=False, + created_by=str(uuid4()), + ) + sqlite_session.add(app) + sqlite_session.flush() + + # Act / Assert: an unbound session would raise if the guard were dropped + with Session(expire_on_commit=False) as unbound_session: + assert app.workflow_with_session(session=unbound_session) is None + @pytest.mark.parametrize("sqlite_session", [(App, AppModelConfig)], indirect=True) def test_deleted_tools_checks_plugin_builtin_providers_through_core_plugin_service(self, sqlite_session: Session): """Plugin-backed built-in tools are checked through core PluginService.""" @@ -528,8 +634,8 @@ class TestConversationModel: assert conversation.from_source == "api" assert conversation.from_end_user_id == from_end_user_id - def test_conversation_with_inputs(self): - """Test conversation inputs property.""" + def test_conversation_with_inputs(self, sqlite_session: Session): + """Test conversation inputs round-trip through the session-aware accessor.""" # Arrange inputs = {"query": "Hello", "context": "test"} conversation = Conversation( @@ -543,11 +649,18 @@ class TestConversationModel: conversation._inputs = inputs # Act - result = conversation.inputs + result = conversation.inputs_with_session(session=sqlite_session) # Assert assert result == inputs + def test_conversation_inputs_is_write_only(self): + """Reading conversation.inputs must fail loudly; reads go through inputs_with_session.""" + conversation = Conversation(app_id=str(uuid4()), _inputs={}) + + with pytest.raises(AttributeError, match="no getter"): + _ = conversation.inputs + def test_conversation_inputs_setter(self): """Test conversation inputs setter.""" # Arrange @@ -772,8 +885,8 @@ class TestMessageModel: assert message.currency == "USD" assert message.from_source == "api" - def test_message_with_inputs(self): - """Test message inputs property.""" + def test_message_with_inputs(self, sqlite_session: Session): + """Test message inputs round-trip through the session-aware accessor.""" # Arrange inputs = {"query": "Hello", "context": "test"} message = Message( @@ -790,11 +903,18 @@ class TestMessageModel: ) # Act - result = message.inputs + result = message.inputs_with_session(session=sqlite_session) # Assert assert result == inputs + def test_message_inputs_is_write_only(self): + """Reading message.inputs must fail loudly; reads go through inputs_with_session.""" + message = Message(app_id=str(uuid4()), _inputs={}) + + with pytest.raises(AttributeError, match="no getter"): + _ = message.inputs + def test_message_inputs_setter(self): """Test message inputs setter.""" # Arrange diff --git a/api/tests/unit_tests/models/test_dataset_models.py b/api/tests/unit_tests/models/test_dataset_models.py index 2ca01cf44f9..77e36f10f63 100644 --- a/api/tests/unit_tests/models/test_dataset_models.py +++ b/api/tests/unit_tests/models/test_dataset_models.py @@ -17,12 +17,11 @@ from urllib.parse import parse_qs, urlparse from uuid import uuid4 import pytest -from sqlalchemy.orm import Session, scoped_session, sessionmaker +from sqlalchemy.orm import Session from core.rag.entities import ParentMode from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from extensions.storage.storage_type import StorageType -from models import dataset as dataset_module from models.account import Account from models.dataset import ( AppDatasetJoin, @@ -821,59 +820,6 @@ class TestDocumentModelRelationships: assert document.get_dataset(session=sqlite_session) is dataset - @pytest.mark.parametrize("sqlite_session", [(Document, DocumentSegment)], indirect=True) - def test_document_average_segment_length( - self, - sqlite_session: Session, - sqlite_session_factory: sessionmaker[Session], - monkeypatch: pytest.MonkeyPatch, - ): - """Test average_segment_length property calculation.""" - # Arrange - document = Document( - tenant_id=str(uuid4()), - dataset_id=str(uuid4()), - position=1, - data_source_type=DataSourceType.UPLOAD_FILE, - batch="batch_001", - name="test.pdf", - created_from=DocumentCreatedFrom.WEB, - created_by=str(uuid4()), - word_count=1000, - ) - sqlite_session.add(document) - sqlite_session.flush() - sqlite_session.add_all(_make_segments(document, [0] * 10)) - sqlite_session.commit() - monkeypatch.setattr(dataset_module.db, "session", scoped_session(sqlite_session_factory)) - - # Act - result = document.average_segment_length - - # Assert - assert result == 100 - - def test_document_average_segment_length_zero(self): - """Test average_segment_length property when word_count is zero.""" - # Arrange - document = Document( - tenant_id=str(uuid4()), - dataset_id=str(uuid4()), - position=1, - data_source_type=DataSourceType.UPLOAD_FILE, - batch="batch_001", - name="test.pdf", - created_from=DocumentCreatedFrom.WEB, - created_by=str(uuid4()), - word_count=0, - ) - - # Act - result = document.average_segment_length - - # Assert - assert result == 0 - class TestDocumentSegmentIndexing: """Test suite for DocumentSegment model indexing and operations.""" @@ -1573,49 +1519,6 @@ class TestModelIntegration: assert document.word_count == 100 assert segment.status == SegmentStatus.COMPLETED - @pytest.mark.parametrize("sqlite_session", [(Document, DocumentSegment)], indirect=True) - def test_document_to_dict_serialization( - self, - sqlite_session: Session, - sqlite_session_factory: sessionmaker[Session], - monkeypatch: pytest.MonkeyPatch, - ): - """Test document to_dict method for serialization.""" - # Arrange - tenant_id = str(uuid4()) - dataset_id = str(uuid4()) - created_by = str(uuid4()) - - document = Document( - tenant_id=tenant_id, - dataset_id=dataset_id, - position=1, - data_source_type=DataSourceType.UPLOAD_FILE, - batch="batch_001", - name="test.pdf", - created_from=DocumentCreatedFrom.WEB, - created_by=created_by, - word_count=100, - indexing_status=IndexingStatus.COMPLETED, - ) - sqlite_session.add(document) - sqlite_session.flush() - sqlite_session.add_all(_make_segments(document, [2, 2, 2, 2, 2])) - sqlite_session.commit() - monkeypatch.setattr(dataset_module.db, "session", scoped_session(sqlite_session_factory)) - - # Act - result = document.to_dict() - - # Assert - assert result["tenant_id"] == tenant_id - assert result["dataset_id"] == dataset_id - assert result["name"] == "test.pdf" - assert result["word_count"] == 100 - assert result["indexing_status"] == IndexingStatus.COMPLETED - assert result["segment_count"] == 5 - assert result["hit_count"] == 10 - class TestChildChunkSessionAccessors: """Regression coverage for the ``ChildChunk`` accessors refactored to take a caller-provided session. diff --git a/api/tests/unit_tests/models/test_model.py b/api/tests/unit_tests/models/test_model.py index 39a84d64d7e..47b9aeb9074 100644 --- a/api/tests/unit_tests/models/test_model.py +++ b/api/tests/unit_tests/models/test_model.py @@ -104,7 +104,9 @@ def _build_local_file_mapping(record_id: str, *, tenant_id: str | None = None) - @pytest.mark.parametrize("owner_cls", [Conversation, Message]) -def test_inputs_restore_external_remote_url_file_mappings(owner_cls: type[Conversation] | type[Message]) -> None: +def test_inputs_restore_external_remote_url_file_mappings( + owner_cls: type[Conversation] | type[Message], sqlite_session: Session +) -> None: owner = owner_cls(app_id="app-1") owner.inputs = { "file": { @@ -119,7 +121,7 @@ def test_inputs_restore_external_remote_url_file_mappings(owner_cls: type[Conver } } - restored_file = owner.inputs["file"] + restored_file = owner.inputs_with_session(session=sqlite_session)["file"] assert restored_file.transfer_method == FileTransferMethod.REMOTE_URL assert restored_file.remote_url == "https://example.com/report.pdf" diff --git a/api/tests/unit_tests/models/test_recommended_app_session_accessors.py b/api/tests/unit_tests/models/test_recommended_app_session_accessors.py new file mode 100644 index 00000000000..e560144b66f --- /dev/null +++ b/api/tests/unit_tests/models/test_recommended_app_session_accessors.py @@ -0,0 +1,102 @@ +"""Regression coverage for the ``@property``→session-parameter refactor on +``RecommendedApp`` and ``AccountTrialAppRecord``. + +The legacy ``@property`` accessors reached for the global ``db.session`` internally and have +been converted to plain methods taking an explicit ``session: Session`` (per the pattern +established in #40370/#41394/#41830/#42000, tracked in #40372). +""" + +from uuid import uuid4 + +from sqlalchemy.orm import Session + +from models.account import Account +from models.model import AccountTrialAppRecord, App, AppMode, RecommendedApp + + +def _persist_app(session: Session) -> App: + app = App( + tenant_id=str(uuid4()), + name="Test App", + mode=AppMode.CHAT, + enable_site=True, + enable_api=False, + created_by=str(uuid4()), + description="App description", + ) + session.add(app) + session.flush() + return app + + +def _persist_recommended_app(session: Session, *, app_id: str) -> RecommendedApp: + recommended_app = RecommendedApp( + app_id=app_id, + description={"en_US": "A recommended app"}, + copyright="Dify", + privacy_policy="https://dify.ai/privacy", + category="Assistant", + ) + session.add(recommended_app) + session.flush() + return recommended_app + + +def _persist_account(session: Session) -> Account: + account = Account(name="Test User", email=f"user-{uuid4()}@example.com") + session.add(account) + session.flush() + return account + + +def _persist_trial_record(session: Session, *, account_id: str, app_id: str) -> AccountTrialAppRecord: + record = AccountTrialAppRecord(account_id=account_id, app_id=app_id, count=1) + session.add(record) + session.flush() + return record + + +class TestRecommendedAppApp: + def test_returns_the_matching_app(self, sqlite_session: Session) -> None: + app = _persist_app(sqlite_session) + recommended_app = _persist_recommended_app(sqlite_session, app_id=app.id) + + result = recommended_app.app(session=sqlite_session) + + assert result is not None + assert result.id == app.id + + def test_returns_none_when_app_missing(self, sqlite_session: Session) -> None: + recommended_app = _persist_recommended_app(sqlite_session, app_id=str(uuid4())) + + assert recommended_app.app(session=sqlite_session) is None + + +class TestAccountTrialAppRecordAccessors: + def test_app_returns_the_matching_app(self, sqlite_session: Session) -> None: + app = _persist_app(sqlite_session) + record = _persist_trial_record(sqlite_session, account_id=str(uuid4()), app_id=app.id) + + result = record.app(session=sqlite_session) + + assert result is not None + assert result.id == app.id + + def test_app_returns_none_when_app_missing(self, sqlite_session: Session) -> None: + record = _persist_trial_record(sqlite_session, account_id=str(uuid4()), app_id=str(uuid4())) + + assert record.app(session=sqlite_session) is None + + def test_user_returns_the_matching_account(self, sqlite_session: Session) -> None: + account = _persist_account(sqlite_session) + record = _persist_trial_record(sqlite_session, account_id=account.id, app_id=str(uuid4())) + + result = record.user(session=sqlite_session) + + assert result is not None + assert result.id == account.id + + def test_user_returns_none_when_account_missing(self, sqlite_session: Session) -> None: + record = _persist_trial_record(sqlite_session, account_id=str(uuid4()), app_id=str(uuid4())) + + assert record.user(session=sqlite_session) is None diff --git a/api/tests/unit_tests/services/agent/test_agent_dsl_service.py b/api/tests/unit_tests/services/agent/test_agent_dsl_service.py index c3a069f15d3..085221a2f9a 100644 --- a/api/tests/unit_tests/services/agent/test_agent_dsl_service.py +++ b/api/tests/unit_tests/services/agent/test_agent_dsl_service.py @@ -124,7 +124,8 @@ def _agent_node(node_id: str, binding: object | None = None) -> dict: return {"id": node_id, "data": data} -def test_make_portable_agent_package_strips_workspace_credentials_and_assets() -> None: +@pytest.mark.parametrize("include_assets", [False, True]) +def test_make_portable_agent_package_strips_workspace_credentials_and_assets(include_assets: bool) -> None: soul = AgentSoulConfig.model_validate( { "model": { @@ -164,8 +165,10 @@ def test_make_portable_agent_package_strips_workspace_credentials_and_assets() - ], }, "env": {"secret_refs": [{"name": "GLOBAL_TOKEN", "value": "plain-secret", "id": "secret-1"}]}, - "config_skills": [{"name": "research", "file_kind": "tool_file", "file_id": "skill-file"}], - "config_files": [{"name": "guide.md", "file_kind": "upload_file", "file_id": "config-file"}], + "config_skills": [{"name": "research", "file_id": "skill-file"}, {"name": "missing", "is_missing": True}], + "config_files": [ + {"name": "guide.md", "file_kind": "upload_file", "file_id": "config-file", "mime_type": "text/markdown"} + ], "human": { "contacts": [ { @@ -179,7 +182,9 @@ def test_make_portable_agent_package_strips_workspace_credentials_and_assets() - } ) - package = make_portable_agent_package(_agent(), soul) + original = soul.model_dump(mode="json") + package = make_portable_agent_package(_agent(), soul, include_assets=include_assets) + assert soul.model_dump(mode="json") == original serialized = package.model_dump(mode="json") assert package.soul.model is not None @@ -189,20 +194,32 @@ def test_make_portable_agent_package_strips_workspace_credentials_and_assets() - assert package.soul.tools.dify_tools[0].runtime_parameters["upload_file_id"] is None assert package.soul.tools.dify_tools[0].runtime_parameters["api_key"] is None assert package.soul.config_skills[0].name == "research" - assert package.soul.config_skills[0].file_id == "" - assert package.soul.config_skills[0].is_missing is True + assert package.soul.config_skills[0].file_id == ("skill-file" if include_assets else "") + assert package.soul.config_skills[0].is_missing is (not include_assets) assert package.soul.config_files[0].name == "guide.md" - assert package.soul.config_files[0].file_id == "" - assert package.soul.config_files[0].is_missing is True - assert [asset.kind for asset in package.omitted_assets] == ["skill", "file"] + assert package.soul.config_files[0].file_id == ("config-file" if include_assets else "") + assert package.soul.config_files[0].is_missing is (not include_assets) + assert package.soul.config_skills[1].is_missing + assert package.soul.config_files[0].mime_type == "text/markdown" + assert [asset.kind for asset in package.omitted_assets] == ( + ["skill"] if include_assets else ["skill", "skill", "file"] + ) + assert [asset.name for asset in package.omitted_assets] == ( + ["missing"] if include_assets else ["research", "missing", "guide.md"] + ) assert "plain-secret" not in str(serialized) assert "model-secret" not in str(serialized) assert "tool-secret" not in str(serialized) - assert "skill-file" not in str(serialized) - assert "config-file" not in str(serialized) + assert ("skill-file" in str(serialized)) is include_assets + assert ("config-file" in str(serialized)) is include_assets assert package.soul.human.contacts[0].id is None assert package.soul.human.contacts[0].name == "Reviewer" + package.soul.config_skills[0].description = "Changed" + package.soul.config_files[0].name = "changed.md" + package.soul.tools.cli_tools[0].env.secret_refs[0].name = "CHANGED" + assert soul.model_dump(mode="json") == original + def test_agent_package_round_trips_as_strict_dsl_dto() -> None: package = make_portable_agent_package(_agent(), AgentSoulConfig()) @@ -271,7 +288,7 @@ def test_import_warnings_cover_runtime_setup_removed_from_package( ) monkeypatch.setattr("services.agent.dsl_service.get_tenant_knowledge_dataset_rows", Mock(return_value={})) - _, warnings = AgentDslService(unbound_session)._resolve_package_soul( + _, warnings = AgentDslService(unbound_session).resolve_package_soul( tenant_id="tenant-1", package=make_portable_agent_package(_agent(), soul), package_path="agent_packages.agent_1", @@ -397,7 +414,7 @@ def test_import_agent_app_package_creates_config_and_unpublished_draft( service = AgentDslService(sqlite_session) soul = AgentSoulConfig(config_note="portable") warning = DslImportWarning(code="setup", path="agent.soul", message="setup required") - service._resolve_package_soul = Mock(return_value=(soul, [warning])) + service.resolve_package_soul = Mock(return_value=(soul, [warning])) service._unique_roster_name = Mock(return_value="Portable Agent import") agent = _agent() agent.active_config_snapshot_id = "snapshot-1" @@ -590,22 +607,28 @@ def test_clone_inline_binding_copies_soul(unbound_session: Session) -> None: assert create_kwargs["source"] == AgentSource.WORKFLOW -def test_extract_package_dependencies_covers_model_tools_and_knowledge( +def test_extract_package_dependencies_covers_models_features_tools_and_knowledge( monkeypatch: pytest.MonkeyPatch, unbound_session: Session ) -> None: model_dependency = Mock(side_effect=lambda provider: f"model:{provider}") tool_dependency = Mock(side_effect=lambda provider: f"tool:{provider}") monkeypatch.setattr( - "services.agent.dsl_service.DependenciesAnalysisService.analyze_model_provider_dependency", + "services.agent.dependency_service.DependenciesAnalysisService.analyze_model_provider_dependency", model_dependency, ) monkeypatch.setattr( - "services.agent.dsl_service.DependenciesAnalysisService.analyze_tool_dependency", + "services.agent.dependency_service.DependenciesAnalysisService.analyze_tool_dependency", tool_dependency, ) soul = AgentSoulConfig.model_validate( { "model": {"plugin_id": "model-plugin", "model_provider": "provider/model", "model": "model"}, + "app_features": { + "suggested_questions_after_answer": { + "enabled": True, + "model": {"provider": "provider/follow-up", "name": "follow-up-model"}, + } + }, "tools": { "dify_tools": [ { @@ -631,9 +654,49 @@ def test_extract_package_dependencies_covers_model_tools_and_knowledge( "retrieval": { "mode": "single", "model": {"provider": "provider/retrieval", "name": "embed", "mode": "embedding"}, + }, + "metadata_filtering": { + "mode": "automatic", + "model_config": { + "provider": "provider/metadata", + "name": "metadata-model", + "mode": "chat", + }, + }, + }, + { + "id": "set-2", + "name": "Reranked Set", + "datasets": [{"id": "dataset-2", "name": "Reranked Docs"}], + "query": {"mode": "user_query", "value": "query"}, + "retrieval": { + "mode": "multiple", + "top_k": 5, + "reranking_enable": True, + "reranking_mode": "reranking_model", "reranking_model": {"provider": "provider/rerank", "model": "rerank"}, }, - } + }, + { + "id": "set-3", + "name": "Weighted Set", + "datasets": [{"id": "dataset-3", "name": "Weighted Docs"}], + "query": {"mode": "user_query", "value": "query"}, + "retrieval": { + "mode": "multiple", + "top_k": 5, + "reranking_enable": True, + "reranking_mode": "weighted_score", + "weights": { + "vector_setting": { + "embedding_provider_name": "provider/embedding", + "embedding_model_name": "embedding-model", + "vector_weight": 0.7, + }, + "keyword_setting": {"keyword_weight": 0.3}, + }, + }, + }, ] }, } @@ -645,18 +708,77 @@ def test_extract_package_dependencies_covers_model_tools_and_knowledge( assert dependencies == [ "model:provider/model", + "model:provider/follow-up", "tool:provider/tool", "tool:plugin-id/fallback-provider", "model:provider/retrieval", + "model:provider/metadata", "model:provider/rerank", + "model:provider/embedding", ] +def test_extract_package_dependencies_ignores_inactive_knowledge_models( + monkeypatch: pytest.MonkeyPatch, unbound_session: Session +) -> None: + model_dependency = Mock(side_effect=lambda provider: f"model:{provider}") + monkeypatch.setattr( + "services.agent.dependency_service.DependenciesAnalysisService.analyze_model_provider_dependency", + model_dependency, + ) + soul = AgentSoulConfig.model_validate( + { + "knowledge": { + "sets": [ + { + "id": "set-1", + "name": "Set", + "datasets": [{"id": "dataset-1", "name": "Docs"}], + "query": {"mode": "user_query", "value": "query"}, + "retrieval": { + "mode": "multiple", + "top_k": 5, + "reranking_enable": False, + "reranking_mode": "weighted_score", + "reranking_model": {"provider": "provider/inactive-rerank", "model": "rerank"}, + "weights": { + "vector_setting": { + "embedding_provider_name": "provider/inactive-embedding", + } + }, + "model": { + "provider": "provider/inactive-retrieval", + "name": "retrieval-model", + "mode": "chat", + }, + }, + "metadata_filtering": { + "mode": "disabled", + "model_config": { + "provider": "provider/inactive-metadata", + "name": "metadata-model", + "mode": "chat", + }, + }, + } + ] + } + } + ) + + dependencies = AgentDslService(unbound_session).extract_package_dependencies( + {"agent_1": make_portable_agent_package(_agent(), soul)} + ) + + assert dependencies == [] + model_dependency.assert_not_called() + + def test_create_imported_inline_agent_uses_import_provenance(unbound_session: Session) -> None: service = AgentDslService(unbound_session) soul = AgentSoulConfig(config_note="inline") warning = DslImportWarning(code="setup", path="agent", message="setup") - service._resolve_package_soul = Mock(return_value=(soul, [warning])) + service.resolve_package_soul = Mock(return_value=(soul, [warning])) service._create_workflow_only_agent = Mock(return_value=(_agent(), _snapshot(soul=soul))) workflow = _workflow() @@ -743,7 +865,7 @@ def test_resolve_package_soul_preserves_existing_and_marks_missing_knowledge( get_dataset_rows = Mock(return_value={"existing": existing_dataset}) monkeypatch.setattr("services.agent.dsl_service.get_tenant_knowledge_dataset_rows", get_dataset_rows) - resolved, warnings = AgentDslService(sqlite_session)._resolve_package_soul( + resolved, warnings = AgentDslService(sqlite_session).resolve_package_soul( tenant_id="tenant-1", package=make_portable_agent_package(_agent(), soul), package_path="agent_packages.agent_1", diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index 90435a5ef12..43ed0043d64 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -40,6 +40,7 @@ from models.agent_config_entities import ( from models.enums import AppStatus, ConversationFromSource, ConversationStatus from models.model import App, AppMode, AppModelConfig, Conversation, IconType, Message from models.skill import AgentSkillBindingSnapshot, Skill, SkillVersion, SkillVersionManifest +from models.tools import ToolFile from models.workflow import Workflow, WorkflowType from services.agent import composer_service, roster_service from services.agent.agent_soul_state import agent_soul_has_model @@ -5603,7 +5604,17 @@ class TestWorkflowAgentDraftBindingSync: skill_id="skill-1", priority=0, ) - session.add_all([binding, agent, snapshot, skill, skill_version, skill_binding]) + archive_file = ToolFile( + user_id="account-1", + tenant_id="tenant-1", + conversation_id=None, + file_key="tools/research.zip", + mimetype="application/zip", + name="research.zip", + size=1, + ) + archive_file.id = "archive-1" + session.add_all([binding, agent, snapshot, skill, skill_version, skill_binding, archive_file]) session.commit() WorkflowAgentPublishService.validate_agent_nodes_for_publish( diff --git a/api/tests/unit_tests/services/agent/test_roster_package_importer.py b/api/tests/unit_tests/services/agent/test_roster_package_importer.py new file mode 100644 index 00000000000..2e8ee8f4324 --- /dev/null +++ b/api/tests/unit_tests/services/agent/test_roster_package_importer.py @@ -0,0 +1,817 @@ +from __future__ import annotations + +import hashlib +import io +import json +import zipfile +from collections.abc import Callable, Generator + +import pytest +import yaml +from sqlalchemy import event, func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, sessionmaker +from werkzeug.exceptions import Forbidden + +from core.db.session_factory import session_factory +from core.plugin.entities.plugin import PluginDependency, PluginDependencyType +from models import Account +from models.account import TenantPluginDebugPermission, TenantPluginInstallPermission, TenantPluginPermission +from models.agent import ( + Agent, + AgentConfigDraft, + AgentConfigRevision, + AgentConfigRevisionOperation, + AgentConfigSnapshot, + AgentDebugConversation, + AgentKind, + AgentScope, + AgentSource, + AgentStatus, +) +from models.agent_config_entities import AgentSoulConfig +from models.base import Base, TypeBase +from models.model import App, AppModelConfig, InstalledApp, Site, UploadFile +from models.tools import ToolFile +from services.agent.dsl_entities import ( + AgentPackage, + AgentPackageMetadata, + AgentPackageWorkspaceSkill, + make_agent_app_dsl, +) +from services.agent.errors import ( + AgentNameConflictError, + InvalidRosterAgentPackageError, + RosterAgentPackageDependenciesMissingError, + RosterAgentPackageImportFailedError, + RosterAgentPackageResourceUnavailableError, + RosterAgentPackageTooLargeError, +) +from services.agent.roster_package_entities import ( + ROSTER_AGENT_PACKAGE_FORMAT, + ROSTER_AGENT_PACKAGE_FORMAT_VERSION, + RosterAgentPackageApp, + RosterAgentPackageFile, + RosterAgentPackageManifest, + RosterAgentPackageSkill, +) +from services.agent.roster_package_importer import RosterAgentPackageImporter +from services.app_service import AppService +from services.file_service import FileService +from services.plugin.dependencies_analysis import DependenciesAnalysisService + + +class _MemoryStorage: + def __init__(self, *, fail_save_at: int | None = None) -> None: + self.files: dict[str, bytes] = {} + self.deleted: list[str] = [] + self.save_count = 0 + self.fail_save_at = fail_save_at + + def save(self, filename: str, data: bytes) -> None: + self.save_count += 1 + if self.fail_save_at == self.save_count: + raise OSError("storage unavailable") + self.files[filename] = data + + def delete(self, filename: str) -> None: + self.deleted.append(filename) + self.files.pop(filename, None) + + def load_stream(self, filename: str) -> Generator[bytes, None, None]: + yield self.files[filename] + + +@pytest.fixture(autouse=True) +def _installed_plugins(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(DependenciesAnalysisService, "get_leaked_dependencies", lambda **_kwargs: []) + + +def _account() -> Account: + account = Account( + name="Agent Importer", + email="agent-importer@example.com", + interface_language="en-US", + ) + account.id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + return account + + +def _zip(members: dict[str, bytes]) -> bytes: + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED) as archive: + for path, payload in members.items(): + archive.writestr(path, payload) + return output.getvalue() + + +def _skill_archive(name: str) -> bytes: + return _zip( + { + "SKILL.md": f"---\nname: {name}\ndescription: {name} description.\n---\n\n# Skill\n".encode(), + "scripts/run.py": b"print('ok')\n", + } + ) + + +def _package( + *, + name: str = "Imported Agent", + binary_dependency: bool = False, + missing_knowledge: bool = False, +) -> bytes: + config_skill = _skill_archive("config-skill") + workspace_skill = _skill_archive("workspace-skill") + guide = b"guide-content" + notes = b"notes-content" + soul_data: dict[str, object] = { + "prompt": {"system_prompt": "Imported prompt"}, + "config_skills": [{"name": "config-skill", "file_id": "s_000001"}], + "config_files": [ + {"name": "guide.pdf", "file_kind": "upload_file", "file_id": "f_000001"}, + {"name": "notes.txt", "file_kind": "tool_file", "file_id": "f_000002"}, + ], + } + if missing_knowledge: + soul_data["knowledge"] = { + "sets": [ + { + "id": "set-1", + "name": "Documents", + "datasets": [{"id": "missing-dataset-id", "name": "Missing Docs"}], + "query": {"mode": "user_query", "value": "query"}, + "retrieval": { + "mode": "single", + "model": {"provider": "provider/embedding", "name": "embed", "mode": "embedding"}, + }, + } + ] + } + soul = AgentSoulConfig.model_validate(soul_data) + files = [ + RosterAgentPackageFile( + id="f_000001", + path="f_000001.pdf", + size=len(guide), + sha256=hashlib.sha256(guide).hexdigest(), + ), + RosterAgentPackageFile( + id="f_000002", + path="f_000002.txt", + size=len(notes), + sha256=hashlib.sha256(notes).hexdigest(), + ), + ] + members = { + "s_000001.zip": config_skill, + "s_000002.zip": workspace_skill, + "f_000001.pdf": guide, + "f_000002.txt": notes, + } + dependency = PluginDependency( + type=PluginDependencyType.Marketplace, + value=PluginDependency.Marketplace(marketplace_plugin_unique_identifier="langgenius/example:1.0.0@digest"), + ) + app = make_agent_app_dsl( + App(name=name, mode="agent"), + package_ref="agent_1", + packages={ + "agent_1": AgentPackage( + metadata=AgentPackageMetadata(name=name, description="Imported description", role="researcher"), + soul=soul, + workspace_skills=[ + AgentPackageWorkspaceSkill( + name="workspace-skill", + display_name="Workspace Skill", + description="workspace-skill description.", + priority=0, + ) + ], + ) + }, + dependencies=[dependency], + ) + app_bytes = yaml.safe_dump(app.model_dump(mode="json")).encode() + manifest = RosterAgentPackageManifest( + format=ROSTER_AGENT_PACKAGE_FORMAT, + format_version=ROSTER_AGENT_PACKAGE_FORMAT_VERSION, + apps=[ + RosterAgentPackageApp(path="app.yaml", size=len(app_bytes), sha256=hashlib.sha256(app_bytes).hexdigest()) + ], + skills=[ + RosterAgentPackageSkill( + id="s_000001", + scope="agent_config", + name="config-skill", + path="s_000001.zip", + size=len(config_skill), + sha256=hashlib.sha256(config_skill).hexdigest(), + ), + RosterAgentPackageSkill( + id="s_000002", + scope="workspace", + name="workspace-skill", + path="s_000002.zip", + size=len(workspace_skill), + sha256=hashlib.sha256(workspace_skill).hexdigest(), + ), + ], + files=files, + ) + manifest_data = manifest.model_dump(mode="json", exclude_none=True) + if binary_dependency: + manifest_data["files"][0]["role"] = "binary_dependency" + return _zip({"manifest.yaml": yaml.safe_dump(manifest_data).encode(), "app.yaml": app_bytes, **members}) + + +def _count(session: Session, model: type[Base | TypeBase]) -> int: + return session.scalar(select(func.count()).select_from(model)) or 0 + + +@pytest.mark.parametrize("skill_index", [0, 1], ids=["uploaded", "workspace"]) +@pytest.mark.parametrize( + "damage", ["invalid_zip", "missing_skill_md", "member_crc", "checksum", "size", "name_mismatch"] +) +def test_damaged_skill_becomes_missing_with_warning( + monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session], skill_index: int, damage: str +) -> None: + with zipfile.ZipFile(io.BytesIO(_package())) as archive: + members = {name: archive.read(name) for name in archive.namelist()} + manifest = yaml.safe_load(members["manifest.yaml"]) + skill = manifest["skills"][skill_index] + path = skill["path"] + if damage == "invalid_zip": + members[path] = b"not a zip archive" + elif damage == "missing_skill_md": + members[path] = _zip({"README.md": b"no entrypoint"}) + elif damage == "member_crc": + members[path] = members[path].replace(b"print('ok')", b"print('NO')", 1) + elif damage == "name_mismatch": + members[path] = _skill_archive("different-name") + skill["size"] = len(members[path]) + (1 if damage == "size" else 0) + skill["sha256"] = "0" * 64 if damage == "checksum" else hashlib.sha256(members[path]).hexdigest() + members["manifest.yaml"] = yaml.safe_dump(manifest).encode() + storage = _MemoryStorage() + monkeypatch.setattr(AppService, "finalize_created_app", lambda *_args, **_kwargs: None) + result = RosterAgentPackageImporter(storage_backend=storage).import_package( + source=io.BytesIO(_zip(members)), tenant_id="tenant-1", account=_account() + ) + assert len(result.warnings) == 1 + warning = result.warnings[0] + assert warning.code == "agent_skill_missing" + assert warning.details["name"] == skill["name"] + assert warning.details["path"] == path + assert warning.details["reason"] + assert storage.save_count == 3 + with sqlite_session_factory() as session: + draft = session.scalar(select(AgentConfigDraft).where(AgentConfigDraft.agent_id == result.agent_id)) + assert draft is not None + soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict) + damaged = soul.config_skills[skill_index] + assert damaged.name == skill["name"] + assert damaged.is_missing is True + assert damaged.file_id == "" + assert soul.config_skills[1 - skill_index].is_missing is False + assert all(not ref.is_missing for ref in soul.config_files) + assert _count(session, ToolFile) == 2 + + +def test_damaged_ordinary_file_still_rejects_package() -> None: + with zipfile.ZipFile(io.BytesIO(_package())) as archive: + members = {name: archive.read(name) for name in archive.namelist()} + members["f_000001.pdf"] = b"damaged" + storage = _MemoryStorage() + with pytest.raises(InvalidRosterAgentPackageError): + RosterAgentPackageImporter(storage_backend=storage).import_package( + source=io.BytesIO(_zip(members)), tenant_id="tenant-1", account=_account() + ) + assert storage.save_count == 0 + + +def test_import_rejects_multiple_apps_before_writes() -> None: + with zipfile.ZipFile(io.BytesIO(_package())) as archive: + members = {name: archive.read(name) for name in archive.namelist()} + manifest = yaml.safe_load(members["manifest.yaml"]) + members["second.yaml"] = members["app.yaml"] + manifest["apps"].append({**manifest["apps"][0], "path": "second.yaml"}) + members["manifest.yaml"] = yaml.safe_dump(manifest).encode() + storage = _MemoryStorage() + with pytest.raises(InvalidRosterAgentPackageError, match="exactly one Agent App"): + RosterAgentPackageImporter(storage_backend=storage).import_package( + source=io.BytesIO(_zip(members)), tenant_id="tenant-1", account=_account() + ) + assert storage.save_count == 0 + + +def test_import_uses_indexed_app_path( + monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session] +) -> None: + with zipfile.ZipFile(io.BytesIO(_package())) as archive: + members = {name: archive.read(name) for name in archive.namelist()} + manifest = yaml.safe_load(members["manifest.yaml"]) + members["custom.yml"] = members.pop("app.yaml") + manifest["apps"][0]["path"] = "custom.yml" + members["manifest.yaml"] = yaml.safe_dump(manifest).encode() + monkeypatch.setattr(AppService, "finalize_created_app", lambda *_args, **_kwargs: None) + result = RosterAgentPackageImporter(storage_backend=_MemoryStorage()).import_package( + source=io.BytesIO(_zip(members)), tenant_id="tenant-1", account=_account() + ) + with sqlite_session_factory() as session: + app = session.get(App, result.app_id) + assert app is not None + assert app.name == "Imported Agent" + + +@pytest.mark.parametrize("use_zip64", [False, True]) +def test_invalid_utf8_skill_filename_is_recovered( + monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session], use_zip64: bool +) -> None: + with zipfile.ZipFile(io.BytesIO(_package())) as archive: + members = {name: archive.read(name) for name in archive.namelist()} + with monkeypatch.context() as scoped: + if use_zip64: + scoped.setattr(zipfile, "ZIP64_LIMIT", 0) + skill = _zip( + { + "SKILL.md": b"---\nname: config-skill\ndescription: Recoverable skill.\n---\n", + "scripts/\u00e9.py": b"print('ok')\n", + } + ).replace(b"\xc3\xa9.py", b"\xffa.py") + members["s_000001.zip"] = skill + manifest = yaml.safe_load(members["manifest.yaml"]) + manifest["skills"][0]["size"] = len(skill) + manifest["skills"][0]["sha256"] = hashlib.sha256(skill).hexdigest() + members["manifest.yaml"] = yaml.safe_dump(manifest).encode() + storage = _MemoryStorage() + monkeypatch.setattr(AppService, "finalize_created_app", lambda *_args, **_kwargs: None) + result = RosterAgentPackageImporter(storage_backend=storage).import_package( + source=io.BytesIO(_zip(members)), tenant_id="tenant-1", account=_account() + ) + assert result.warnings == [] + with sqlite_session_factory() as session: + draft = session.scalar(select(AgentConfigDraft).where(AgentConfigDraft.agent_id == result.agent_id)) + assert draft is not None + soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict) + assert soul.config_skills[0].is_missing is False + tool_file = session.get(ToolFile, soul.config_skills[0].file_id) + assert tool_file is not None + with zipfile.ZipFile(io.BytesIO(storage.files[tool_file.file_key])) as archive: + assert archive.read("scripts/\ufffda.py") == b"print('ok')\n" + + +@pytest.mark.parametrize("names", [["INVALID-NAME"], ["TOKEN", "TOKEN"]]) +def test_import_rejects_invalid_shell_environment_before_writes( + monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session], names: list[str] +) -> None: + with zipfile.ZipFile(io.BytesIO(_package())) as archive: + members = {name: archive.read(name) for name in archive.namelist()} + app = yaml.safe_load(members["app.yaml"]) + app["agent_packages"]["agent_1"]["soul"]["env"]["variables"] = [{"name": name, "value": "value"} for name in names] + members["app.yaml"] = yaml.safe_dump(app).encode() + manifest = yaml.safe_load(members["manifest.yaml"]) + manifest["apps"][0].update(size=len(members["app.yaml"]), sha256=hashlib.sha256(members["app.yaml"]).hexdigest()) + members["manifest.yaml"] = yaml.safe_dump(manifest).encode() + monkeypatch.setattr(AppService, "finalize_created_app", lambda *_args, **_kwargs: None) + storage = _MemoryStorage() + with pytest.raises(InvalidRosterAgentPackageError, match="Soul is invalid"): + RosterAgentPackageImporter(storage_backend=storage).import_package( + source=io.BytesIO(_zip(members)), tenant_id="tenant-1", account=_account() + ) + assert storage.save_count == 0 + with sqlite_session_factory() as session: + assert _count(session, App) == 0 + + +def test_import_clears_source_credentials( + monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session] +) -> None: + with zipfile.ZipFile(io.BytesIO(_package())) as archive: + members = {name: archive.read(name) for name in archive.namelist()} + app = yaml.safe_load(members["app.yaml"]) + soul = app["agent_packages"]["agent_1"]["soul"] + soul["tools"]["dify_tools"] = [ + { + "provider_id": "langgenius/example/example", + "provider_type": "plugin", + "credential_type": "api-key", + "credential_ref": {"type": "tool", "id": "source-credential"}, + "runtime_parameters": {"api_key": "source-secret", "query": "keep"}, + } + ] + soul["model"] = { + "plugin_id": "langgenius/example", + "model_provider": "langgenius/example/example", + "model": "example", + "credential_ref": {"type": "provider", "id": "source-model"}, + } + soul["env"]["secret_refs"] = [{"name": "TOKEN", "value": "source-secret", "id": "source-id"}] + members["app.yaml"] = yaml.safe_dump(app).encode() + manifest = yaml.safe_load(members["manifest.yaml"]) + manifest["apps"][0].update(size=len(members["app.yaml"]), sha256=hashlib.sha256(members["app.yaml"]).hexdigest()) + members["manifest.yaml"] = yaml.safe_dump(manifest).encode() + monkeypatch.setattr(AppService, "finalize_created_app", lambda *_a, **_k: None) + result = RosterAgentPackageImporter(storage_backend=_MemoryStorage()).import_package( + source=io.BytesIO(_zip(members)), tenant_id="tenant-1", account=_account() + ) + with sqlite_session_factory() as session: + draft = session.scalar(select(AgentConfigDraft).where(AgentConfigDraft.agent_id == result.agent_id)) + assert draft is not None + data = draft.config_snapshot_dict + tool = data["tools"]["dify_tools"][0] + assert tool["credential_type"] == "unauthorized" + assert tool["credential_ref"] is None + assert tool["runtime_parameters"] == {"api_key": None, "query": "keep"} + assert data["model"]["credential_ref"] is None + assert "source-secret" not in json.dumps(data) + assert "source-id" not in json.dumps(data) + + +@pytest.mark.parametrize("allowed", [False, True]) +def test_missing_plugins_are_checked_before_writes( + monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None], allowed: bool +) -> None: + config_overrides(RBAC_ENABLED=True) + monkeypatch.setattr(DependenciesAnalysisService, "get_leaked_dependencies", lambda **kwargs: kwargs["dependencies"]) + monkeypatch.setattr( + "services.agent.roster_package_dependencies.RBACService.CheckAccess.check", lambda *_args, **_kwargs: allowed + ) + storage = _MemoryStorage() + with pytest.raises(RosterAgentPackageDependenciesMissingError if allowed else Forbidden) as failure: + RosterAgentPackageImporter(storage_backend=storage).import_package( + source=io.BytesIO(_package()), tenant_id="tenant-1", account=_account() + ) + assert storage.save_count == 0 + if allowed: + assert isinstance(failure.value, RosterAgentPackageDependenciesMissingError) + assert failure.value.data is not None + assert len(failure.value.data["leaked_dependencies"]) == 1 + + +def test_empty_dependencies_do_not_require_plugin_service(monkeypatch: pytest.MonkeyPatch) -> None: + from services.agent.roster_package_dependencies import check_package_dependencies + + def unavailable(**_kwargs) -> None: + raise OSError("plugin service unavailable") + + monkeypatch.setattr(DependenciesAnalysisService, "get_leaked_dependencies", unavailable) + check_package_dependencies(tenant_id="tenant-1", account=_account(), dependencies=[]) + + +@pytest.mark.parametrize("policy", [TenantPluginInstallPermission.NOBODY, TenantPluginInstallPermission.ADMINS]) +def test_missing_plugins_respect_workspace_install_policy( + monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], + sqlite_session_factory: sessionmaker[Session], + policy: TenantPluginInstallPermission, +) -> None: + config_overrides(RBAC_ENABLED=False) + with sqlite_session_factory() as session, session.begin(): + session.add( + TenantPluginPermission( + tenant_id="tenant-1", install_permission=policy, debug_permission=TenantPluginDebugPermission.NOBODY + ) + ) + monkeypatch.setattr(DependenciesAnalysisService, "get_leaked_dependencies", lambda **kwargs: kwargs["dependencies"]) + storage = _MemoryStorage() + with pytest.raises(Forbidden): + RosterAgentPackageImporter(storage_backend=storage).import_package( + source=io.BytesIO(_package()), tenant_id="tenant-1", account=_account() + ) + assert storage.save_count == 0 + + +def test_import_materializes_agent_resources_and_unpublished_draft( + monkeypatch: pytest.MonkeyPatch, + sqlite_session_factory: sessionmaker[Session], +) -> None: + storage = _MemoryStorage() + monkeypatch.setattr("services.app_service.initialize_agent_rbac_access", lambda **_kwargs: None) + monkeypatch.setattr("services.app_service.SystemFeatureService.is_webapp_auth_enabled", lambda: False) + + result = RosterAgentPackageImporter( + storage_backend=storage, + ).import_package( + source=io.BytesIO(_package()), + tenant_id="tenant-1", + account=_account(), + ) + + assert result.warnings == [] + assert len(storage.files) == 4 + with sqlite_session_factory() as session: + app = session.get(App, result.app_id) + agent = session.get(Agent, result.agent_id) + assert app is not None + assert app.name == "Imported Agent" + assert app.mode == "agent" + assert app.enable_site is False + assert app.enable_api is False + model_config = session.get(AppModelConfig, app.app_model_config_id) + assert model_config is not None + assert model_config.app_id == app.id + assert agent is not None + assert agent.source == AgentSource.IMPORTED + assert agent.scope == AgentScope.ROSTER + assert agent.active_config_is_published is False + draft = session.scalar(select(AgentConfigDraft).where(AgentConfigDraft.agent_id == agent.id)) + assert draft is not None + soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict) + assert [item.name for item in soul.config_skills] == ["config-skill", "workspace-skill"] + assert all(item.file_id and not item.is_missing for item in soul.config_skills) + assert [(item.name, item.file_kind) for item in soul.config_files] == [ + ("guide.pdf", "upload_file"), + ("notes.txt", "tool_file"), + ] + assert all(item.file_id and not item.is_missing for item in soul.config_files) + revision = session.scalar(select(AgentConfigRevision).where(AgentConfigRevision.agent_id == agent.id)) + assert revision is not None + assert revision.operation == AgentConfigRevisionOperation.IMPORT_PACKAGE + assert _count(session, ToolFile) == 3 + assert _count(session, UploadFile) == 1 + assert _count(session, Site) == 1 + assert _count(session, InstalledApp) == 1 + assert _count(session, AgentDebugConversation) == 1 + + +def test_import_rejects_binary_dependencies_before_side_effects( + sqlite_session_factory: sessionmaker[Session], +) -> None: + storage = _MemoryStorage() + importer = RosterAgentPackageImporter(storage_backend=storage) + + with pytest.raises(InvalidRosterAgentPackageError, match="manifest is invalid"): + importer.import_package( + source=io.BytesIO(_package(binary_dependency=True)), + tenant_id="tenant-1", + account=_account(), + ) + + assert storage.files == {} + with sqlite_session_factory() as session: + assert _count(session, App) == 0 + + +def test_import_marks_unavailable_knowledge_for_rebinding( + monkeypatch: pytest.MonkeyPatch, + sqlite_session_factory: sessionmaker[Session], +) -> None: + storage = _MemoryStorage() + monkeypatch.setattr(AppService, "finalize_created_app", lambda *_args, **_kwargs: None) + + result = RosterAgentPackageImporter(storage_backend=storage).import_package( + source=io.BytesIO(_package(missing_knowledge=True)), + tenant_id="tenant-1", + account=_account(), + ) + + assert [warning.code for warning in result.warnings] == ["agent_knowledge_unresolved"] + with sqlite_session_factory() as session: + draft = session.scalar(select(AgentConfigDraft).where(AgentConfigDraft.agent_id == result.agent_id)) + assert draft is not None + soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict) + dataset_id = soul.knowledge.sets[0].datasets[0].id + assert dataset_id is not None + assert dataset_id.startswith("missing-dataset-") + assert dataset_id != "missing-dataset-id" + + +def test_import_validates_all_file_limits_before_staging( + monkeypatch: pytest.MonkeyPatch, +) -> None: + storage = _MemoryStorage() + monkeypatch.setattr(FileService, "file_size_limit", lambda **_kwargs: 1) + + with pytest.raises(InvalidRosterAgentPackageError, match="file size limit"): + RosterAgentPackageImporter(storage_backend=storage).import_package( + source=io.BytesIO(_package()), + tenant_id="tenant-1", + account=_account(), + ) + + assert storage.files == {} + + +def test_import_rejects_duplicate_agent_name_before_staging( + sqlite_session_factory: sessionmaker[Session], +) -> None: + with sqlite_session_factory() as session, session.begin(): + session.add( + Agent( + tenant_id="tenant-1", + name="Imported Agent", + description="", + role="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + created_by=_account().id, + updated_by=_account().id, + ) + ) + storage = _MemoryStorage() + + with pytest.raises(AgentNameConflictError): + RosterAgentPackageImporter(storage_backend=storage).import_package( + source=io.BytesIO(_package()), + tenant_id="tenant-1", + account=_account(), + ) + + assert storage.files == {} + + +@pytest.mark.parametrize("fail_after_write", [False, True]) +def test_import_does_not_create_records_when_storage_fails( + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + fail_after_write: bool, +) -> None: + storage = _MemoryStorage(fail_save_at=None if fail_after_write else 2) + if fail_after_write: + save = storage.save + + def save_then_fail(filename: str, data: bytes) -> None: + save(filename, data) + if storage.save_count == 2: + raise OSError("storage write completed but acknowledgement failed") + + monkeypatch.setattr(storage, "save", save_then_fail) + + with pytest.raises(RosterAgentPackageResourceUnavailableError): + RosterAgentPackageImporter(storage_backend=storage).import_package( + source=io.BytesIO(_package()), + tenant_id="tenant-1", + account=_account(), + ) + + assert len(storage.files) == (2 if fail_after_write else 1) + assert storage.deleted == [] + with sqlite_session_factory() as session: + assert _count(session, App) == 0 + assert _count(session, ToolFile) == 0 + + +@pytest.mark.parametrize( + ("failure", "expected"), + [ + (AgentNameConflictError(), AgentNameConflictError), + (InvalidRosterAgentPackageError(), InvalidRosterAgentPackageError), + (RosterAgentPackageTooLargeError(), RosterAgentPackageTooLargeError), + (RosterAgentPackageResourceUnavailableError(), RosterAgentPackageResourceUnavailableError), + (IntegrityError("insert", {}, RuntimeError("roster_unique_name")), AgentNameConflictError), + (IntegrityError("insert", {}, RuntimeError("other constraint")), RosterAgentPackageImportFailedError), + (RuntimeError("unexpected failure"), RosterAgentPackageImportFailedError), + ], +) +def test_import_preserves_error_mapping( + monkeypatch: pytest.MonkeyPatch, failure: Exception, expected: type[Exception] +) -> None: + importer = RosterAgentPackageImporter(storage_backend=_MemoryStorage()) + + def fail(**_kwargs) -> None: + raise failure + + monkeypatch.setattr(importer, "_ensure_name_available", fail) + with pytest.raises(expected) as caught: + importer.import_package(source=io.BytesIO(_package()), tenant_id="tenant-1", account=_account()) + if isinstance(failure, expected): + assert caught.value is failure + else: + assert caught.value.__cause__ is failure + + +def test_import_preserves_file_records_when_agent_creation_fails( + monkeypatch: pytest.MonkeyPatch, + sqlite_session_factory: sessionmaker[Session], +) -> None: + storage = _MemoryStorage() + monkeypatch.setattr( + RosterAgentPackageImporter, + "_persist_import", + lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("database unavailable")), + ) + + with pytest.raises(RosterAgentPackageImportFailedError): + RosterAgentPackageImporter(storage_backend=storage).import_package( + source=io.BytesIO(_package()), + tenant_id="tenant-1", + account=_account(), + ) + + assert len(storage.files) == 4 + assert storage.deleted == [] + with sqlite_session_factory() as session: + assert _count(session, App) == 0 + assert _count(session, ToolFile) == 3 + assert _count(session, UploadFile) == 1 + uploaded = session.scalar(select(UploadFile)) + assert uploaded is not None + assert uploaded.used is False + + +@pytest.mark.parametrize("failed_phase", ["files", "agent"]) +def test_import_rolls_back_only_the_failed_transaction( + sqlite_session_factory: sessionmaker[Session], failed_phase: str +) -> None: + storage = _MemoryStorage() + + def fail_after_flush(session: Session, _flush_context) -> None: + target = ToolFile if failed_phase == "files" else Site + if any(isinstance(row, target) for row in session.new): + raise RuntimeError("database write failed after flush") + + event.listen(sqlite_session_factory.class_, "after_flush", fail_after_flush) + try: + with pytest.raises(RosterAgentPackageImportFailedError): + RosterAgentPackageImporter(storage_backend=storage).import_package( + source=io.BytesIO(_package()), tenant_id="tenant-1", account=_account() + ) + finally: + event.remove(sqlite_session_factory.class_, "after_flush", fail_after_flush) + + assert len(storage.files) == 4 + assert storage.deleted == [] + with sqlite_session_factory() as session: + for model in ( + App, + AppModelConfig, + Agent, + AgentConfigSnapshot, + AgentConfigRevision, + AgentConfigDraft, + AgentDebugConversation, + Site, + InstalledApp, + ): + assert _count(session, model) == 0 + assert _count(session, ToolFile) == (0 if failed_phase == "files" else 3) + assert _count(session, UploadFile) == (0 if failed_phase == "files" else 1) + if failed_phase == "agent": + uploaded = session.scalar(select(UploadFile)) + assert uploaded is not None + assert uploaded.used is False + + +@pytest.mark.usefixtures("sqlite_session_factory") +def test_uploads_do_not_hold_database_transactions(monkeypatch: pytest.MonkeyPatch) -> None: + sessions: list[Session] = [] + create_session = session_factory.create_session + + def track_session() -> Session: + session = create_session() + sessions.append(session) + return session + + storage = _MemoryStorage() + save = storage.save + + def save_outside_transaction(filename: str, data: bytes) -> None: + assert not any(session.in_transaction() for session in sessions) + save(filename, data) + + monkeypatch.setattr(session_factory, "create_session", track_session) + monkeypatch.setattr(storage, "save", save_outside_transaction) + monkeypatch.setattr(AppService, "finalize_created_app", lambda *_args, **_kwargs: None) + RosterAgentPackageImporter(storage_backend=storage).import_package( + source=io.BytesIO(_package()), tenant_id="tenant-1", account=_account() + ) + assert storage.save_count == 4 + + +def test_import_succeeds_when_post_commit_initialization_fails( + monkeypatch: pytest.MonkeyPatch, + sqlite_session_factory: sessionmaker[Session], + caplog: pytest.LogCaptureFixture, +) -> None: + storage = _MemoryStorage() + monkeypatch.setattr( + AppService, + "finalize_created_app", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("finalization failed")), + ) + result = RosterAgentPackageImporter(storage_backend=storage).import_package( + source=io.BytesIO(_package()), tenant_id="tenant-1", account=_account() + ) + assert len(storage.files) == 4 + assert storage.deleted == [] + assert result.warnings == [] + assert "post-commit initialization failed" in caplog.text + with sqlite_session_factory() as session: + assert session.get(App, result.app_id) is not None + assert session.get(Agent, result.agent_id) is not None + for model in ( + AppModelConfig, + AgentConfigSnapshot, + AgentConfigRevision, + AgentConfigDraft, + AgentDebugConversation, + Site, + InstalledApp, + UploadFile, + ): + assert _count(session, model) == 1 + assert _count(session, ToolFile) == 3 + uploaded = session.scalar(select(UploadFile)) + assert uploaded is not None + assert uploaded.used is True diff --git a/api/tests/unit_tests/services/agent/test_roster_package_service.py b/api/tests/unit_tests/services/agent/test_roster_package_service.py new file mode 100644 index 00000000000..eb07f2b8874 --- /dev/null +++ b/api/tests/unit_tests/services/agent/test_roster_package_service.py @@ -0,0 +1,1401 @@ +from __future__ import annotations + +import hashlib +import io +import zipfile +from collections.abc import Callable, Generator + +import pytest +import yaml +from flask import Flask, send_file +from pydantic import ValidationError +from sqlalchemy.orm import Session, sessionmaker + +from configs import dify_config +from models.agent import ( + Agent, + AgentConfigDraft, + AgentConfigDraftType, + AgentConfigSnapshot, + AgentKind, + AgentScope, + AgentSource, + AgentStatus, +) +from models.agent_config_entities import AgentSoulConfig +from models.enums import AppStatus +from models.model import App, AppMode, IconType +from models.skill import AgentSkillBindingSnapshot, Skill, SkillVersion, SkillVersionManifest +from models.tools import ToolFile +from services.agent import roster_package_exporter as roster_package_exporter_module +from services.agent.dsl_entities import AgentAppDsl, AgentPackage, AgentPackageMetadata, make_agent_app_dsl +from services.agent.errors import ( + InvalidRosterAgentPackageError, + RosterAgentPackageExportFailedError, + RosterAgentPackageImportFailedError, + RosterAgentPackageResourceUnavailableError, + RosterAgentPackageTooLargeError, +) +from services.agent.roster_package_entities import ( + ROSTER_AGENT_PACKAGE_FORMAT, + ROSTER_AGENT_PACKAGE_FORMAT_VERSION, + RosterAgentPackageApp, + RosterAgentPackageFile, + RosterAgentPackageManifest, + RosterAgentPackageSkill, +) +from services.agent.roster_package_exporter import RosterAgentPackageExporter +from services.agent.roster_package_reader import RosterAgentPackageReader +from services.app_dsl_service import AppDslService +from services.plugin.dependencies_analysis import DependenciesAnalysisService +from tests.unit_tests.config_override import apply_config_overrides + + +class _MemoryStorage: + def __init__(self, files: dict[str, bytes], *, before_read: Callable[[], None] | None = None) -> None: + self.files = files + self.before_read = before_read + self.read_count = 0 + self.bytes_yielded: dict[str, int] = {} + + def load_stream(self, filename: str) -> Generator[bytes, None, None]: + self.read_count += 1 + if self.before_read is not None: + self.before_read() + content = self.files[filename] + for offset in range(0, len(content), 7): + chunk = content[offset : offset + 7] + self.bytes_yielded[filename] = self.bytes_yielded.get(filename, 0) + len(chunk) + yield chunk + + +def _zip(members: dict[str, bytes]) -> bytes: + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path, payload in members.items(): + archive.writestr(path, payload) + return output.getvalue() + + +def _skill_archive(name: str = "research") -> bytes: + return _zip( + { + "SKILL.md": (f"---\nname: {name}\ndescription: Research skill.\n---\n\n# Research\n").encode(), + "scripts/run.py": b"print('ok')\n", + } + ) + + +def _package_app(soul: AgentSoulConfig | None = None) -> AgentAppDsl: + soul = soul or AgentSoulConfig.model_validate( + { + "config_skills": [{"name": "research", "file_id": "s_000001"}], + "config_files": [ + { + "name": "guide.pdf", + "file_kind": "tool_file", + "file_id": "f_000001", + } + ], + } + ) + return make_agent_app_dsl( + _app("app-1"), + package_ref="agent_1", + packages={"agent_1": AgentPackage(metadata=AgentPackageMetadata(name="Research Agent"), soul=soul)}, + dependencies=[], + ) + + +def _yaml_bytes(document: RosterAgentPackageManifest | AgentAppDsl) -> bytes: + return yaml.safe_dump(document.model_dump(mode="json", exclude_none=True)).encode() + + +def _app_member(payload: bytes, path: str = "app.yaml") -> RosterAgentPackageApp: + return RosterAgentPackageApp(path=path, size=len(payload), sha256=hashlib.sha256(payload).hexdigest()) + + +def _manifest(*, skill_payload: bytes, file_payload: bytes) -> RosterAgentPackageManifest: + return RosterAgentPackageManifest( + format=ROSTER_AGENT_PACKAGE_FORMAT, + format_version=ROSTER_AGENT_PACKAGE_FORMAT_VERSION, + apps=[_app_member(_yaml_bytes(_package_app()))], + skills=[ + RosterAgentPackageSkill( + id="s_000001", + scope="agent_config", + name="research", + path="s_000001.zip", + size=len(skill_payload), + sha256=hashlib.sha256(skill_payload).hexdigest(), + ) + ], + files=[ + RosterAgentPackageFile( + id="f_000001", + path="f_000001.pdf", + size=len(file_payload), + sha256=hashlib.sha256(file_payload).hexdigest(), + ) + ], + ) + + +def _app(app_id: str) -> App: + return App( + id=app_id, + tenant_id="tenant-1", + name="Research Agent", + description="", + mode=AppMode.AGENT, + icon_type=IconType.EMOJI, + icon="R", + icon_background="#FFFFFF", + status=AppStatus.NORMAL, + enable_site=False, + enable_api=False, + max_active_requests=None, + created_by="account-1", + ) + + +def _package_bytes( + manifest: RosterAgentPackageManifest, + *, + skill_payload: bytes, + file_payload: bytes, + extra_members: dict[str, bytes] | None = None, + app: AgentAppDsl | None = None, +) -> bytes: + app_payload = (extra_members or {}).get("app.yaml", _yaml_bytes(app or _package_app())) + manifest = manifest.model_copy(update={"apps": [_app_member(app_payload)]}) + return _zip( + { + "manifest.yaml": _yaml_bytes(manifest), + "app.yaml": app_payload, + "s_000001.zip": skill_payload, + "f_000001.pdf": file_payload, + **(extra_members or {}), + } + ) + + +def test_manifest_rejects_dangling_resource_references() -> None: + manifest = RosterAgentPackageManifest( + format=ROSTER_AGENT_PACKAGE_FORMAT, + format_version=ROSTER_AGENT_PACKAGE_FORMAT_VERSION, + apps=[_app_member(_yaml_bytes(_package_app()))], + ) + with pytest.raises(ValueError, match="config skill reference must resolve"): + manifest.validate_apps({"app.yaml": _package_app()}) + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"path": "wrong.zip"}, "skill path must be"), + ({"sha256": "not-a-digest"}, "sha256 must be"), + ], +) +def test_skill_resource_rejects_invalid_metadata(overrides: dict[str, object], message: str) -> None: + values: dict[str, object] = { + "id": "s_000001", + "scope": "agent_config", + "name": "research", + "path": "s_000001.zip", + "size": 1, + "sha256": "0" * 64, + } + values.update(overrides) + + with pytest.raises(ValidationError, match=message): + RosterAgentPackageSkill.model_validate(values) + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"path": "f_000001"}, "file path must start"), + ({"path": "f_000001.dir/file"}, "root-level archive member"), + ], +) +def test_file_resource_rejects_invalid_metadata(overrides: dict[str, object], message: str) -> None: + values: dict[str, object] = { + "id": "f_000001", + "path": "f_000001.pdf", + "size": 1, + "sha256": "0" * 64, + } + values.update(overrides) + + with pytest.raises(ValidationError, match=message): + RosterAgentPackageFile.model_validate(values) + + +def test_manifest_rejects_unsupported_soul_version() -> None: + manifest = _manifest(skill_payload=_skill_archive(), file_payload=b"pdf-content") + with pytest.raises(ValueError, match="unsupported Agent Soul schema version"): + manifest.validate_apps({"app.yaml": _package_app(AgentSoulConfig(schema_version=2))}) + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + pytest.param({"name": "duplicate"}, "resource ids must be unique", id="duplicate-id"), + pytest.param( + {"id": "s_000002", "path": "s_000002.zip", "scope": "workspace"}, + "skill names must be unique", + id="duplicate-localized-name", + ), + ], +) +def test_manifest_rejects_duplicate_resources(overrides: dict[str, str], message: str) -> None: + values = _manifest(skill_payload=_skill_archive(), file_payload=b"pdf-content").model_dump(mode="json") + values["skills"].append({**values["skills"][0], **overrides}) + + with pytest.raises(ValidationError, match=message): + RosterAgentPackageManifest.model_validate(values) + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ("skill_name", "config skill name must match"), + ("unreferenced_skill", "agent_config skill resources must be referenced"), + ("missing_file", "config file reference must resolve"), + ("unreferenced_file", "file resources must be referenced"), + ], +) +def test_manifest_rejects_inconsistent_resource_index(mutation: str, message: str) -> None: + skill_payload = _skill_archive() + file_payload = b"pdf-content" + values = _manifest(skill_payload=skill_payload, file_payload=file_payload).model_dump(mode="json") + app = _package_app() + if mutation == "skill_name": + values["skills"][0]["name"] = "renamed" + elif mutation == "unreferenced_skill": + app.package.soul.config_skills = [] + elif mutation == "missing_file": + values["files"] = list[dict[str, object]]() + else: + app.package.soul.config_files = [] + + with pytest.raises(ValueError, match=message): + RosterAgentPackageManifest.model_validate(values).validate_apps({"app.yaml": app}) + + +@pytest.mark.parametrize("missing_field", ["format", "format_version", "apps"]) +def test_reader_rejects_manifest_without_format_discriminator(missing_field: str) -> None: + skill_payload = _skill_archive() + file_payload = b"pdf-content" + manifest = _manifest(skill_payload=skill_payload, file_payload=file_payload).model_dump(mode="json") + manifest.pop(missing_field) + package = _zip( + { + "manifest.yaml": yaml.safe_dump(manifest).encode(), + "app.yaml": _yaml_bytes(_package_app()), + "s_000001.zip": skill_payload, + "f_000001.pdf": file_payload, + } + ) + + with pytest.raises(InvalidRosterAgentPackageError, match="manifest is invalid"): + RosterAgentPackageReader().read(io.BytesIO(package)) + + +@pytest.mark.parametrize("file_kind", ["upload_file", "tool_file"]) +def test_package_files_reuse_existing_dsl_file_kinds(file_kind: str) -> None: + skill_payload = _skill_archive() + file_payload = b"pdf-content" + manifest = _manifest(skill_payload=skill_payload, file_payload=file_payload) + app_data = _package_app().model_dump(mode="json") + app_data["agent_packages"]["agent_1"]["soul"]["config_files"][0]["file_kind"] = file_kind + app = AgentAppDsl.model_validate(app_data) + content = _package_bytes(manifest, app=app, skill_payload=skill_payload, file_payload=file_payload) + reader = RosterAgentPackageReader() + with reader.read(io.BytesIO(content)) as prepared: + assert prepared.apps["app.yaml"].package.soul.config_files[0].file_kind == file_kind + assert reader.read_member_bytes(prepared, "f_000001.pdf", max_bytes=len(file_payload)) == file_payload + + +@pytest.mark.parametrize( + ("error", "error_code", "status"), + [ + (InvalidRosterAgentPackageError(), "invalid_roster_agent_package", 400), + (RosterAgentPackageTooLargeError(), "roster_agent_package_too_large", 413), + (RosterAgentPackageExportFailedError(), "roster_agent_package_export_failed", 500), + (RosterAgentPackageImportFailedError(), "roster_agent_package_import_failed", 500), + ( + RosterAgentPackageResourceUnavailableError(), + "roster_agent_package_resource_unavailable", + 503, + ), + ], +) +def test_roster_package_errors_use_standard_http_payload( + error: ( + InvalidRosterAgentPackageError + | RosterAgentPackageTooLargeError + | RosterAgentPackageExportFailedError + | RosterAgentPackageImportFailedError + | RosterAgentPackageResourceUnavailableError + ), + error_code: str, + status: int, +) -> None: + assert error.error_code == error_code + assert error.code == status + assert error.data == {"code": error_code, "message": error.description, "status": status} + + +def test_preflight_validates_resources_and_accepts_ignored_signature() -> None: + skill_payload = _skill_archive() + file_payload = b"pdf-content" + manifest = _manifest(skill_payload=skill_payload, file_payload=file_payload) + package = _package_bytes( + manifest, + skill_payload=skill_payload, + file_payload=file_payload, + extra_members={"signature.sig": b"not-verified-in-v1"}, + ) + + with RosterAgentPackageReader().read(io.BytesIO(package)) as prepared: + assert prepared.manifest == manifest + assert prepared.members["s_000001.zip"].sha256 == hashlib.sha256(skill_payload).hexdigest() + assert prepared.members["f_000001.pdf"].size == len(file_payload) + + +def test_reader_accepts_app_larger_than_legacy_limit() -> None: + skill_payload = _skill_archive() + file_payload = b"pdf-content" + manifest = _manifest(skill_payload=skill_payload, file_payload=file_payload) + app = _package_app() + app.package.soul.prompt.system_prompt = "x" * (1024 * 1024) + package = _package_bytes(manifest, app=app, skill_payload=skill_payload, file_payload=file_payload) + + assert len(_yaml_bytes(app)) > 1024 * 1024 + with RosterAgentPackageReader().read(io.BytesIO(package)) as prepared: + assert prepared.apps["app.yaml"] == app + + +@pytest.mark.parametrize("path", ["manifest.yaml", "app.yaml"]) +def test_reader_rejects_oversized_documents(path: str, monkeypatch: pytest.MonkeyPatch) -> None: + skill_payload = _skill_archive() + file_payload = b"pdf-content" + manifest = _manifest(skill_payload=skill_payload, file_payload=file_payload) + limit = 4096 + apply_config_overrides(monkeypatch, AGENT_PACKAGE_MAX_MANIFEST_BYTES=limit) + package = _package_bytes( + manifest, + skill_payload=skill_payload, + file_payload=file_payload, + extra_members={path: b"x" * (limit + 1)}, + ) + with pytest.raises(RosterAgentPackageTooLargeError, match=path): + RosterAgentPackageReader().read(io.BytesIO(package)) + + +def test_reader_applies_the_whole_package_size_limit(monkeypatch: pytest.MonkeyPatch) -> None: + skill_payload = _skill_archive() + file_payload = b"pdf-content" + manifest = _manifest(skill_payload=skill_payload, file_payload=file_payload) + package = _package_bytes(manifest, skill_payload=skill_payload, file_payload=file_payload) + apply_config_overrides(monkeypatch, AGENT_PACKAGE_MAX_BYTES=len(package) - 1) + + with pytest.raises(RosterAgentPackageTooLargeError): + RosterAgentPackageReader().read(io.BytesIO(package)) + + +def test_preflight_rejects_tampered_payload() -> None: + skill_payload = _skill_archive() + file_payload = b"pdf-content" + manifest = _manifest(skill_payload=skill_payload, file_payload=file_payload) + package = _package_bytes( + manifest, + skill_payload=skill_payload, + file_payload=b"tampered", + ) + + with pytest.raises(InvalidRosterAgentPackageError, match="failed integrity checks"): + RosterAgentPackageReader().read(io.BytesIO(package)) + + +def test_preflight_rejects_members_not_declared_by_manifest() -> None: + skill_payload = _skill_archive() + file_payload = b"pdf-content" + manifest = _manifest(skill_payload=skill_payload, file_payload=file_payload) + package = _package_bytes( + manifest, + skill_payload=skill_payload, + file_payload=file_payload, + extra_members={"undeclared.txt": b"unexpected"}, + ) + + with pytest.raises(InvalidRosterAgentPackageError, match="members do not match"): + RosterAgentPackageReader().read(io.BytesIO(package)) + + +def test_preflight_rejects_unsafe_member_path() -> None: + package = _zip({"../manifest.yaml": b"{}"}) + + with pytest.raises(InvalidRosterAgentPackageError, match="unsafe path"): + RosterAgentPackageReader().read(io.BytesIO(package)) + + +def test_high_compression_ratio_uses_absolute_package_limit(monkeypatch: pytest.MonkeyPatch) -> None: + skill_payload = _skill_archive() + file_payload = b"x" * (1024 * 1024) + manifest = _manifest(skill_payload=skill_payload, file_payload=file_payload) + package = _package_bytes(manifest, skill_payload=skill_payload, file_payload=file_payload) + reader = RosterAgentPackageReader() + with reader.read(io.BytesIO(package)) as prepared: + assert prepared.members["f_000001.pdf"].size == len(file_payload) + apply_config_overrides(monkeypatch, AGENT_PACKAGE_MAX_BYTES=128 * 1024) + with pytest.raises(RosterAgentPackageTooLargeError, match="uncompressed size"): + reader.read(io.BytesIO(package)) + + +@pytest.mark.parametrize("path", ["manifest.yaml", "app.yaml"]) +@pytest.mark.parametrize( + "payload", + [ + b"format: dify.roster-agent\nformat: dify.roster-agent\n", + b"metadata:\n name: first\n name: second\n", + b"value: &value [1]\ncopy: *value\n", + b"value: !!python/object/apply:os.system ['false']\n", + b"[invalid", + b"\xff", + b"null", + b"[]", + b"1: value", + b"---\n{}\n---\n{}", + ], +) +def test_preflight_rejects_invalid_yaml_documents(path: str, payload: bytes) -> None: + skill_payload = _skill_archive() + file_payload = b"pdf-content" + package = _package_bytes( + _manifest(skill_payload=skill_payload, file_payload=file_payload), + skill_payload=skill_payload, + file_payload=file_payload, + extra_members={path: payload}, + ) + with pytest.raises(InvalidRosterAgentPackageError, match=f"{path.removesuffix('.yaml')} is invalid"): + RosterAgentPackageReader().read(io.BytesIO(package)) + + +@pytest.mark.parametrize("missing_path", ["manifest.yaml", "app.yaml"]) +def test_reader_requires_both_yaml_documents(missing_path: str) -> None: + documents = { + "manifest.yaml": _yaml_bytes(_manifest(skill_payload=_skill_archive(), file_payload=b"pdf-content")), + "app.yaml": _yaml_bytes(_package_app()), + } + del documents[missing_path] + with pytest.raises(InvalidRosterAgentPackageError, match=f"missing {missing_path}"): + RosterAgentPackageReader().read(io.BytesIO(_zip(documents))) + + +@pytest.mark.parametrize( + "mutation", + [ + "legacy_app", + "wrong_kind", + "wrong_mode", + "missing_ref", + "dangling_ref", + "extra_package", + "invalid_version", + "future_version", + ], +) +def test_reader_rejects_invalid_agent_app_dsl(mutation: str) -> None: + app = _package_app().model_dump(mode="json") + if mutation == "legacy_app": + app = { + "metadata": app["agent_packages"]["agent_1"]["metadata"], + "soul": app["agent_packages"]["agent_1"]["soul"], + } + elif mutation == "wrong_kind": + app["kind"] = "workflow" + elif mutation == "wrong_mode": + app["app"]["mode"] = "workflow" + elif mutation == "missing_ref": + app["agent"] = dict[str, str]() + elif mutation == "dangling_ref": + app["agent"]["package_ref"] = "agent_2" + elif mutation == "extra_package": + app["agent_packages"]["agent_2"] = app["agent_packages"]["agent_1"] + else: + app["version"] = "invalid" if mutation == "invalid_version" else "999.0.0" + skill_payload = _skill_archive() + file_payload = b"pdf-content" + package = _package_bytes( + _manifest(skill_payload=skill_payload, file_payload=file_payload), + skill_payload=skill_payload, + file_payload=file_payload, + extra_members={"app.yaml": yaml.safe_dump(app).encode()}, + ) + with pytest.raises(InvalidRosterAgentPackageError, match="app is invalid"): + RosterAgentPackageReader().read(io.BytesIO(package)) + + +def test_reader_rejects_cross_document_reference_mismatch() -> None: + skill_payload = _skill_archive() + file_payload = b"pdf-content" + app = _package_app() + app.package.soul.config_skills[0].file_id = "s_000002" + package = _package_bytes( + _manifest(skill_payload=skill_payload, file_payload=file_payload), + app=app, + skill_payload=skill_payload, + file_payload=file_payload, + ) + with pytest.raises(InvalidRosterAgentPackageError, match="app is invalid"): + RosterAgentPackageReader().read(io.BytesIO(package)) + + +def test_preflight_records_invalid_skill_payload() -> None: + skill_payload = _zip({"README.md": b"missing skill manifest"}) + file_payload = b"pdf-content" + manifest = _manifest(skill_payload=skill_payload, file_payload=file_payload) + package = _package_bytes(manifest, skill_payload=skill_payload, file_payload=file_payload) + + with RosterAgentPackageReader().read(io.BytesIO(package)) as prepared: + assert prepared.invalid_skills == {"s_000001": "missing_skill_md"} + + +def test_preflight_rejects_oversized_skill_before_materializing(monkeypatch: pytest.MonkeyPatch) -> None: + apply_config_overrides(monkeypatch, UPLOAD_SKILL_FILE_SIZE_LIMIT=0) + skill_payload = _skill_archive() + file_payload = b"pdf-content" + manifest = _manifest(skill_payload=skill_payload, file_payload=file_payload) + package = _package_bytes(manifest, skill_payload=skill_payload, file_payload=file_payload) + + with pytest.raises(RosterAgentPackageTooLargeError) as exc_info: + RosterAgentPackageReader().read(io.BytesIO(package)) + + assert exc_info.value.error_code == "roster_agent_package_too_large" + assert exc_info.value.code == 413 + + +def test_preflight_rejects_aggregate_nested_skill_expansion(monkeypatch: pytest.MonkeyPatch) -> None: + skill_payloads = { + "s_000001.zip": _zip( + { + "SKILL.md": b"---\nname: research-one\ndescription: Research skill.\n---\n", + "data.bin": b"x" * 8192, + } + ), + "s_000002.zip": _zip( + { + "SKILL.md": b"---\nname: research-two\ndescription: Research skill.\n---\n", + "data.bin": b"x" * 8192, + } + ), + } + soul = AgentSoulConfig.model_validate( + { + "config_skills": [ + {"name": "research-one", "file_id": "s_000001"}, + {"name": "research-two", "file_id": "s_000002"}, + ] + } + ) + manifest = RosterAgentPackageManifest( + format=ROSTER_AGENT_PACKAGE_FORMAT, + format_version=ROSTER_AGENT_PACKAGE_FORMAT_VERSION, + apps=[_app_member(_yaml_bytes(_package_app(soul)))], + skills=[ + RosterAgentPackageSkill( + id=path.removesuffix(".zip"), + scope="agent_config", + name=f"research-{index}", + path=path, + size=len(payload), + sha256=hashlib.sha256(payload).hexdigest(), + ) + for index, (path, payload) in zip(("one", "two"), skill_payloads.items()) + ], + ) + package = _zip( + { + "manifest.yaml": _yaml_bytes(manifest), + "app.yaml": _yaml_bytes(_package_app(soul)), + **skill_payloads, + } + ) + nested_limit = 12 * 1024 + assert len(package) < nested_limit + apply_config_overrides(monkeypatch, AGENT_PACKAGE_MAX_BYTES=nested_limit) + + with pytest.raises(RosterAgentPackageTooLargeError, match="nested Skill contents"): + RosterAgentPackageReader().read(io.BytesIO(package)) + + exporter = RosterAgentPackageExporter(storage_backend=_MemoryStorage(skill_payloads)) + sources = [ + roster_package_exporter_module._SkillSource( + path=item.path, + storage_key=item.path, + id=item.id, + scope=item.scope, + name=item.name, + display_name=None, + description="Research skill.", + priority=None, + audit_ref="source", + ) + for item in manifest.skills + ] + with pytest.raises(RosterAgentPackageTooLargeError, match="nested Skill contents"): + exporter._build_archive(app=_package_app(soul), skill_sources=sources, file_sources=[]) + + +@pytest.mark.parametrize("failure", ["checksum", "size", "name", "crc"]) +def test_reader_records_unusable_skills_and_preserves_other_members(failure: str) -> None: + skill_payload = _skill_archive("different" if failure == "name" else "research") + file_payload = b"pdf-content" + manifest = _manifest(skill_payload=skill_payload, file_payload=file_payload) + if failure == "checksum": + manifest.skills[0].sha256 = "0" * 64 + elif failure == "size": + manifest.skills[0].size += 1 + package = _package_bytes(manifest, skill_payload=skill_payload, file_payload=file_payload) + if failure == "crc": + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED) as archive: + archive.writestr("manifest.yaml", _yaml_bytes(manifest)) + archive.writestr("app.yaml", _yaml_bytes(_package_app())) + archive.writestr("s_000001.zip", skill_payload) + archive.writestr("f_000001.pdf", file_payload) + package = output.getvalue().replace(skill_payload, b"X" + skill_payload[1:]) + reader = RosterAgentPackageReader() + with reader.read(io.BytesIO(package)) as prepared: + expected = { + "checksum": "checksum_mismatch", + "size": "archive_integrity_failed", + "name": "skill_name_mismatch", + "crc": "archive_integrity_failed", + } + assert prepared.invalid_skills == {"s_000001": expected[failure]} + assert reader.read_member_bytes(prepared, "f_000001.pdf", max_bytes=len(file_payload)) == file_payload + + +@pytest.mark.parametrize("rewrite_manifest", [False, True]) +def test_member_read_rechecks_limit_and_integrity_after_preflight(rewrite_manifest: bool) -> None: + skill_payload = _skill_archive() + file_payload = b"pdf-content" + manifest = _manifest(skill_payload=skill_payload, file_payload=file_payload) + package = _package_bytes(manifest, skill_payload=skill_payload, file_payload=file_payload) + reader = RosterAgentPackageReader() + with reader.read(io.BytesIO(package)) as prepared: + assert reader.read_member_bytes(prepared, "s_000001.zip", max_bytes=len(skill_payload)) == skill_payload + with pytest.raises(RosterAgentPackageTooLargeError): + reader.read_member_bytes(prepared, "f_000001.pdf", max_bytes=len(file_payload) - 1) + with pytest.raises(InvalidRosterAgentPackageError, match="unavailable"): + reader.read_member_bytes(prepared, "unknown", max_bytes=100) + modified = _package_bytes(manifest, skill_payload=skill_payload, file_payload=b"bad-content") + prepared.archive.seek(0) + prepared.archive.truncate() + prepared.archive.write(modified) + if rewrite_manifest: + prepared.manifest.files[0].sha256 = hashlib.sha256(b"bad-content").hexdigest() + with pytest.raises(InvalidRosterAgentPackageError, match="integrity checks"): + reader.read_member_bytes(prepared, "f_000001.pdf", max_bytes=len(file_payload)) + + +@pytest.mark.parametrize( + ("case", "error_type", "message"), + [ + ("missing_manifest", RosterAgentPackageExportFailedError, "unusable Skill"), + ("name_mismatch", RosterAgentPackageExportFailedError, "unusable Skill"), + ("size_limit", RosterAgentPackageTooLargeError, "exceeds the size limit"), + ], +) +def test_export_rejects_unusable_or_oversized_skill_payload( + case: str, + error_type: type[RosterAgentPackageExportFailedError | RosterAgentPackageTooLargeError], + message: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + payload = _zip({"README.md": b"missing skill manifest"}) if case == "missing_manifest" else _skill_archive("other") + if case == "size_limit": + apply_config_overrides(monkeypatch, UPLOAD_SKILL_FILE_SIZE_LIMIT=0) + source = roster_package_exporter_module._SkillSource( + path="s_000001.zip", + storage_key="skill", + id="s_000001", + scope="agent_config", + name="research", + display_name=None, + description="Research skill.", + priority=None, + audit_ref="source", + ) + app = _package_app() + app.package.soul.config_files = [] + exporter = RosterAgentPackageExporter(storage_backend=_MemoryStorage({"skill": payload})) + with pytest.raises(error_type, match=message): + exporter._build_archive(app=app, skill_sources=[source], file_sources=[]) + + +@pytest.mark.parametrize("max_bytes", [0, 8]) +def test_read_member_enforces_actual_output_limit(max_bytes: int) -> None: + package = _zip({"payload.bin": b"x" * 32}) + + with zipfile.ZipFile(io.BytesIO(package)) as archive: + with pytest.raises(RosterAgentPackageTooLargeError) as exc_info: + RosterAgentPackageReader._read_member( + archive, + archive.getinfo("payload.bin"), + collect=True, + max_bytes=max_bytes, + expected_size=32, + ) + + assert exc_info.value.error_code == "roster_agent_package_too_large" + + +@pytest.mark.parametrize("expected_size", [8, 64]) +def test_read_member_validates_actual_size_while_streaming(expected_size: int) -> None: + package = _zip({"payload.bin": b"x" * 32}) + + with zipfile.ZipFile(io.BytesIO(package)) as archive: + with pytest.raises(InvalidRosterAgentPackageError, match="failed integrity checks"): + RosterAgentPackageReader._read_member( + archive, + archive.getinfo("payload.bin"), + collect=False, + max_bytes=32, + expected_size=expected_size, + ) + + +@pytest.mark.parametrize("collect", [False, True]) +def test_read_empty_member_with_zero_budget(collect: bool) -> None: + with zipfile.ZipFile(io.BytesIO(_zip({"empty.bin": b""}))) as archive: + payload, digest, size = RosterAgentPackageReader._read_member( + archive, archive.getinfo("empty.bin"), collect=collect, max_bytes=0, expected_size=0 + ) + assert payload == b"" + assert digest == hashlib.sha256(b"").hexdigest() + assert size == 0 + + +def test_export_accepts_legacy_agent_and_preserves_caller_transaction( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], +) -> None: + skill_payload = _skill_archive() + file_payload = b"0" * (1024 * 1024) + read_sessions: list[Session] = [] + + def create_read_session() -> Session: + session = sqlite_session_factory() + read_sessions.append(session) + return session + + def assert_read_session_closed() -> None: + assert read_sessions + assert not read_sessions[-1].in_transaction() + + monkeypatch.setattr(roster_package_exporter_module.session_factory, "create_session", create_read_session) + + storage = _MemoryStorage( + {"tools/skill.zip": skill_payload, "tools/guide.pdf": file_payload}, + before_read=assert_read_session_closed, + ) + skill_file = ToolFile( + user_id="account-1", + tenant_id="tenant-1", + conversation_id=None, + file_key="tools/skill.zip", + mimetype="application/zip", + name="research.zip", + size=len(skill_payload), + original_url=None, + ) + skill_file.id = "11111111-1111-4111-8111-111111111111" + config_file = ToolFile( + user_id="account-1", + tenant_id="tenant-1", + conversation_id=None, + file_key="tools/guide.pdf", + mimetype="application/pdf", + name="guide.pdf", + size=len(file_payload), + original_url=None, + ) + config_file.id = "22222222-2222-4222-8222-222222222222" + published_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "published"}}) + draft_soul = AgentSoulConfig.model_validate( + { + "model": { + "plugin_id": "langgenius/openai", + "model_provider": "langgenius/openai/openai", + "model": "gpt-test", + "credential_ref": {"type": "provider", "id": "private-model"}, + }, + "tools": { + "dify_tools": [ + { + "provider_id": "langgenius/google/google", + "provider_type": "plugin", + "tool_name": "search", + "credential_type": "api-key", + "credential_ref": {"type": "tool", "id": "private-tool"}, + "runtime_parameters": {"query": "retain", "api_key": "private-value"}, + } + ], + }, + "prompt": {"system_prompt": "draft"}, + "config_skills": [ + {"name": "research", "file_id": skill_file.id}, + {"name": "missing-skill", "file_id": "", "is_missing": True}, + ], + "config_files": [ + {"name": "guide.pdf", "file_kind": "tool_file", "file_id": config_file.id}, + {"name": "missing.txt", "file_kind": "upload_file", "file_id": "", "is_missing": True}, + ], + } + ) + agent = Agent( + tenant_id="tenant-1", + name="Research Agent", + description="description", + role="researcher", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + app_id="33333333-3333-4333-8333-333333333333", + backing_app_id=None, + status=AgentStatus.ACTIVE, + created_by="account-1", + updated_by="account-1", + ) + agent.id = "44444444-4444-4444-8444-444444444444" + snapshot = AgentConfigSnapshot( + tenant_id="tenant-1", + agent_id=agent.id, + version=1, + config_snapshot=published_soul, + created_by="account-1", + ) + snapshot.id = "55555555-5555-4555-8555-555555555555" + agent.active_config_snapshot_id = snapshot.id + draft = AgentConfigDraft( + tenant_id="tenant-1", + agent_id=agent.id, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", + base_snapshot_id=snapshot.id, + config_snapshot=draft_soul, + created_by="account-1", + updated_by="account-1", + ) + sqlite_session.add_all( + [_app("33333333-3333-4333-8333-333333333333"), skill_file, config_file, agent, snapshot, draft] + ) + sqlite_session.commit() + + caller_owned_file = ToolFile( + user_id="account-1", + tenant_id="tenant-1", + conversation_id=None, + file_key="tools/caller-owned.txt", + mimetype="text/plain", + name="caller-owned.txt", + size=1, + original_url=None, + ) + caller_owned_file.id = "77777777-7777-4777-8777-777777777777" + sqlite_session.add(caller_owned_file) + sqlite_session.flush() + assert caller_owned_file not in sqlite_session.new + + exporter = RosterAgentPackageExporter( + storage_backend=storage, + dependency_provider=lambda _tenant_id, _dependencies: [], + ) + monkeypatch.setattr(DependenciesAnalysisService, "generate_dependencies", lambda **_kwargs: []) + app_model = sqlite_session.get(App, agent.app_id) + assert app_model is not None + standalone_dsl = yaml.safe_load(AppDslService.export_dsl(app_model, session=sqlite_session)) + with exporter.export(tenant_id="tenant-1", agent_id=agent.id) as exported: + archive_bytes = exported.archive.read() + assert exported.filename == "research-agent.ifpkg" + with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive: + assert set(archive.namelist()) == {"manifest.yaml", "app.yaml", "s_000001.zip", "f_000001.pdf"} + manifest_data = yaml.safe_load(archive.read("manifest.yaml")) + app_data = yaml.safe_load(archive.read("app.yaml")) + exported_app = AgentAppDsl.model_validate(app_data) + exported_soul = exported_app.package.soul + assert exported_soul.model is not None + assert exported_soul.model.credential_ref is None + assert exported_soul.tools.dify_tools[0].credential_ref is None + assert exported_soul.tools.dify_tools[0].runtime_parameters == {"query": "retain", "api_key": None} + assert "private-" not in exported_app.model_dump_json() + assert draft_soul.model is not None + assert draft_soul.model.credential_ref is not None + assert draft_soul.tools.dify_tools[0].runtime_parameters["api_key"] == "private-value" + assert exported_app.package.soul.prompt.system_prompt == "draft" + assert exported_app.package.soul.config_skills[0].file_id == "s_000001" + assert exported_app.package.soul.config_files[0].file_id == "f_000001" + assert exported_app.package.soul.config_files[0].mime_type == "application/pdf" + assert exported_app.package.soul.config_skills[1].is_missing is True + assert exported_app.package.soul.config_skills[1].file_id == "" + assert exported_app.package.soul.config_files[1].is_missing is True + assert exported_app.package.soul.config_files[1].file_id == "" + assert set(manifest_data) == {"format", "format_version", "audit", "apps", "skills", "files"} + assert set(manifest_data["files"][0]) == { + "id", + "path", + "size", + "sha256", + "audit", + } + assert manifest_data["apps"] == [ + { + "path": "app.yaml", + "size": len(archive.read("app.yaml")), + "sha256": hashlib.sha256(archive.read("app.yaml")).hexdigest(), + } + ] + assert set(app_data) == {"version", "kind", "app", "agent", "agent_packages", "dependencies"} + assert app_data["kind"] == "app" + assert app_data["app"]["mode"] == "agent" + assert app_data["app"]["icon"] == "R" + assert app_data["agent"]["package_ref"] == "agent_1" + assert "audit" not in app_data["agent_packages"]["agent_1"]["metadata"] + for key in ("version", "kind", "app", "agent", "dependencies"): + assert app_data[key] == standalone_dsl[key] + standalone_package = AgentPackage.model_validate(standalone_dsl["agent_packages"]["agent_1"]) + assert exported_app.package.metadata == standalone_package.metadata + assert standalone_package.soul.config_skills[0].is_missing is True + assert standalone_package.soul.config_skills[0].file_id == "" + assert standalone_package.soul.config_files[0].is_missing is True + assert {item.name for item in exported_app.package.omitted_assets} == {"missing-skill", "missing.txt"} + assert {item.name for item in standalone_package.omitted_assets} == { + "research", + "guide.pdf", + "missing-skill", + "missing.txt", + } + assert archive.read("s_000001.zip") == skill_payload + assert all(info.compress_type == zipfile.ZIP_STORED for info in archive.infolist()) + assert "signature.sig" not in archive.namelist() + + with RosterAgentPackageReader().read(io.BytesIO(archive_bytes)) as prepared: + assert prepared.apps["app.yaml"].package.soul.prompt.system_prompt == "draft" + + assert sqlite_session.in_transaction() + assert sqlite_session.get(ToolFile, caller_owned_file.id) is caller_owned_file + + max_entries = dify_config.AGENT_PACKAGE_MAX_ENTRIES + max_manifest_bytes = dify_config.AGENT_PACKAGE_MAX_MANIFEST_BYTES + apply_config_overrides(monkeypatch, AGENT_PACKAGE_MAX_ENTRIES=4) + with exporter.export(tenant_id="tenant-1", agent_id=agent.id) as exported: + with zipfile.ZipFile(exported.archive) as archive: + assert len(archive.infolist()) == 4 + + reads_before_limit_check = storage.read_count + apply_config_overrides(monkeypatch, AGENT_PACKAGE_MAX_ENTRIES=3) + with pytest.raises(RosterAgentPackageTooLargeError): + exporter.export(tenant_id="tenant-1", agent_id=agent.id) + assert storage.read_count == reads_before_limit_check + apply_config_overrides(monkeypatch, AGENT_PACKAGE_MAX_ENTRIES=max_entries) + + apply_config_overrides(monkeypatch, AGENT_PACKAGE_MAX_MANIFEST_BYTES=1) + with pytest.raises(RosterAgentPackageTooLargeError): + exporter.export(tenant_id="tenant-1", agent_id=agent.id) + apply_config_overrides(monkeypatch, AGENT_PACKAGE_MAX_MANIFEST_BYTES=max_manifest_bytes) + + file_bytes_before_limit_check = storage.bytes_yielded["tools/guide.pdf"] + apply_config_overrides(monkeypatch, AGENT_PACKAGE_MAX_BYTES=len(skill_payload) + 1) + with pytest.raises(RosterAgentPackageTooLargeError): + exporter.export(tenant_id="tenant-1", agent_id=agent.id) + assert storage.bytes_yielded["tools/guide.pdf"] - file_bytes_before_limit_check == 7 + + +def test_export_uses_current_workspace_skill_bindings( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, +) -> None: + workspace_payload = _skill_archive("legacy-published") + storage = _MemoryStorage({"tools/workspace.zip": workspace_payload}) + monkeypatch.setattr( + "services.skill_management_service.SkillManagementService._load_tool_file_bytes", + staticmethod(lambda **_kwargs: workspace_payload), + ) + archive_file = ToolFile( + user_id="account-1", + tenant_id="tenant-1", + conversation_id=None, + file_key="tools/workspace.zip", + mimetype="application/zip", + name="workspace-research.zip", + size=len(workspace_payload), + original_url=None, + ) + archive_file.id = "11111111-1111-4111-8111-111111111111" + skill = Skill( + tenant_id="tenant-1", + name="renamed-workspace", + display_name="Renamed Workspace", + description="Renamed description.", + created_by="account-1", + updated_by="account-1", + ) + skill.id = "22222222-2222-4222-8222-222222222222" + version = SkillVersion( + skill_id=skill.id, + version_number=1, + version_name="1.0", + publish_note="", + manifest=SkillVersionManifest( + name=None, + display_name=None, + description=None, + files=[], + ), + archive_tool_file_id=archive_file.id, + hash_code="hash-code", + archive_size=len(workspace_payload), + published_by="account-1", + ) + version.id = "33333333-3333-4333-8333-333333333333" + skill.latest_published_version_id = version.id + agent = Agent( + tenant_id="tenant-1", + name="Research Agent", + description="", + role="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + app_id="44444444-4444-4444-8444-444444444444", + backing_app_id="44444444-4444-4444-8444-444444444444", + status=AgentStatus.ACTIVE, + created_by="account-1", + updated_by="account-1", + ) + agent.id = "55555555-5555-4555-8555-555555555555" + snapshot = AgentConfigSnapshot( + tenant_id="tenant-1", + agent_id=agent.id, + version=1, + config_snapshot=AgentSoulConfig.model_validate( + {"config_skills": [{"name": "legacy-published", "file_id": "", "is_missing": True}]} + ), + created_by="account-1", + ) + snapshot.id = "66666666-6666-4666-8666-666666666666" + agent.active_config_snapshot_id = snapshot.id + binding = AgentSkillBindingSnapshot( + tenant_id="tenant-1", + agent_id=agent.id, + config_snapshot_id=snapshot.id, + skill_id=skill.id, + priority=0, + created_by="account-1", + ) + sqlite_session.add_all( + [_app("44444444-4444-4444-8444-444444444444"), archive_file, skill, version, agent, snapshot, binding] + ) + sqlite_session.commit() + + exporter = RosterAgentPackageExporter( + storage_backend=storage, + dependency_provider=lambda _tenant_id, _dependencies: [], + ) + with exporter.export(tenant_id="tenant-1", agent_id=agent.id) as exported: + with RosterAgentPackageReader().read(exported.archive) as prepared: + assert len(prepared.manifest.skills) == 1 + assert prepared.manifest.skills[0].scope == "workspace" + assert set(prepared.manifest.skills[0].model_dump()) == { + "id", + "path", + "size", + "sha256", + "audit", + "scope", + "name", + } + assert prepared.apps["app.yaml"].package.workspace_skills[0].priority == 0 + assert prepared.manifest.skills[0].name == "legacy-published" + assert prepared.apps["app.yaml"].package.workspace_skills[0].display_name == "Legacy Published" + assert prepared.apps["app.yaml"].package.workspace_skills[0].description == "Research skill." + assert prepared.apps["app.yaml"].package.soul.config_skills[0].is_missing is True + assert prepared.manifest.skills[0].sha256 == hashlib.sha256(workspace_payload).hexdigest() + + draft = AgentConfigDraft( + tenant_id="tenant-1", + agent_id=agent.id, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", + base_snapshot_id=snapshot.id, + config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "current draft"}}), + created_by="account-1", + updated_by="account-1", + ) + sqlite_session.add(draft) + sqlite_session.commit() + + with exporter.export(tenant_id="tenant-1", agent_id=agent.id) as exported: + with RosterAgentPackageReader().read(exported.archive) as prepared: + assert prepared.apps["app.yaml"].package.soul.prompt.system_prompt == "current draft" + assert prepared.manifest.skills == [] + + +def test_manifest_yaml_is_strict() -> None: + skill_payload = _skill_archive() + file_payload = b"pdf-content" + manifest = _manifest(skill_payload=skill_payload, file_payload=file_payload).model_dump(mode="json") + manifest["unexpected"] = True + + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + RosterAgentPackageManifest.model_validate(yaml.safe_load(yaml.safe_dump(manifest))) + + +@pytest.mark.parametrize( + ("case", "message"), + [ + ("empty", "package is empty"), + ("entries", "too many members"), + ("duplicate", "duplicate member paths"), + ("directory", "root-level files"), + ("control_character", "unsafe path"), + ("symlink", "symbolic links"), + ("encrypted", "encrypted members"), + ("compression", "unsupported compression"), + ], +) +def test_reader_rejects_invalid_container_before_parsing_manifest( + case: str, message: str, monkeypatch: pytest.MonkeyPatch +) -> None: + if case == "empty": + content = _zip({}) + elif case == "entries": + apply_config_overrides(monkeypatch, AGENT_PACKAGE_MAX_ENTRIES=1) + content = _zip({"manifest.yaml": b"", "app.yaml": b""}) + elif case == "duplicate": + content = _zip({"manifest.yaml": b"", "MANIFEST.YAML": b""}) + elif case == "directory": + content = _zip({"directory/": b""}) + elif case == "control_character": + content = _zip({"bad\nname": b""}) + elif case == "symlink": + output = io.BytesIO() + info = zipfile.ZipInfo("manifest.yaml") + info.create_system = 3 + info.external_attr = 0o120777 << 16 + with zipfile.ZipFile(output, "w") as archive: + archive.writestr(info, b"target.yaml") + content = output.getvalue() + elif case == "encrypted": + data = bytearray(_zip({"manifest.yaml": b"manifest"})) + directory = data.index(b"PK\x01\x02") + data[directory + 8] |= 1 + content = bytes(data) + else: + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_BZIP2) as archive: + archive.writestr("manifest.yaml", b"manifest") + content = output.getvalue() + with pytest.raises(InvalidRosterAgentPackageError, match=message): + RosterAgentPackageReader().read(io.BytesIO(content)) + + +def test_member_read_reports_closed_prepared_archive() -> None: + skill_payload = _skill_archive() + file_payload = b"pdf-content" + content = _package_bytes( + _manifest(skill_payload=skill_payload, file_payload=file_payload), + skill_payload=skill_payload, + file_payload=file_payload, + ) + reader = RosterAgentPackageReader() + prepared = reader.read(io.BytesIO(content)) + prepared.close() + with pytest.raises(InvalidRosterAgentPackageError, match="member is unavailable"): + reader.read_member_bytes(prepared, "f_000001.pdf", max_bytes=len(file_payload)) + + +def test_export_download_closes_owned_archive(app: Flask) -> None: + exported = RosterAgentPackageExporter()._build_archive( + app=_package_app(AgentSoulConfig()), skill_sources=[], file_sources=[] + ) + with app.test_request_context("/"): + response = send_file( + exported.archive, mimetype="application/zip", as_attachment=True, download_name=exported.filename + ) + response.call_on_close(exported.close) + try: + content = b"".join(response.iter_encoded()) + assert len(content) == exported.size + assert exported.filename in response.headers["Content-Disposition"] + with zipfile.ZipFile(io.BytesIO(content)) as archive: + assert set(archive.namelist()) == {"manifest.yaml", "app.yaml"} + finally: + response.close() + assert exported.archive.closed + + +@pytest.mark.parametrize("file_kind", ["upload_file", "tool_file"]) +@pytest.mark.parametrize( + ("declared_mime", "stored_mime", "expected_mime"), + [ + ("text/plain", "application/pdf", "text/plain"), + (None, "application/pdf", "application/pdf"), + (None, "", "application/octet-stream"), + ], +) +def test_export_preserves_file_metadata_in_dsl( + monkeypatch: pytest.MonkeyPatch, file_kind: str, declared_mime: str | None, stored_mime: str, expected_mime: str +) -> None: + from unittest.mock import Mock + + from models.model import UploadFile + + exporter = RosterAgentPackageExporter(storage_backend=_MemoryStorage({})) + tool_file = ToolFile( + user_id="account-1", + tenant_id="tenant-1", + conversation_id=None, + file_key="payload", + mimetype=stored_mime, + name="original.pdf", + size=1, + original_url=None, + ) + tool_file.id = "source-id" + upload_file = Mock(spec=UploadFile, id="source-id", key="payload", mime_type=stored_mime) + monkeypatch.setattr(exporter, "_tool_files", Mock(return_value={"source-id": tool_file})) + monkeypatch.setattr(exporter, "_upload_files", Mock(return_value={"source-id": upload_file})) + soul = AgentSoulConfig.model_validate( + { + "config_files": [ + {"name": "original.pdf", "file_kind": file_kind, "file_id": "source-id", "mime_type": declared_mime} + ] + } + ) + + portable_soul, _, sources = exporter._collect_payloads(session=Mock(spec=Session), tenant_id="tenant-1", soul=soul) + + ref = portable_soul.config_files[0] + assert (ref.name, ref.file_kind, ref.mime_type, ref.file_id) == ( + "original.pdf", + file_kind, + expected_mime, + sources[0].id, + ) + assert sources[0].storage_key == "payload" + assert soul.config_files[0].mime_type == declared_mime + assert soul.config_files[0].file_id == "source-id" + + +@pytest.mark.parametrize("mutation", ["missing", "extra", "duplicate"]) +def test_manifest_requires_workspace_skill_dsl_metadata(mutation: str) -> None: + from services.agent.dsl_entities import AgentPackageWorkspaceSkill + + manifest = _manifest(skill_payload=_skill_archive(), file_payload=b"pdf-content") + app = _package_app() + manifest.skills[0].scope = "workspace" + app.package.soul.config_skills = [] + descriptor = AgentPackageWorkspaceSkill( + name="research", display_name="Research", description="Research skill.", priority=3 + ) + app.package.workspace_skills = [descriptor] + manifest.validate_apps({"app.yaml": app}) + if mutation == "missing": + app.package.workspace_skills = [] + elif mutation == "extra": + app.package.workspace_skills.append(descriptor.model_copy(update={"name": "extra"})) + else: + app.package.workspace_skills.append(descriptor) + with pytest.raises(ValueError, match="workspace skill"): + manifest.validate_apps({"app.yaml": app}) + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + (None, None), + ("shared", None), + ("size", "integrity checks"), + ("checksum", "integrity checks"), + ("missing", "missing assistant.yml"), + ("schema", "app is invalid"), + ("extra", "members do not match"), + ("unreferenced", "app is invalid"), + ], +) +def test_reader_resolves_multiple_indexed_apps(mutation: str | None, message: str | None) -> None: + from services.agent.dsl_entities import AgentPackageWorkspaceSkill + + skill = _skill_archive() + workspace_skill = _skill_archive("workspace") + manifest = _manifest(skill_payload=skill, file_payload=b"pdf") + first, second = _package_app(), _package_app() + first.package.soul.config_files = [] + second.package.soul.config_skills = [] + second.package.workspace_skills = [AgentPackageWorkspaceSkill(name="workspace", priority=0)] + manifest.skills.append( + RosterAgentPackageSkill( + id="s_000002", + path="s_000002.zip", + scope="workspace", + name="workspace", + size=len(workspace_skill), + sha256=hashlib.sha256(workspace_skill).hexdigest(), + ) + ) + if mutation == "shared": + first.package.soul.config_files = second.package.soul.config_files + second.package.soul.config_skills = first.package.soul.config_skills + first.package.workspace_skills = second.package.workspace_skills + elif mutation == "unreferenced": + second.package.soul.config_files = [] + app_payloads = {"research.yaml": _yaml_bytes(first), "assistant.yml": _yaml_bytes(second)} + if mutation == "schema": + app_payloads["assistant.yml"] = b"kind: workflow\n" + manifest.apps = [_app_member(payload, path) for path, payload in app_payloads.items()] + if mutation == "size": + manifest.apps[1].size += 1 + elif mutation == "checksum": + manifest.apps[1].sha256 = "0" * 64 + elif mutation == "missing": + del app_payloads["assistant.yml"] + elif mutation == "extra": + app_payloads["undeclared.yaml"] = b"{}" + content = _zip( + { + "manifest.yaml": _yaml_bytes(manifest), + **app_payloads, + "s_000001.zip": skill, + "s_000002.zip": workspace_skill, + "f_000001.pdf": b"pdf", + } + ) + reader = RosterAgentPackageReader() + if message is not None: + with pytest.raises(InvalidRosterAgentPackageError, match=message): + reader.read(io.BytesIO(content)) + else: + with reader.read(io.BytesIO(content)) as prepared: + assert prepared.apps == {"research.yaml": first, "assistant.yml": second} + for path, payload in app_payloads.items(): + assert reader.read_member_bytes(prepared, path, max_bytes=len(payload)) == payload + + +@pytest.mark.parametrize( + "paths", + [[], ["app.yaml", "app.yaml"], ["app.yaml", "APP.yaml"], ["manifest.yaml"], ["nested/app.yaml"], ["app.json"]], +) +def test_manifest_rejects_invalid_app_index(paths: list[str]) -> None: + values = _manifest(skill_payload=_skill_archive(), file_payload=b"pdf").model_dump(mode="json") + values["apps"] = [{"path": path, "size": 0, "sha256": "0" * 64} for path in paths] + with pytest.raises(ValidationError): + RosterAgentPackageManifest.model_validate(values) diff --git a/api/tests/unit_tests/services/agent/test_skill_package_service.py b/api/tests/unit_tests/services/agent/test_skill_package_service.py index 94aef19e514..d62318434ca 100644 --- a/api/tests/unit_tests/services/agent/test_skill_package_service.py +++ b/api/tests/unit_tests/services/agent/test_skill_package_service.py @@ -9,8 +9,8 @@ import zlib import pytest -from services.agent import skill_package_service as skill_package_service_module from services.agent.skill_package_service import NormalizedSkillPackage, SkillPackageError, SkillPackageService +from tests.unit_tests.config_override import apply_config_overrides _SKILL_MD = """--- name: pdf-toolkit @@ -35,36 +35,110 @@ def _normalize(members: dict[str, bytes], *, filename: str = "skill.zip") -> Nor return SkillPackageService().validate_and_normalize(content=_zip(members), filename=filename) -def _archive_members(content: bytes) -> list[str]: - with zipfile.ZipFile(io.BytesIO(content)) as archive: - return sorted(info.filename for info in archive.infolist() if not info.is_dir()) +def test_normalize_replaces_invalid_utf8_member_names() -> None: + original = _zip( + { + "SKILL.md": _SKILL_MD.encode(), + "scripts/\u00e9.py": b"print('keep payload')\n", + "assets/\u00f6.txt": b"valid unicode name", + "assets/X.txt": b"legacy encoded name", + } + ) + damaged = original.replace(b"\xc3\xa9.py", b"\xffa.py").replace(b"assets/X.txt", b"assets/\x82.txt") + service = SkillPackageService() + assert service.inspect(content=damaged, filename="skill.zip").name == "pdf-toolkit" + normalized = service.validate_and_normalize(content=damaged, filename="skill.zip") + with zipfile.ZipFile(io.BytesIO(normalized.archive_bytes)) as archive: + assert archive.read("scripts/\ufffda.py") == b"print('keep payload')\n" + assert archive.read("assets/\u00f6.txt") == b"valid unicode name" + assert archive.read("assets/\u00e9.txt") == b"legacy encoded name" + assert archive.testzip() is None -def test_valid_skill_normalizes_manifest(): - manifest = _normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('hi')\n"}).manifest - - assert manifest.name == "pdf-toolkit" - assert manifest.description == "Tools for working with PDF files." - assert manifest.entry_path == "SKILL.md" - assert set(manifest.files) == {"SKILL.md", "scripts/run.py"} - assert manifest.size > 0 - assert len(manifest.hash) == 64 +def test_normalize_recovers_zip64_skill_names(monkeypatch: pytest.MonkeyPatch) -> None: + with monkeypatch.context() as scoped: + scoped.setattr(zipfile, "ZIP64_LIMIT", 0) + original = _zip({"SKILL.md": _SKILL_MD.encode(), "scripts/\u00e9.py": b"print('ok')\n"}) + damaged = original.replace(b"\xc3\xa9.py", b"\xffa.py") + service = SkillPackageService() + assert service.inspect(content=damaged, filename="skill.zip").name == "pdf-toolkit" + normalized = service.validate_and_normalize(content=damaged, filename="skill.zip") + with zipfile.ZipFile(io.BytesIO(normalized.archive_bytes)) as archive: + assert archive.read("scripts/\ufffda.py") == b"print('ok')\n" -def test_validate_and_normalize_accepts_crlf_skill_md(): - crlf_skill_md = _SKILL_MD.replace("\n", "\r\n") - package = _normalize({"SKILL.md": crlf_skill_md.encode()}) - - assert package.manifest.name == "pdf-toolkit" - assert package.manifest.description == "Tools for working with PDF files." - assert b"\r" not in package.skill_md_bytes - assert package.skill_md_bytes.decode() == _SKILL_MD +def test_replacement_member_names_must_remain_unique() -> None: + original = _zip( + { + "SKILL.md": _SKILL_MD.encode(), + "scripts/\u00e9.py": b"one", + "scripts/\u00f6.py": b"two", + } + ) + damaged = original.replace(b"\xc3\xa9.py", b"\xffa.py").replace(b"\xc3\xb6.py", b"\xfea.py") + with pytest.raises(SkillPackageError) as error: + SkillPackageService().validate_and_normalize(content=damaged, filename="skill.zip") + assert error.value.code == "duplicate_member_path" -def test_name_and_description_are_required_in_frontmatter(): - with pytest.raises(SkillPackageError) as exc_info: - _normalize({"SKILL.md": b"# heading-name\n\nbody"}) - assert exc_info.value.code == "missing_skill_name" +def test_replacement_names_do_not_hide_local_header_mismatch() -> None: + original = _zip({"SKILL.md": _SKILL_MD.encode(), "\u00e9.py": b"payload"}) + # Damage only the central name, leaving the local header unchanged. + central = original.index(b"PK\x01\x02") + damaged = original[:central] + original[central:].replace(b"\xc3\xa9.py", b"\xffa.py") + with pytest.raises(SkillPackageError) as error: + SkillPackageService().validate_and_normalize(content=damaged, filename="skill.zip") + assert error.value.code == "invalid_archive" + + +def test_normalized_highly_compressible_skill_passes_reinspection() -> None: + payload = b"x" * (1024 * 1024) + content = _zip({"SKILL.md": _SKILL_MD.encode(), "data.bin": payload}, compression=zipfile.ZIP_STORED) + service = SkillPackageService() + normalized = service.validate_and_normalize(content=content, filename="skill.zip") + inspection = service.inspect(content=normalized.archive_bytes, filename="skill.zip") + assert inspection.name == normalized.manifest.name + with zipfile.ZipFile(io.BytesIO(normalized.archive_bytes)) as archive: + assert archive.getinfo("data.bin").compress_type == zipfile.ZIP_DEFLATED + assert archive.read("data.bin") == payload + + +def test_inspect_reports_uncompressed_size_without_rebuilding(monkeypatch: pytest.MonkeyPatch): + members = {"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('hi')\n"} + + def fail_if_called(*_args: object, **_kwargs: object) -> bytes: + raise AssertionError("inspection must not rebuild the archive") + + monkeypatch.setattr(SkillPackageService, "_build_normalized_archive", fail_if_called) + + inspection = SkillPackageService().inspect(content=_zip(members), filename="skill.zip") + + assert inspection.name == "pdf-toolkit" + assert inspection.uncompressed_size == sum(len(content) for content in members.values()) + + +@pytest.mark.parametrize("prefix", ["", "pdf-toolkit/"]) +@pytest.mark.parametrize("crlf", [False, True]) +def test_inspection_and_normalization_keep_distinct_size_meanings(prefix: str, crlf: bool) -> None: + skill_md = _SKILL_MD.replace("\n", "\r\n") if crlf else _SKILL_MD + script = b"print('hi')\n" + members = {f"{prefix}SKILL.md": skill_md.encode(), f"{prefix}scripts/run.py": script} + if prefix: + members["README.md"] = b"ignored outside the selected root" + content = _zip(members) + service = SkillPackageService() + inspection = service.inspect(content=content, filename="skill.zip") + normalized = service.validate_and_normalize(content=content, filename="skill.zip") + assert inspection.uncompressed_size == sum(len(value) for value in members.values()) + expected_size = len(_SKILL_MD.encode()) + len(script) + assert normalized.manifest.size == expected_size + assert normalized.manifest.name == "pdf-toolkit" + assert normalized.manifest.description == "Tools for working with PDF files." + with zipfile.ZipFile(io.BytesIO(normalized.archive_bytes)) as archive: + assert sum(info.file_size for info in archive.infolist()) == expected_size + assert archive.read("SKILL.md") == _SKILL_MD.encode() + if prefix or crlf: + assert inspection.uncompressed_size > normalized.manifest.size def test_shallowest_skill_md_preferred_during_normalization(): @@ -73,96 +147,40 @@ def test_shallowest_skill_md_preferred_during_normalization(): assert manifest.files == ["SKILL.md", "nested/SKILL.md"] -def test_validate_and_normalize_keeps_root_skill_unchanged(): - package = _normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('hi')\n"}) +@pytest.mark.parametrize( + ("prefix", "extra_members"), + [ + pytest.param("", {}, id="root"), + pytest.param("pdf-toolkit/", {}, id="single-folder"), + pytest.param("pdf-toolkit/", {"README.md": b"bundle notes\n"}, id="root-outsider"), + pytest.param("pdf-toolkit/", {".": b"x"}, id="dot-outsider"), + pytest.param("pdf-toolkit/", {"bundle/other.txt": b"x"}, id="nested-outsider"), + pytest.param("bundle/pdf-toolkit/", {}, id="deep-root"), + ], +) +def test_normalization_preserves_selected_skill_contents(prefix: str, extra_members: dict[str, bytes]) -> None: + expected = {"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('hi')\n"} + original = _zip({**{prefix + path: content for path, content in expected.items()}, **extra_members}) + package = SkillPackageService().validate_and_normalize(content=original, filename="skill.zip") + assert package.manifest.name == "pdf-toolkit" + assert package.manifest.description == "Tools for working with PDF files." + assert package.manifest.size == sum(len(content) for content in expected.values()) assert package.manifest.entry_path == "SKILL.md" - assert package.manifest.files == ["SKILL.md", "scripts/run.py"] - assert package.skill_md_bytes == _SKILL_MD.encode() - assert package.strip_prefix is None - assert _archive_members(package.archive_bytes) == ["SKILL.md", "scripts/run.py"] - assert len(package.manifest.hash) == 64 - - -def test_validate_and_normalize_strips_single_top_level_folder(): - package = _normalize( - { - "pdf-toolkit/SKILL.md": _SKILL_MD.encode(), - "pdf-toolkit/scripts/run.py": b"print('hi')\n", - } - ) - - assert package.manifest.entry_path == "SKILL.md" - assert package.manifest.files == ["SKILL.md", "scripts/run.py"] - assert package.skill_md_bytes == _SKILL_MD.encode() - assert package.strip_prefix == "pdf-toolkit/" - assert _archive_members(package.archive_bytes) == ["SKILL.md", "scripts/run.py"] - - -def test_validate_and_normalize_strips_single_top_level_folder_ignoring_other_root_entries(): - package = _normalize( - { - "pdf-toolkit/SKILL.md": _SKILL_MD.encode(), - "pdf-toolkit/scripts/run.py": b"print('hi')\n", - "README.md": b"bundle notes\n", - } - ) - - assert package.manifest.entry_path == "SKILL.md" - assert package.manifest.files == ["SKILL.md", "scripts/run.py"] - assert package.skill_md_bytes == _SKILL_MD.encode() - assert package.strip_prefix == "pdf-toolkit/" - assert _archive_members(package.archive_bytes) == ["SKILL.md", "scripts/run.py"] - - -def test_validate_and_normalize_strips_single_top_level_folder_dropping_nested_foreign_paths(): - package = _normalize( - { - "pdf-toolkit/SKILL.md": _SKILL_MD.encode(), - "pdf-toolkit/scripts/run.py": b"print('hi')\n", - "bundle/other.txt": b"x", - } - ) - - assert package.manifest.entry_path == "SKILL.md" - assert package.manifest.files == ["SKILL.md", "scripts/run.py"] - assert package.skill_md_bytes == _SKILL_MD.encode() - assert package.strip_prefix == "pdf-toolkit/" - assert _archive_members(package.archive_bytes) == ["SKILL.md", "scripts/run.py"] - - -def test_validate_and_normalize_rejects_multiple_depth_2_skill_roots_with_sibling_skill_tree(): - with pytest.raises(SkillPackageError) as exc_info: - _normalize( - { - "pdf-toolkit/SKILL.md": _SKILL_MD.encode(), - "pdf-toolkit/scripts/run.py": b"print('hi')\n", - "other-tool/SKILL.md": _SKILL_MD.encode(), - } - ) - assert exc_info.value.code == "files_outside_skill_root" - - -def test_validate_and_normalize_strips_deeper_selected_skill_root(): - members = { - "bundle/pdf-toolkit/SKILL.md": _SKILL_MD.encode(), - "bundle/pdf-toolkit/scripts/run.py": b"print('hi')\n", - } - original_upload_bytes = _zip(members) - package = SkillPackageService().validate_and_normalize(content=original_upload_bytes, filename="skill.zip") - - assert package.manifest.entry_path == "SKILL.md" - assert package.manifest.files == ["SKILL.md", "scripts/run.py"] - assert package.strip_prefix == "bundle/pdf-toolkit/" - assert _archive_members(package.archive_bytes) == ["SKILL.md", "scripts/run.py"] + assert package.manifest.files == sorted(expected) + with zipfile.ZipFile(io.BytesIO(package.archive_bytes)) as archive: + assert archive.namelist() == sorted(expected) + assert {path: archive.read(path) for path in archive.namelist()} == expected assert package.manifest.hash == hashlib.sha256(package.archive_bytes).hexdigest() - assert package.manifest.hash != hashlib.sha256(original_upload_bytes).hexdigest() + if prefix: + assert package.manifest.hash != hashlib.sha256(original).hexdigest() @pytest.mark.parametrize( ("members", "filename", "code"), [ ({"README.md": b"x"}, "skill.zip", "missing_skill_md"), + ({"SKILL.md": _SKILL_MD.encode(), ".": b"x"}, "skill.zip", "unsafe_path"), ({"SKILL.md": _SKILL_MD.encode()}, "skill.tar", "unsupported_extension"), ({"SKILL.md": b""}, "skill.zip", "empty_skill_md"), ({"SKILL.md": b"---\ndescription: valid\n---\n# no name here"}, "skill.zip", "missing_skill_name"), @@ -178,6 +196,43 @@ def test_validate_and_normalize_strips_deeper_selected_skill_root(): "invalid_skill_description", ), ({"SKILL.md": b"\xff\xfenot utf8"}, "skill.zip", "skill_md_not_utf8"), + pytest.param({"SKILL.md": b"# heading-name\n\nbody"}, "skill.zip", "missing_skill_name", id="no-frontmatter"), + pytest.param( + { + "pdf-toolkit/SKILL.md": _SKILL_MD.encode(), + "pdf-toolkit/scripts/run.py": b"print('hi')\n", + "other-tool/SKILL.md": _SKILL_MD.encode(), + }, + "skill.zip", + "files_outside_skill_root", + id="multiple-skill-roots", + ), + pytest.param({"../evil.txt": b"x", "SKILL.md": _SKILL_MD.encode()}, "skill.zip", "unsafe_path", id="zip-slip"), + pytest.param( + {"SKILL.md": b"---\n: : : not yaml\n---\n# x\n"}, + "skill.zip", + "invalid_frontmatter", + id="invalid-frontmatter", + ), + pytest.param( + {"SKILL.md": b"---\n# heading-wins\nbody"}, "skill.zip", "missing_skill_name", id="unterminated-frontmatter" + ), + pytest.param( + {"bundle/pdf-toolkit/SKILL.md": _SKILL_MD.encode(), "README.md": b"x"}, + "skill.zip", + "files_outside_skill_root", + id="outside-deep-root", + ), + pytest.param( + { + "pdf-toolkit/SKILL.md": _SKILL_MD.encode(), + "pdf-toolkit/scripts/run.py": b"print('x')\n", + "pdf-toolkit/scripts/./run.py": b"print('y')\n", + }, + "skill.zip", + "duplicate_member_path", + id="duplicate-normalized-path", + ), ], ) def test_invalid_packages_rejected(members: dict[str, bytes], filename: str, code: str): @@ -193,13 +248,6 @@ def test_non_zip_content_rejected(): assert exc_info.value.code == "invalid_archive" -def test_zip_slip_member_rejected(): - payload = _zip({"../evil.txt": b"x", "SKILL.md": _SKILL_MD.encode()}) - with pytest.raises(SkillPackageError) as exc_info: - SkillPackageService().validate_and_normalize(content=payload, filename="skill.zip") - assert exc_info.value.code == "unsafe_path" - - def test_empty_archive_rejected(): with pytest.raises(SkillPackageError) as exc_info: SkillPackageService().validate_and_normalize(content=b"", filename="skill.zip") @@ -207,7 +255,7 @@ def test_empty_archive_rejected(): def test_validate_and_normalize_rejects_skill_md_too_large(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(skill_package_service_module, "_MAX_SKILL_MD_BYTES", 8) + apply_config_overrides(monkeypatch, SKILL_PACKAGE_MAX_SKILL_MD_BYTES=8) with pytest.raises(SkillPackageError) as exc_info: _normalize({"SKILL.md": _SKILL_MD.encode()}) @@ -215,15 +263,15 @@ def test_validate_and_normalize_rejects_skill_md_too_large(monkeypatch: pytest.M def test_validate_and_normalize_rejects_too_many_entries(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(skill_package_service_module, "_MAX_ENTRIES", 1) + apply_config_overrides(monkeypatch, SKILL_PACKAGE_MAX_ENTRIES=1) with pytest.raises(SkillPackageError) as exc_info: - _normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('x')\n"}) + _normalize({"SKILL.md": _SKILL_MD.encode(), "assets/": b""}) assert exc_info.value.code == "too_many_entries" def test_validate_and_normalize_rejects_archive_too_large_uncompressed(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(skill_package_service_module, "_MAX_UNCOMPRESSED_BYTES", 32) + apply_config_overrides(monkeypatch, SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES=32) with pytest.raises(SkillPackageError) as exc_info: _normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"x" * 33}) @@ -231,8 +279,6 @@ def test_validate_and_normalize_rejects_archive_too_large_uncompressed(monkeypat def test_validate_and_normalize_rejects_archive_too_large_uploaded_bytes(monkeypatch: pytest.MonkeyPatch): - from tests.unit_tests.config_override import apply_config_overrides - apply_config_overrides(monkeypatch, UPLOAD_SKILL_FILE_SIZE_LIMIT=1) with pytest.raises(SkillPackageError) as exc_info: @@ -240,35 +286,14 @@ def test_validate_and_normalize_rejects_archive_too_large_uploaded_bytes(monkeyp assert exc_info.value.code == "archive_too_large" -def test_bad_frontmatter_yaml_rejected(): - bad = b"---\n: : : not yaml\n---\n# x\n" +def test_high_compression_ratio_uses_absolute_expansion_limit(monkeypatch: pytest.MonkeyPatch) -> None: + content = _zip({"SKILL.md": _SKILL_MD.encode(), "data.bin": b"x" * (1024 * 1024)}) + service = SkillPackageService() + assert service.inspect(content=content, filename="skill.zip").name == "pdf-toolkit" + apply_config_overrides(monkeypatch, SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES=1024) with pytest.raises(SkillPackageError) as exc_info: - _normalize({"SKILL.md": bad}) - assert exc_info.value.code == "invalid_frontmatter" - - -def test_unterminated_frontmatter_rejected(): - with pytest.raises(SkillPackageError) as exc_info: - _normalize({"SKILL.md": b"---\n# heading-wins\nbody"}) - assert exc_info.value.code == "missing_skill_name" - - -def test_validate_and_normalize_rejects_files_outside_selected_skill_root(): - with pytest.raises(SkillPackageError) as exc_info: - _normalize({"bundle/pdf-toolkit/SKILL.md": _SKILL_MD.encode(), "README.md": b"x"}) - assert exc_info.value.code == "files_outside_skill_root" - - -def test_validate_and_normalize_rejects_duplicate_normalized_paths(): - with pytest.raises(SkillPackageError) as exc_info: - _normalize( - { - "pdf-toolkit/SKILL.md": _SKILL_MD.encode(), - "pdf-toolkit/scripts/run.py": b"print('x')\n", - "pdf-toolkit/scripts/./run.py": b"print('y')\n", - } - ) - assert exc_info.value.code == "duplicate_member_path" + service.inspect(content=content, filename="skill.zip") + assert exc_info.value.code == "archive_too_large" def test_validate_and_normalize_maps_member_decompression_failures_to_invalid_archive(monkeypatch: pytest.MonkeyPatch): diff --git a/api/tests/unit_tests/services/test_agent_config_service.py b/api/tests/unit_tests/services/test_agent_config_service.py index f69def8bd7c..dcd6af670c4 100644 --- a/api/tests/unit_tests/services/test_agent_config_service.py +++ b/api/tests/unit_tests/services/test_agent_config_service.py @@ -366,7 +366,7 @@ def test_push_accepts_tenant_scoped_tool_file_sources_from_different_upload_owne patch.object( service._skill_normalizer, "normalize", - return_value=(skill_ref, object()), + return_value=skill_ref, ), ): manifest = service.push( diff --git a/api/tests/unit_tests/services/test_app_dsl_service.py b/api/tests/unit_tests/services/test_app_dsl_service.py index 86a410c665e..b7d84895d04 100644 --- a/api/tests/unit_tests/services/test_app_dsl_service.py +++ b/api/tests/unit_tests/services/test_app_dsl_service.py @@ -9,11 +9,13 @@ import yaml from sqlalchemy import event, select from sqlalchemy.orm import Session, sessionmaker +from constants.dsl_version import CURRENT_APP_DSL_VERSION from core.rbac import RBACPermission, RBACResourceScope from core.workflow.llm_environment_variable import LLMEnvironmentVariable from models import Account, App, AppMode, Tenant from models.model import AppModelConfig, AppModelConfigDict, IconType from models.workflow import Workflow, WorkflowType +from services.agent.dsl_entities import AgentPackage from services.app_dsl_service import AppDslService, Import, PendingData from services.enterprise.enterprise_service import EnterpriseService from services.entities.dsl_entities import ImportStatus @@ -795,3 +797,56 @@ def test_append_workflow_export_data_reports_missing_selected_workflow( session=unbound_session, workflow_id=workflow_id, ) + + +@pytest.mark.parametrize( + "mode", + [AppMode.AGENT, AppMode.WORKFLOW, AppMode.ADVANCED_CHAT, AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.COMPLETION], +) +def test_export_dsl_preserves_envelope_and_mode_specific_content( + mode: AppMode, monkeypatch: pytest.MonkeyPatch, unbound_session: Session +) -> None: + app = _app(mode=mode) + package = AgentPackage.model_validate({"metadata": {"name": app.name}, "soul": {}}) + monkeypatch.setattr( + "services.app_dsl_service.AgentDslService.export_agent_app", + Mock(return_value=("agent_1", {"agent_1": package})), + ) + monkeypatch.setattr( + "services.app_dsl_service.DependenciesAnalysisService.generate_dependencies", Mock(return_value=[]) + ) + + def append_workflow(*, export_data: dict[str, object], **_kwargs: object) -> None: + export_data["workflow"] = {"fixture": "workflow"} + + def append_model(export_data: dict[str, object], *_args: object, **_kwargs: object) -> None: + export_data["model_config"] = {"fixture": "model"} + + monkeypatch.setattr(AppDslService, "_append_workflow_export_data", Mock(side_effect=append_workflow)) + monkeypatch.setattr(AppDslService, "_append_model_config_export_data", Mock(side_effect=append_model)) + data = yaml.safe_load(AppDslService.export_dsl(app, session=unbound_session)) + assert data["version"] == CURRENT_APP_DSL_VERSION + assert data["kind"] == "app" + assert data["app"] == { + "name": "Existing app", + "description": "", + "mode": mode.value, + "icon_type": "emoji", + "icon": "robot", + "icon_background": "#FFFFFF", + "use_icon_as_answer_icon": False, + } + if mode == AppMode.AGENT: + assert data["agent"] == {"package_ref": "agent_1"} + assert AgentPackage.model_validate(data["agent_packages"]["agent_1"]) == package + assert data["dependencies"] == [] + assert "workflow" not in data + assert "model_config" not in data + elif mode in {AppMode.WORKFLOW, AppMode.ADVANCED_CHAT}: + assert data["workflow"] == {"fixture": "workflow"} + assert "agent" not in data + assert "model_config" not in data + else: + assert data["model_config"] == {"fixture": "model"} + assert "agent" not in data + assert "workflow" not in data diff --git a/api/tests/unit_tests/services/test_app_service.py b/api/tests/unit_tests/services/test_app_service.py index 1e6f79261f3..d568fb6742c 100644 --- a/api/tests/unit_tests/services/test_app_service.py +++ b/api/tests/unit_tests/services/test_app_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from collections.abc import Callable from datetime import datetime from types import SimpleNamespace @@ -544,6 +545,32 @@ class TestGetApp: is_agent.assert_not_called() get_model_config.assert_called_once_with(session=unbound_session) + def test_masked_agent_config_is_served_through_the_session_accessor(self, unbound_session: Session): + """The masked config must be reachable via `app_model_config_with_session`. + + Every response path resolves an `App`'s model config through that accessor + (`AppResponseView.app_model_config`), never through a raw attribute, so the + masking `get_app` applies has to be observable there. + """ + app = App(mode=AppMode.AGENT_CHAT) + app.id = str(uuid4()) + masked_config = AppModelConfig(app_id=app.id, agent_mode=json.dumps({"enabled": True, "tools": []})) + # A second, unmasked instance: what a fresh lookup would hand back if the + # returned app ever fell through to the base accessor. + refetched_config = AppModelConfig(app_id=app.id, agent_mode=json.dumps({"enabled": True, "tools": []})) + account = Account(name="Test Account", email="test@example.com") + account._current_tenant = Tenant(name="Test Tenant") + account._current_tenant.id = "tenant-1" + + with ( + patch.object(App, "app_model_config_with_session", side_effect=[masked_config, refetched_config]), + patch("services.app_service.current_user", account), + ): + result = AppService().get_app(app, session=unbound_session) + + assert result is not app + assert result.app_model_config_with_session(session=unbound_session) is masked_config + class TestAgentAppType: """S1: new ``AppMode.AGENT`` app type wiring.""" diff --git a/api/tests/unit_tests/services/test_skill_management_service.py b/api/tests/unit_tests/services/test_skill_management_service.py index 8bdb7503dcc..315e3b6b3b5 100644 --- a/api/tests/unit_tests/services/test_skill_management_service.py +++ b/api/tests/unit_tests/services/test_skill_management_service.py @@ -1150,6 +1150,80 @@ def test_replace_agent_bindings_rejects_skill_name_conflict_with_agent_config_sk assert exc_info.value.details == {"names": ["finance-sop"]} +@pytest.mark.parametrize( + ("snapshot_has_skill", "draft_has_skill", "debug_has_skill", "expect_conflict"), + [ + (True, False, False, False), + (True, False, True, False), + (False, True, False, True), + (True, True, False, True), + (False, None, True, False), + (True, None, True, True), + ], +) +def test_replace_agent_bindings_checks_editable_config( + snapshot_has_skill: bool, + draft_has_skill: bool | None, + debug_has_skill: bool, + expect_conflict: bool, +) -> None: + service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) + created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload(name="finance-sop")) + skill_ref = AgentConfigSkillRefConfig(name="finance-sop", description="Embedded skill.", file_id="tool-file-1") + with session_factory.create_session() as session: + snapshot = AgentConfigSnapshot( + tenant_id=TENANT, + agent_id=AGENT, + version=1, + config_snapshot=AgentSoulConfig(config_skills=[skill_ref] if snapshot_has_skill else []), + created_by=USER, + ) + session.add(snapshot) + session.flush() + agent = session.get(Agent, AGENT) + assert agent is not None + agent.active_config_snapshot_id = snapshot.id + if draft_has_skill is not None: + session.add( + AgentConfigDraft( + tenant_id=TENANT, + agent_id=AGENT, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", + base_snapshot_id=snapshot.id, + config_snapshot=AgentSoulConfig(config_skills=[skill_ref] if draft_has_skill else []), + created_by=USER, + ) + ) + if debug_has_skill: + session.add( + AgentConfigDraft( + tenant_id=TENANT, + agent_id=AGENT, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=USER, + draft_owner_key=USER, + base_snapshot_id=snapshot.id, + config_snapshot=AgentSoulConfig(config_skills=[skill_ref]), + created_by=USER, + ) + ) + session.commit() + + if expect_conflict: + with pytest.raises(SkillManagementServiceError) as exc_info: + service.replace_agent_bindings(tenant_id=TENANT, user_id=USER, agent_id=AGENT, skill_ids=[created["id"]]) + assert exc_info.value.code == "agent_skill_name_conflict" + assert exc_info.value.details == {"names": ["finance-sop"]} + else: + result = service.replace_agent_bindings( + tenant_id=TENANT, user_id=USER, agent_id=AGENT, skill_ids=[created["id"]] + ) + assert result["skill_ids"] == [created["id"]] + assert service.list_agent_bindings(tenant_id=TENANT, agent_id=AGENT)["data"][0]["id"] == created["id"] + + def test_replace_agent_bindings_allows_existing_bound_workspace_skill_name_in_agent_config() -> None: service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload(name="finance-sop")) @@ -2696,6 +2770,31 @@ def test_import_skill_package_accepts_crlf_skill_md() -> None: assert skill_md_file["content"].startswith("---\nname: expense-sop\n") +@pytest.mark.parametrize("include_empty_directory", [False, True]) +def test_import_skill_package_drops_explicit_wrapper_directory(include_empty_directory: bool) -> None: + package = io.BytesIO() + with zipfile.ZipFile(package, "w") as archive: + archive.writestr("critical-thinking/", "") + archive.writestr("__MACOSX/._critical-thinking", b"\x00") + archive.writestr( + "critical-thinking/SKILL.md", + "---\nname: critical-thinking\ndescription: Critical thinking\n---\n# Critical thinking", + ) + archive.writestr("__MACOSX/critical-thinking/._SKILL.md", b"\x00") + if include_empty_directory: + archive.writestr("critical-thinking/references/", "") + + service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) + imported = service.import_skill( + tenant_id=TENANT, + user_id=USER, + payload=SkillImportPayload(content=package.getvalue(), filename="critical-thinking.zip"), + ) + + expected_paths = ["SKILL.md", "references"] if include_empty_directory else ["SKILL.md"] + assert [item["path"] for item in imported["files"]] == expected_paths + + def test_import_skill_package_strips_root_alongside_macos_metadata_folder() -> None: package = io.BytesIO() with zipfile.ZipFile(package, "w") as archive: @@ -2755,21 +2854,27 @@ def test_import_skill_package_rejects_archive_larger_than_upload_skill_limit(mon assert exc_info.value.code == "archive_too_large" -def test_import_skill_package_rejects_zip_bomb_before_reading_members() -> None: +def test_import_skill_package_rejects_excessive_expansion_before_reading_members( + monkeypatch: pytest.MonkeyPatch, +) -> None: + apply_config_overrides(monkeypatch, SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES=1024 * 1024) package = io.BytesIO() with zipfile.ZipFile(package, "w", compression=zipfile.ZIP_DEFLATED) as archive: archive.writestr("payload.bin", b"\x00" * (8 * 1024 * 1024)) service = SkillManagementService(tool_file_manager=_FakeToolFileManager()) - with pytest.raises(SkillManagementServiceError) as exc_info: + with ( + patch.object(zipfile.ZipFile, "read", side_effect=AssertionError("member read before size validation")), + pytest.raises(SkillManagementServiceError) as exc_info, + ): service._draft_payload_from_zip( tenant_id=TENANT, user_id=USER, archive_bytes=package.getvalue(), ) - assert exc_info.value.code == "invalid_skill_package" + assert exc_info.value.code == "skill_too_large" def test_publish_and_export_include_binary_tool_files() -> None: @@ -3199,6 +3304,7 @@ def test_runtime_agent_skills_use_published_identity_when_draft_metadata_changed description="draft description", ) version = SimpleNamespace( + id="version-1", archive_tool_file_id="archive-1", archive_size=10, hash_code="hash-1", @@ -3209,8 +3315,10 @@ def test_runtime_agent_skills_use_published_identity_when_draft_metadata_changed description="published description", ), ) + binding = SimpleNamespace(priority=0) + tool_file = SimpleNamespace(file_key="tools/archive-1.zip") session = MagicMock() - session.execute.return_value = [(SimpleNamespace(), skill, version)] + session.execute.return_value = [(binding, skill, version, tool_file)] with patch("services.skill_management_service.session_factory.create_session") as create_session: create_session.return_value.__enter__.return_value = session @@ -3284,6 +3392,29 @@ def test_agent_skill_binding_changes_require_agent_publish_before_runtime_load() assert service.list_runtime_agent_skills(tenant_id=TENANT, agent_id=AGENT)[0]["name"] == "finance-sop" + with session_factory.create_session() as session: + agent = session.get(Agent, AGENT) + assert agent is not None + next_snapshot = AgentConfigSnapshot( + tenant_id=TENANT, + agent_id=AGENT, + version=2, + config_snapshot=AgentSoulConfig(), + created_by=USER, + ) + session.add(next_snapshot) + session.flush() + agent.active_config_snapshot_id = next_snapshot.id + session.commit() + + assert service.list_runtime_agent_skills(tenant_id=TENANT, agent_id=AGENT) == [] + historical_skills = service.list_runtime_agent_skills( + tenant_id=TENANT, agent_id=AGENT, config_snapshot_id=snapshot_id + ) + assert [skill["name"] for skill in historical_skills] == ["finance-sop"] + draft_skills = service.list_runtime_agent_skills(tenant_id=TENANT, agent_id=AGENT, include_draft=True) + assert [skill["name"] for skill in draft_skills] == ["finance-sop"] + def test_runtime_agent_skill_pull_normalizes_archive_identity_to_published_metadata() -> None: archive_buffer = io.BytesIO() @@ -3304,3 +3435,14 @@ def test_runtime_agent_skill_pull_normalizes_archive_identity_to_published_metad assert "description: Published description" in skill_md assert "display-name: Published Name" in skill_md assert archive.read("references/example.md") == b"Example" + + +def test_archive_limits_accept_high_compression_ratio_within_size_budget(monkeypatch: pytest.MonkeyPatch) -> None: + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("data.bin", b"x" * (1024 * 1024)) + with zipfile.ZipFile(io.BytesIO(output.getvalue())) as archive: + assert SkillManagementService._validate_archive_limits(archive)[0].file_size == 1024 * 1024 + apply_config_overrides(monkeypatch, SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES=1024) + with pytest.raises(SkillManagementServiceError, match="uncompressed size limit"): + SkillManagementService._validate_archive_limits(archive) diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index f5ae7604d0c..fbb960b742c 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -66,6 +66,12 @@ UPLOAD_IMAGE_FILE_SIZE_LIMIT=10 UPLOAD_VIDEO_FILE_SIZE_LIMIT=100 UPLOAD_AUDIO_FILE_SIZE_LIMIT=50 UPLOAD_SKILL_FILE_SIZE_LIMIT=50 +SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES=209715200 +SKILL_PACKAGE_MAX_ENTRIES=5000 +SKILL_PACKAGE_MAX_SKILL_MD_BYTES=1048576 +AGENT_PACKAGE_MAX_BYTES=536870912 +AGENT_PACKAGE_MAX_MANIFEST_BYTES=5242880 +AGENT_PACKAGE_MAX_ENTRIES=5000 API_SENTRY_DSN= API_SENTRY_TRACES_SAMPLE_RATE=1.0 API_SENTRY_PROFILES_SAMPLE_RATE=1.0 diff --git a/knip.config.ts b/knip.config.ts index 939a3143ac2..4cf2444d674 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -17,6 +17,7 @@ const config: KnipConfig = { 'plugins/eslint/index.js', // Public surface consumed by the standalone Marketplace host. // The `!` suffix keeps these entries in `knip --production`. + 'app/components/plugins/marketplace/detail-dialog/image-preview-message.ts!', 'app/components/plugins/marketplace/standalone/server.ts!', 'app/components/plugins/marketplace/standalone/client.ts!', ], diff --git a/lint.config.ts b/lint.config.ts index a8aa394ac57..bf84dec886b 100644 --- a/lint.config.ts +++ b/lint.config.ts @@ -912,11 +912,6 @@ export const lintConfig = { message: 'Do not import Floating UI directly in web. Use @langgenius/dify-ui/* primitives instead.', }, - { - group: ['**/base/input', '**/base/input/*'], - message: - 'Do not import the deprecated web base Input. Use @langgenius/dify-ui/input for standalone inputs, and @langgenius/dify-ui/field for labelled or validated form composition.', - }, { group: [ '@/service/base', @@ -984,11 +979,6 @@ export const lintConfig = { message: 'Do not import Floating UI directly in web. Use @langgenius/dify-ui/* primitives instead.', }, - { - group: ['**/base/input', '**/base/input/*'], - message: - 'Do not import the deprecated web base Input. Use @langgenius/dify-ui/input for standalone inputs, and @langgenius/dify-ui/field for labelled or validated form composition.', - }, { group: [ '@/service/base', diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index f44de1f0103..155b029e760 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -100,14 +100,8 @@ } }, "web/app/account/(commonLayout)/account-page/email-change-modal.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 3 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 3 - }, "no-restricted-imports": { - "count": 2 + "count": 1 } }, "web/app/components/app/annotation/__tests__/index.spec.tsx": { @@ -965,11 +959,6 @@ "count": 1 } }, - "web/app/components/base/emoji-picker/Inner.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/error-boundary/index.tsx": { "eslint-react/jsx-no-key-after-spread": { "count": 1 @@ -1140,11 +1129,6 @@ "count": 1 } }, - "web/app/components/base/form/components/base/base-field.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/form/components/base/base-form.tsx": { "typescript/no-explicit-any": { "count": 6 @@ -1460,9 +1444,6 @@ "web/app/components/base/image-uploader/image-preview.tsx": { "jsx-a11y/no-static-element-interactions": { "count": 1 - }, - "typescript/no-explicit-any": { - "count": 1 } }, "web/app/components/base/image-uploader/utils.ts": { @@ -1478,17 +1459,6 @@ "count": 2 } }, - "web/app/components/base/input/index.stories.tsx": { - "jsx-a11y/label-has-associated-control": { - "count": 9 - }, - "no-console": { - "count": 2 - }, - "typescript/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/markdown-blocks/__tests__/paragraph.spec.tsx": { "jsx-a11y/anchor-is-valid": { "count": 1 @@ -1505,9 +1475,6 @@ } }, "web/app/components/base/markdown-blocks/form.tsx": { - "no-restricted-imports": { - "count": 1 - }, "prefer-regex-literals": { "count": 1 } @@ -2048,11 +2015,6 @@ "count": 5 } }, - "web/app/components/datasets/create/step-two/components/inputs.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "web/app/components/datasets/create/step-two/components/option-card.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -3248,11 +3210,6 @@ "count": 2 } }, - "web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/tools/edit-custom-collection-modal/index.tsx": { "eslint-react/set-state-in-effect": { "count": 4 @@ -3262,15 +3219,6 @@ } }, "web/app/components/tools/edit-custom-collection-modal/test-api.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - }, "typescript/no-explicit-any": { "count": 1 } @@ -3293,11 +3241,6 @@ "count": 4 } }, - "web/app/components/tools/workflow-tool/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx": { "eslint-react/static-components": { "count": 2 @@ -3517,9 +3460,6 @@ "jsx-a11y/no-autofocus": { "count": 3 }, - "no-restricted-imports": { - "count": 1 - }, "typescript/no-explicit-any": { "count": 11 } @@ -3598,11 +3538,6 @@ "count": 2 } }, - "web/app/components/workflow/nodes/_base/components/form-input-item.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/input-support-select-var.tsx": { "typescript/no-explicit-any": { "count": 1 @@ -3637,14 +3572,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/next-step/operator.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 2 - } - }, "web/app/components/workflow/nodes/_base/components/node-control.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -3875,11 +3802,6 @@ "count": 15 } }, - "web/app/components/workflow/nodes/assigner/components/var-list/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/assigner/hooks.ts": { "typescript/no-explicit-any": { "count": 1 @@ -3972,11 +3894,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/http/components/authorization/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/http/components/key-value/key-value-edit/index.tsx": { "typescript/no-explicit-any": { "count": 2 @@ -4765,9 +4682,6 @@ "web/app/components/workflow/panel/env-panel/variable-modal.tsx": { "eslint-react/set-state-in-effect": { "count": 4 - }, - "no-restricted-imports": { - "count": 1 } }, "web/app/components/workflow/panel/global-variable-panel/index.tsx": { diff --git a/packages/contracts/binary-zod.test.ts b/packages/contracts/binary-zod.test.ts index dab37626fae..2aee0ffcdec 100644 --- a/packages/contracts/binary-zod.test.ts +++ b/packages/contracts/binary-zod.test.ts @@ -1,8 +1,28 @@ import { describe, expect, it } from 'vite-plus/test' +import { + zGetAppsByAppIdExportResponse, + zPostAppsImportsBody, +} from './generated/api/console/apps/zod.gen' import { zPostFilesUploadBody } from './generated/api/console/files/zod.gen' import { zPostWorkspacesCurrentPluginUploadPkgBody } from './generated/api/console/workspaces/zod.gen' describe('generated binary schemas', () => { + it('accepts binary package imports alongside YAML import requests', () => { + const file = new File(['PK'], 'agent.ifpkg', { type: 'application/zip' }) + expect(zPostAppsImportsBody.safeParse({ file }).success).toBe(true) + expect( + zPostAppsImportsBody.safeParse({ mode: 'yaml-content', yaml_content: 'kind: app' }).success, + ).toBe(true) + expect(zPostAppsImportsBody.safeParse({ file: 'not a binary file' }).success).toBe(false) + }) + + it('accepts both Agent archives and YAML JSON export responses', () => { + const archive = new File(['PK'], 'agent.ifpkg', { type: 'application/zip' }) + expect(zGetAppsByAppIdExportResponse.safeParse(archive).success).toBe(true) + expect(zGetAppsByAppIdExportResponse.safeParse({ data: 'kind: app' }).success).toBe(true) + expect(zGetAppsByAppIdExportResponse.safeParse('kind: app').success).toBe(false) + }) + it.each([ ['file upload', zPostFilesUploadBody, 'file'], ['plugin package upload', zPostWorkspacesCurrentPluginUploadPkgBody, 'pkg'], diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index fa685709fe4..bfd59c89666 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -76,6 +76,13 @@ export type Import = { warnings?: Array } +export type RosterAgentPackageConflictResponse = { + code: string + leaked_dependencies?: Array + message: string + status?: 409 +} + export type CheckDependenciesResult = { leaked_dependencies?: Array } @@ -3147,7 +3154,11 @@ export type PostAppsResponses = { export type PostAppsResponse = PostAppsResponses[keyof PostAppsResponses] export type PostAppsImportsData = { - body: AppImportPayload + body: + | AppImportPayload + | { + file: Blob | File + } path?: never query?: never url: '/apps/imports' @@ -3155,6 +3166,9 @@ export type PostAppsImportsData = { export type PostAppsImportsErrors = { 400: Import + 403: unknown + 409: RosterAgentPackageConflictResponse + 413: unknown } export type PostAppsImportsError = PostAppsImportsErrors[keyof PostAppsImportsErrors] @@ -4417,6 +4431,7 @@ export type GetAppsByAppIdExportData = { app_id: string } query?: { + format?: 'ifpkg' | 'yaml' include_secret?: boolean workflow_id?: string } @@ -4428,7 +4443,7 @@ export type GetAppsByAppIdExportErrors = { } export type GetAppsByAppIdExportResponses = { - 200: AppExportResponse + 200: AppExportResponse | Blob | File } export type GetAppsByAppIdExportResponse = diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index a29b6c275de..7b20baca4ea 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -2258,6 +2258,16 @@ export const zPluginDependency = z.object({ value: z.union([zGithub, zMarketplace, zPackage]), }) +/** + * RosterAgentPackageConflictResponse + */ +export const zRosterAgentPackageConflictResponse = z.object({ + code: z.string(), + leaked_dependencies: z.array(zPluginDependency).optional(), + message: z.string(), + status: z.literal(409).optional().default(409), +}) + /** * CheckDependenciesResult */ @@ -4244,7 +4254,12 @@ export const zPostAppsBody = zCreateAppPayload */ export const zPostAppsResponse = zAppDetailWithSite -export const zPostAppsImportsBody = zAppImportPayload +export const zPostAppsImportsBody = z.union([ + zAppImportPayload, + z.object({ + file: z.custom((value) => value instanceof Blob || value instanceof File), + }), +]) /** * Import completed @@ -5041,6 +5056,7 @@ export const zGetAppsByAppIdExportPath = z.object({ }) export const zGetAppsByAppIdExportQuery = z.object({ + format: z.enum(['ifpkg', 'yaml']).optional(), include_secret: z.boolean().optional().default(false), workflow_id: z.string().optional(), }) @@ -5048,7 +5064,10 @@ export const zGetAppsByAppIdExportQuery = z.object({ /** * App exported successfully */ -export const zGetAppsByAppIdExportResponse = zAppExportResponse +export const zGetAppsByAppIdExportResponse = z.union([ + zAppExportResponse, + z.custom((value) => value instanceof Blob || value instanceof File), +]) export const zPostAppsByAppIdFeedbacksBody = zMessageFeedbackPayload diff --git a/packages/contracts/openapi-ts.api.config.ts b/packages/contracts/openapi-ts.api.config.ts index a2b272336cb..05d91cd0852 100644 --- a/packages/contracts/openapi-ts.api.config.ts +++ b/packages/contracts/openapi-ts.api.config.ts @@ -261,7 +261,31 @@ const filterContractOperations = (document: SwaggerDocument) => { } } -const includeEventStreamInJsonResponseSchemas = (document: SwaggerDocument) => { +const includeMultipartRequestSchemas = (document: SwaggerDocument) => { + for (const pathItem of Object.values(document.paths ?? {})) { + for (const [method, operation] of Object.entries(pathItem)) { + if (!operationMethods.has(method) || !isObject(operation) || !isObject(operation.requestBody)) + continue + const content = operation.requestBody.content + if (!isObject(content)) continue + const json = content['application/json'] + const multipart = content['multipart/form-data'] + if ( + !isObject(json) || + !isObject(json.schema) || + !isObject(multipart) || + !isObject(multipart.schema) + ) + continue + + // hey-api selects JSON for mixed request media; retain the multipart shape + // so the generated client can serialize File values as FormData. + json.schema = { anyOf: [json.schema, multipart.schema] } + } + } +} + +const includeNonJsonResponseSchemas = (document: SwaggerDocument) => { for (const pathItem of Object.values(document.paths ?? {})) { for (const [method, operation] of Object.entries(pathItem)) { if (!operationMethods.has(method) || !isObject(operation)) continue @@ -272,21 +296,22 @@ const includeEventStreamInJsonResponseSchemas = (document: SwaggerDocument) => { if (!/^2\d\d$/.test(status) || !isObject(response) || !isObject(response.content)) continue const jsonMedia = response.content['application/json'] - const eventStreamMedia = response.content['text/event-stream'] - if ( - !isObject(jsonMedia) || - !isObject(jsonMedia.schema) || - !isObject(eventStreamMedia) || - !isObject(eventStreamMedia.schema) - ) { - continue + if (!isObject(jsonMedia) || !isObject(jsonMedia.schema)) continue + + const alternatives: SwaggerSchema[] = [] + for (const [mediaType, media] of Object.entries(response.content)) { + if (mediaType === 'application/json' || !isObject(media) || !isObject(media.schema)) + continue + if (mediaType === 'text/event-stream' || media.schema.format === 'binary') + alternatives.push(media.schema) } + if (alternatives.length === 0) continue // hey-api selects the JSON schema when one status advertises multiple - // response media types. Preserve the SSE transport in the generated + // response media types. Preserve SSE and binary transports in the generated // TypeScript and Zod contracts by making that selected schema a union. jsonMedia.schema = { - anyOf: [jsonMedia.schema, eventStreamMedia.schema], + anyOf: [jsonMedia.schema, ...alternatives], } } } @@ -369,7 +394,8 @@ const normalizeApiSwagger = (document: SwaggerDocument) => { normalizeOpaqueContractResponses(document) filterContractOperations(document) addOperationIds(document) - includeEventStreamInJsonResponseSchemas(document) + includeMultipartRequestSchemas(document) + includeNonJsonResponseSchemas(document) // OpenAPI defaults describe server behavior. Keep them in the exported specs, // but do not let Zod synthesize omitted transport fields during client-side // request or response validation. Non-null defaults remain useful for query diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a434f12a643..5d762e4b8a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,8 +201,8 @@ catalogs: specifier: 0.27.20 version: 0.27.20 '@formatjs/intl-localematcher': - specifier: 0.8.14 - version: 0.8.14 + specifier: 0.9.0 + version: 0.9.0 '@heroicons/react': specifier: 2.2.0 version: 2.2.0 @@ -264,8 +264,8 @@ catalogs: specifier: 4.9.0 version: 4.9.0 '@rgrove/parse-xml': - specifier: 4.2.3 - version: 4.2.3 + specifier: 5.0.0 + version: 5.0.0 '@sentry/react': specifier: 10.75.0 version: 10.75.0 @@ -339,8 +339,8 @@ catalogs: specifier: 10.4.2 version: 10.4.2 '@testing-library/jest-dom': - specifier: 6.9.1 - version: 6.9.1 + specifier: 7.0.1 + version: 7.0.1 '@testing-library/react': specifier: 16.3.3 version: 16.3.3 @@ -423,8 +423,8 @@ catalogs: specifier: 0.6.5 version: 0.6.5 cn: - specifier: 0.2.6 - version: 0.2.6 + specifier: 0.3.0 + version: 0.3.0 code-inspector-plugin: specifier: 1.6.6 version: 1.6.6 @@ -453,8 +453,8 @@ catalogs: specifier: 3.0.6 version: 3.0.6 elkjs: - specifier: 0.11.1 - version: 0.11.1 + specifier: 0.12.0 + version: 0.12.0 embla-carousel-autoplay: specifier: 8.6.0 version: 8.6.0 @@ -477,8 +477,8 @@ catalogs: specifier: 2.4.0 version: 2.4.0 eslint-markdown: - specifier: 0.12.1 - version: 0.12.1 + specifier: 0.17.0 + version: 0.17.0 eslint-plugin-antfu: specifier: 3.2.3 version: 3.2.3 @@ -486,26 +486,26 @@ catalogs: specifier: 4.7.0 version: 4.7.0 eslint-plugin-command: - specifier: 3.5.3 - version: 3.5.3 + specifier: 4.0.0 + version: 4.0.0 eslint-plugin-erasable-syntax-only: - specifier: 0.4.2 - version: 0.4.2 + specifier: 0.7.1 + version: 0.7.1 eslint-plugin-jsdoc: - specifier: 63.3.3 - version: 63.3.3 + specifier: 64.5.2 + version: 64.5.2 eslint-plugin-jsonc: specifier: 3.4.2 version: 3.4.2 eslint-plugin-markdown-preferences: - specifier: 0.41.1 - version: 0.41.1 + specifier: 0.42.0 + version: 0.42.0 eslint-plugin-n: specifier: 18.3.0 version: 18.3.0 eslint-plugin-no-barrel-files: - specifier: 1.3.1 - version: 1.3.1 + specifier: 2.2.0 + version: 2.2.0 eslint-plugin-perfectionist: specifier: 5.11.1 version: 5.11.1 @@ -522,14 +522,14 @@ catalogs: specifier: 1.5.0 version: 1.5.0 eslint-plugin-unicorn: - specifier: 71.1.0 - version: 71.1.0 + specifier: 75.0.0 + version: 75.0.0 eslint-plugin-yml: specifier: 3.8.1 version: 3.8.1 eventsource-parser: - specifier: 3.1.1 - version: 3.1.1 + specifier: 4.1.1 + version: 4.1.1 fast-deep-equal: specifier: 3.1.3 version: 3.1.3 @@ -615,8 +615,8 @@ catalogs: specifier: 3.0.1 version: 3.0.1 motion: - specifier: 12.43.0 - version: 12.43.0 + specifier: 13.4.0 + version: 13.4.0 negotiator: specifier: 1.1.0 version: 1.1.0 @@ -839,7 +839,7 @@ importers: version: 2.4.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) eslint-markdown: specifier: 'catalog:' - version: 0.12.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + version: 0.17.0(@eslint/markdown@8.0.3(supports-color@11.0.0))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) eslint-plugin-antfu: specifier: 'catalog:' version: 3.2.3(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) @@ -848,25 +848,25 @@ importers: version: 4.7.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(oxlint@1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.2(@types/node@25.9.7)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1)))(tailwindcss@4.3.3) eslint-plugin-command: specifier: 'catalog:' - version: 3.5.3(@typescript-eslint/typescript-estree@8.70.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0))(@typescript-eslint/utils@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) + version: 4.0.0(@typescript-eslint/typescript-estree@8.70.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0))(@typescript-eslint/utils@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) eslint-plugin-erasable-syntax-only: specifier: 'catalog:' - version: 0.4.2(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + version: 0.7.1(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) eslint-plugin-jsdoc: specifier: 'catalog:' - version: 63.3.3(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + version: 64.5.2(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) eslint-plugin-jsonc: specifier: 'catalog:' version: 3.4.2(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) eslint-plugin-markdown-preferences: specifier: 'catalog:' - version: 0.41.1(@eslint/markdown@8.0.3(supports-color@11.0.0))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + version: 0.42.0(@eslint/markdown@8.0.3(supports-color@11.0.0))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) eslint-plugin-n: specifier: 'catalog:' version: 18.3.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) eslint-plugin-no-barrel-files: specifier: 'catalog:' - version: 1.3.1(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + version: 2.2.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) eslint-plugin-perfectionist: specifier: 'catalog:' version: 5.11.1(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) @@ -884,7 +884,7 @@ importers: version: 1.5.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) eslint-plugin-unicorn: specifier: 'catalog:' - version: 71.1.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) + version: 75.0.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) eslint-plugin-yml: specifier: 'catalog:' version: 3.8.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) @@ -926,7 +926,7 @@ importers: version: 0.6.5 eventsource-parser: specifier: 'catalog:' - version: 3.1.1 + version: 4.1.1 js-yaml: specifier: 'catalog:' version: 5.4.2 @@ -1023,7 +1023,7 @@ importers: version: typescript@7.0.2 eventsource-parser: specifier: 'catalog:' - version: 3.1.1 + version: 4.1.1 tsx: specifier: 'catalog:' version: 4.23.13 @@ -1118,7 +1118,7 @@ importers: dependencies: cn: specifier: 'catalog:' - version: 0.2.6 + version: 0.3.0 devDependencies: '@base-ui/react': specifier: 'catalog:' @@ -1368,7 +1368,7 @@ importers: version: 0.27.20(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@formatjs/intl-localematcher': specifier: 'catalog:' - version: 0.8.14 + version: 0.9.0 '@heroicons/react': specifier: 'catalog:' version: 2.2.0(react@19.3.0) @@ -1476,7 +1476,7 @@ importers: version: 3.0.6(echarts@6.1.0)(react@19.3.0) elkjs: specifier: 'catalog:' - version: 0.11.1 + version: 0.12.0 embla-carousel-autoplay: specifier: 'catalog:' version: 8.6.0(embla-carousel@8.6.0) @@ -1563,7 +1563,7 @@ importers: version: 3.0.1 motion: specifier: 'catalog:' - version: 12.43.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 13.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) negotiator: specifier: 'catalog:' version: 1.1.0 @@ -1693,7 +1693,7 @@ importers: version: link:../packages/dify-ui '@rgrove/parse-xml': specifier: 'catalog:' - version: 4.2.3 + version: 5.0.0 '@storybook/addon-docs': specifier: 'catalog:' version: 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)(vite-plus@0.3.2(@types/node@25.9.7)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(esbuild@0.28.2)(happy-dom@20.14.5)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1))) @@ -1723,7 +1723,7 @@ importers: version: 10.4.2 '@testing-library/jest-dom': specifier: 'catalog:' - version: 6.9.1 + version: 7.0.1(@testing-library/dom@10.4.2)(vitest@4.1.11) '@testing-library/react': specifier: 'catalog:' version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -2180,13 +2180,13 @@ packages: '@emoji-mart/data@1.2.1': resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==} - '@es-joy/jsdoccomment@0.88.0': - resolution: {integrity: sha512-GK/HL/claLLNo5KG705auIlZMwEtmn88ofSGuLsmVZwKBqMPJhW9DiznYNq07QEqz9BPtA3LBfYImtZmhVvRAw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@es-joy/jsdoccomment@0.92.0': + resolution: {integrity: sha512-WivZqV5jPTYDQJgIMp0hTsyQESKYNY6yCJr4b0l84tYtQntkAMkc6zARh2vlRQyeg4aYHPUkCUDpvr2IWAiGpQ==} + engines: {node: ^22.22.2 || >=24.15.0} - '@es-joy/jsdoccomment@0.91.0': - resolution: {integrity: sha512-vgqlMGNNhZxwDYbUNIHj3Hskb4R28iqdXx90ufHyt/NeuTQkeqjTDslAs9I0/GCAfbxP5BpH5WsL1R1fht5Lxg==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@es-joy/jsdoccomment@0.97.0': + resolution: {integrity: sha512-EP8uoFfh6+GsdGCduYtmWAW0h7AO+Ayik9Vh5YbA2r/3N6lmJKkCNZX+q3QBXC1K6ixjQ/9igF2b7WVvLm063g==} + engines: {node: ^22.22.2 || >=24.15.0} '@es-joy/resolve.exports@1.2.0': resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} @@ -2354,6 +2354,12 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2430,10 +2436,6 @@ packages: resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@1.2.1': resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -2442,10 +2444,6 @@ packages: resolution: {integrity: sha512-iPmijIAq4hlIJB86PYmY/fcZORHtjphSqICDbwuw32A/JmkhZQ/K/6TjHE03zqf3n5yABpVcbRAMG8Mi9ojy8g==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/markdown@7.5.1': - resolution: {integrity: sha512-R8uZemG9dKTbru/DQRPblbJyXpObwKzo8rv1KYGGuPUPtjM4LXBYM9q5CIZAComzZupws3tWbDwam5AFpPLyJQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/markdown@8.0.3': resolution: {integrity: sha512-rBTSSShrq7e4O+PWfeE4azH4/CWPNrC+VGxBXiW00o3vYVJnznsZiDayj3KC9JztIMVRZxZHn2nrDIUau/4j7A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -2454,10 +2452,6 @@ packages: resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.7.2': resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -2490,8 +2484,8 @@ packages: '@formatjs/fast-memoize@3.1.7': resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==} - '@formatjs/intl-localematcher@0.8.14': - resolution: {integrity: sha512-8O7V38QmbB11+ryJ3KM/znrzXtiqgpXzqNoqDsndHZZ5/zDKTTL+cf5F0mzXkQEwhx8IlWnqYvEovf2S5EwLTw==} + '@formatjs/intl-localematcher@0.9.0': + resolution: {integrity: sha512-Z7TD3ssNry1Av1dVDoq/mzpxGyxqWwU0ClWEo+BB3k0JQTeQvUGmeK9WZGYvlqStS0UqkaKXk3dfjsb6NaHAxw==} '@heroicons/react@2.2.0': resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==} @@ -4186,9 +4180,9 @@ packages: resolution: {integrity: sha512-C7c51Nn4yTxXFKvgh2txJFNweaVcfUPQxwEUFw4aWsCmfiBDJsTSwviIF8EcwjQ6k8bPyMWCl1vw4BdxE569Cg==} engines: {node: '>= 10'} - '@rgrove/parse-xml@4.2.3': - resolution: {integrity: sha512-Jhlb+0zYez1T1yXUQs3F1qAtFuJljBVNdy9TKmLDauAXkxsOXopKYOyQ5Wm6SvP3fycav0GviX4Y15WWhGetMw==} - engines: {node: '>=14.0.0'} + '@rgrove/parse-xml@5.0.0': + resolution: {integrity: sha512-SgHFyisNAkS2ZbM8SCriUm1N0bxLiz0POKvpDeAtrnLrXaYG1PawNwu1rSjmE/vcVHbZzYWNgVP6xkep6R4uXg==} + engines: {node: '>=22.12.0'} '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -4637,6 +4631,16 @@ packages: resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/jest-dom@7.0.1': + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + vitest: 4.1.11 + peerDependenciesMeta: + vitest: + optional: true + '@testing-library/react@16.3.3': resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} engines: {node: '>=18'} @@ -5600,9 +5604,9 @@ packages: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} - are-docs-informative@0.0.2: - resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} - engines: {node: '>=14'} + are-docs-informative@0.1.1: + resolution: {integrity: sha512-sqRsNQBwbKLRX0jV5Cu5uzmtflf892n4Vukz7T659ebL4pz3mpOqCMU7lxMoBTFwnp10E3YB5ZcyHM41W5bcDA==} + engines: {node: '>=18'} argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -5625,8 +5629,8 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} - ast-v8-to-istanbul@1.0.4: - resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + ast-v8-to-istanbul@1.0.6: + resolution: {integrity: sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==} async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -5673,6 +5677,10 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -5868,8 +5876,8 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - cn@0.2.6: - resolution: {integrity: sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ==} + cn@0.3.0: + resolution: {integrity: sha512-qRoU0ARrZD3Bbj1wKFftEKxPjSnzTEdFkf22NVhW+zU9D/wajhoP+qUZ5DChhhSOBCH1onV12hrmGqfu1+5Z2Q==} engines: {node: '>=20'} hasBin: true @@ -5931,10 +5939,6 @@ packages: resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} engines: {node: '>=18'} - convert-hrtime@5.0.0: - resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} - engines: {node: '>=12'} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -6316,8 +6320,8 @@ packages: electron-to-chromium@1.5.417: resolution: {integrity: sha512-4T+DTDWuMPM4aHlHwWdAVCVWwp7LDilnhzkj+c/Lbj91XSQrLuOmZSLtS9Q4iIqjlPUbPOnC624zDVVHCHaolQ==} - elkjs@0.11.1: - resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + elkjs@0.12.0: + resolution: {integrity: sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ==} embla-carousel-autoplay@8.6.0: resolution: {integrity: sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==} @@ -6403,9 +6407,6 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.3.1: - resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} - es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -6454,11 +6455,12 @@ packages: '@eslint/json': optional: true - eslint-markdown@0.12.1: - resolution: {integrity: sha512-zQ2pnYdZtLLDW2QeeJ0szqYy7yD+ntwBe1mAibeWSo5tZaUrhPpfXqsSf6q09K9OArl9OP4/sxgSlDgZ5cQliA==} + eslint-markdown@0.17.0: + resolution: {integrity: sha512-WG5uqP9dH3Dg01hUt/LyYZcpCG9IdnZuMmiKtxEKFaF0g32GTneI2nGbnOf8uDlg9vaJC3AMAETDwz4VjYgyqg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} peerDependencies: - eslint: ^9.31.0 || ^10.0.0-rc.0 + '@eslint/markdown': ^8.0.3 + eslint: ^9.39.0 || ^10.0.0 peerDependenciesMeta: eslint: optional: true @@ -6481,15 +6483,16 @@ packages: oxlint: optional: true - eslint-plugin-command@3.5.3: - resolution: {integrity: sha512-JIhmUOW2K2Dw1K+p4mtj5potkqTZ4ZaicrTkgCygPZCWErP2+O2F46n7ZwGKapeEjiwcVqoY8u01SxNUUXWPeQ==} + eslint-plugin-command@4.0.0: + resolution: {integrity: sha512-mM1UdKu39YF5nUBVbA+PaVvpB3R3Niwjes8CQ8rJTTbgIThgrfQILgDqmtpAAQL1xGaolCoES1SfiwzwPVGkXQ==} + engines: {node: ^22.22.2 || >=24.15.0} peerDependencies: '@typescript-eslint/typescript-estree': '*' '@typescript-eslint/utils': '*' eslint: '*' - eslint-plugin-erasable-syntax-only@0.4.2: - resolution: {integrity: sha512-eLeg6CgNVY6LYPiPZnDn2JLh1HQVGoqjq1OD2GKGo70EcwbTwpWcqgi1zJJ/PJIKRO87cejCp9lwv5psrdxuKA==} + eslint-plugin-erasable-syntax-only@0.7.1: + resolution: {integrity: sha512-2zpgLgFq/HmIVQU2KNOsuuQdJQAiRdq23xpCamhatbH4ZlzinqXdDmbxGALj7VSiC9GMBHqmu4Cu7KaSqQfNhw==} engines: {node: '>=20.18.0'} peerDependencies: '@typescript-eslint/parser': '>=8' @@ -6502,11 +6505,15 @@ packages: peerDependencies: eslint: '>=8' - eslint-plugin-jsdoc@63.3.3: - resolution: {integrity: sha512-xI4IeVRzRFA2DGHrPLIxF3U+oJHU3FE+P9Zb27fVs5dPHgfcpoAs0PyCbznVhK7pwR+9BPUztFeXSgpw/CL4Yg==} - engines: {node: ^22.13.0 || >=24} + eslint-plugin-jsdoc@64.5.2: + resolution: {integrity: sha512-GirLf/jpVQ/HSLVT98ztIrJ8GUlWWrrwExkofqzeIjjOxlqFtyqnpkRs30FLwEKh0xHEpgy35CU0qFn0I/Mh9w==} + engines: {node: ^22.22.2 || >=24.15.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true eslint-plugin-jsonc@3.4.2: resolution: {integrity: sha512-q5xzjCYFQuhFomTbctXzd3STfDJ8XduvaigjQmMEbP2gzSKrc58y3Fv6D775/cIK1/sRUn+4kpljiU2iW8k0zw==} @@ -6514,8 +6521,8 @@ packages: peerDependencies: eslint: '>=9.38.0' - eslint-plugin-markdown-preferences@0.41.1: - resolution: {integrity: sha512-Xi4rlT7oBZ8PMGDl7J9khgO2vF9X0F/6ag05/25Vyq7r3llaK95x9D6DpzXidxC2Gagl/e8bp2Hw47r4I3wWSA==} + eslint-plugin-markdown-preferences@0.42.0: + resolution: {integrity: sha512-LVoGwsRDeLFooaj9OndJrof5OslnQQnZKStP+GX0DMnAt6Nt2tgJzP+URRNXqS54lYnIa0m6pBo7CO1n/utTsw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: '@eslint/markdown': ^7.4.0 || ^8.0.0 @@ -6534,10 +6541,14 @@ packages: typescript: optional: true - eslint-plugin-no-barrel-files@1.3.1: - resolution: {integrity: sha512-y7OX5kyH7PMNRFhLF6SmM4JapxvaxExrgWPndPNTzilpO5uBqybuN480g3E8TTxT3OLOOhQDynmcJ0dnipIyNA==} + eslint-plugin-no-barrel-files@2.2.0: + resolution: {integrity: sha512-bksrf75UqNXwKnTdK6lCAZI+Ci0b2Mvt9VOY2wBq8Tqu4yxH36bPePaT6Cn4jOdBiY2eWzM6rxXVjOscwiUE4g==} peerDependencies: eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 + typescript: ^5.6.3 || ^6.0.0 + peerDependenciesMeta: + typescript: + optional: true eslint-plugin-perfectionist@5.11.1: resolution: {integrity: sha512-xloHScnOpin9Jy3CegfzoBeY33ASk0spLyM2sJs0LyNHQjv9m0osUD2PYNT7NrJieZbUR2Ebn4A5Q0+0dOcv+A==} @@ -6609,8 +6620,8 @@ packages: peerDependencies: eslint: '>=9.38.0' - eslint-plugin-unicorn@71.1.0: - resolution: {integrity: sha512-dn3YmR3qLLUeYyo/os3ubZ7UHQJ1WbBAgC9cIhnLTyMj9J6kivuc2U1fCmYetLexUlTDVYtBqhjSj/VaebTe6Q==} + eslint-plugin-unicorn@75.0.0: + resolution: {integrity: sha512-72I7eSn+/z5CpgValFBl9mp2JE5UPD+nUUyB/VIGUMF1rCP1i37mA05zfIAVIU312A+qCZ38T8tSZr3oaMv4Mw==} engines: {node: '>=22'} peerDependencies: eslint: '>=10.4' @@ -6680,9 +6691,9 @@ packages: event-target-bus@1.1.0: resolution: {integrity: sha512-24JX34nM0qRcsj7KvtLbVpMcX8ZQYmb0+0a6viaV2GJtVFg5UGyJyDLITkBipJIG0fSscNskI2PxdhTBIMeg8g==} - eventsource-parser@3.1.1: - resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} - engines: {node: '>=18.0.0'} + eventsource-parser@4.1.1: + resolution: {integrity: sha512-zaWNqeSLeyzEAAt2Q6aDBRDRk/VnPmsKqvToyGvv6aahvLv71A4Jw7jG93Hckf11zXurbqgsffvpSgKPoeU1Qw==} + engines: {node: '>=22.12'} expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} @@ -6788,15 +6799,12 @@ packages: react-dom: optional: true - framer-motion@12.43.0: - resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==} + framer-motion@13.4.0: + resolution: {integrity: sha512-HCpqKM9BTu6UYKtj0u6J1QNxojnnirNcghcSDZ+SkpiL3myxvtsgXPS3ZGEELC2eSma7YiA937rAEXtRyydSXQ==} peerDependencies: - '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true react: optional: true react-dom: @@ -6815,10 +6823,6 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - function-timeout@1.0.2: - resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} - engines: {node: '>=18'} - fuse.js@7.5.0: resolution: {integrity: sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow==} engines: {node: '>=10'} @@ -7114,10 +7118,6 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - is-identifier@1.1.0: - resolution: {integrity: sha512-NhOds0mDx9lJu+1lBRO0xbwFo5nobA7GCk/0e5xjr6+6XugX985+0OyGX35BNrTkPAsdLcIKg02HUQJOK8D8kw==} - engines: {node: '>=18'} - is-in-ssh@1.0.0: resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} engines: {node: '>=20'} @@ -7218,21 +7218,17 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true js-yaml@5.4.2: resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==} hasBin: true - jsdoc-type-pratt-parser@7.2.0: - resolution: {integrity: sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==} - engines: {node: '>=20.0.0'} - - jsdoc-type-pratt-parser@8.0.0: - resolution: {integrity: sha512-uQu/fXVqVaMg6gM8/E5G5+eygVcZ1NV0Z51CvqhNa2bDWxvHMl484ETr6vph4oPyC+KUcbP/w2W2pewfCiR9aQ==} - engines: {node: '>=20.0.0'} + jsdoc-type-pratt-parser@9.0.1: + resolution: {integrity: sha512-1LJ/negRKJxH0+ulOsJYEpUgzIs1DAOz9QctuS4In2LW/Ro3l8C4ej5al+5FU2hBGEOAY3R3SCnP6x8226JMIw==} + engines: {node: ^22.22.2 || >=24.15.0} jsdoc-type-pratt-parser@9.2.1: resolution: {integrity: sha512-V4Ww4EHnTcTLSOMoB0FsF72JhQvcAsriCm/LWnxJeGWoxIjEL2l9na11abQok5SYShq8m0Gl02el/xAbTCulvQ==} @@ -7541,9 +7537,8 @@ packages: magicast@0.5.3: resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} - make-asynchronous@1.1.0: - resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} - engines: {node: '>=18'} + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -7756,6 +7751,10 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -7782,21 +7781,18 @@ packages: moo-color@1.0.3: resolution: {integrity: sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==} - motion-dom@12.43.0: - resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==} + motion-dom@13.3.0: + resolution: {integrity: sha512-AmAnB6pHdZ1vHGzWzuzteG6Q3j1lHKeX/lbST6jGlgm0cjvLUgaCk1xhOuPMOy5SgovN6wnnUdwFrkcsus7Ftg==} - motion-utils@12.39.0: - resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + motion-utils@13.3.0: + resolution: {integrity: sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==} - motion@12.43.0: - resolution: {integrity: sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==} + motion@13.4.0: + resolution: {integrity: sha512-aiNSA29N+vgV5p5cJSB5cFzx8KTuuxB99z8zGtxsaqlwKtBK6sq8YgPRpkVQLkbJMOcLDU0q/ShdT95qpBy/0g==} peerDependencies: - '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true react: optional: true react-dom: @@ -8075,6 +8071,10 @@ packages: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + obug@3.0.0: resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} engines: {node: '>=12.20.0'} @@ -8179,10 +8179,6 @@ packages: vite-plus: optional: true - p-event@6.0.1: - resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} - engines: {node: '>=16.17'} - p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -8191,10 +8187,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-timeout@6.1.4: - resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} - engines: {node: '>=14.16'} - package-manager-detector@1.8.0: resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} @@ -8349,10 +8341,6 @@ packages: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.26: - resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -8425,9 +8413,6 @@ packages: resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==} engines: {node: '>=14.18.0'} - rc9@3.0.1: - resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} - rc9@3.1.0: resolution: {integrity: sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==} @@ -8841,8 +8826,8 @@ packages: state-local@1.0.7: resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} - std-env@4.1.0: - resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} std-semver@1.0.8: resolution: {integrity: sha512-9SN0XIjBBXCT6ZXXVnScJN4KP2RyFg6B8sEoFlugVHMANysfaEni4LTWlvUQQ/R0wgZl1Ovt9KBQbzn21kHoZA==} @@ -8948,10 +8933,6 @@ packages: stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} - super-regex@1.1.0: - resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} - engines: {node: '>=18'} - supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -9014,10 +8995,6 @@ packages: resolution: {integrity: sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==} engines: {node: '>=18'} - time-span@5.1.0: - resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} - engines: {node: '>=12'} - tiny-case@1.0.3: resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} @@ -9037,6 +9014,10 @@ packages: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -9053,6 +9034,10 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} @@ -9475,9 +9460,6 @@ packages: web-vitals@5.1.0: resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} - web-worker@1.5.0: - resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} - webpack-sources@3.5.0: resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} engines: {node: '>=10.13.0'} @@ -9776,7 +9758,7 @@ snapshots: '@amplitude/rrweb-snapshot@2.1.0': dependencies: - postcss: 8.5.26 + postcss: 8.5.28 '@amplitude/rrweb-types@2.0.0-alpha.40': {} @@ -10213,21 +10195,21 @@ snapshots: '@emoji-mart/data@1.2.1': {} - '@es-joy/jsdoccomment@0.88.0': + '@es-joy/jsdoccomment@0.92.0': dependencies: '@types/estree': 1.0.9 - '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/types': 8.70.0 comment-parser: 1.4.7 esquery: 1.7.0 - jsdoc-type-pratt-parser: 7.2.0 + jsdoc-type-pratt-parser: 9.0.1 - '@es-joy/jsdoccomment@0.91.0': + '@es-joy/jsdoccomment@0.97.0': dependencies: '@types/estree': 1.0.9 - '@typescript-eslint/types': 8.69.0 - comment-parser: 1.4.7 + '@typescript-eslint/types': 8.70.0 + comment-parser: 1.4.8 esquery: 1.7.0 - jsdoc-type-pratt-parser: 8.0.0 + jsdoc-type-pratt-parser: 9.2.1 '@es-joy/resolve.exports@1.2.0': {} @@ -10315,6 +10297,11 @@ snapshots: eslint: 10.10.0(jiti@2.7.0)(supports-color@11.0.0) ignore: 7.0.8 + '@eslint-community/eslint-utils@4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))': + dependencies: + eslint: 10.10.0(jiti@2.7.0)(supports-color@11.0.0) + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))': dependencies: eslint: 10.10.0(jiti@2.7.0)(supports-color@11.0.0) @@ -10426,10 +10413,6 @@ snapshots: dependencies: '@eslint/core': 1.2.1 - '@eslint/core@0.17.0': - dependencies: - '@types/json-schema': 7.0.15 - '@eslint/core@1.2.1': dependencies: '@types/json-schema': 7.0.15 @@ -10439,20 +10422,6 @@ snapshots: mdn-data: 2.29.0 source-map-js: 1.2.1 - '@eslint/markdown@7.5.1(supports-color@11.0.0)': - dependencies: - '@eslint/core': 0.17.0 - '@eslint/plugin-kit': 0.4.1 - github-slugger: 2.0.0 - mdast-util-from-markdown: 2.0.3(supports-color@11.0.0) - mdast-util-frontmatter: 2.0.1(supports-color@11.0.0) - mdast-util-gfm: 3.1.0(supports-color@11.0.0) - micromark-extension-frontmatter: 2.0.0 - micromark-extension-gfm: 3.0.0 - micromark-util-normalize-identifier: 2.0.1 - transitivePeerDependencies: - - supports-color - '@eslint/markdown@8.0.3(supports-color@11.0.0)': dependencies: '@eslint/core': 1.2.1 @@ -10471,11 +10440,6 @@ snapshots: '@eslint/object-schema@3.0.5': {} - '@eslint/plugin-kit@0.4.1': - dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 - '@eslint/plugin-kit@0.7.2': dependencies: '@eslint/core': 1.2.1 @@ -10513,7 +10477,7 @@ snapshots: '@formatjs/fast-memoize@3.1.7': {} - '@formatjs/intl-localematcher@0.8.14': + '@formatjs/intl-localematcher@0.9.0': dependencies: '@formatjs/fast-memoize': 3.1.7 @@ -10534,7 +10498,7 @@ snapshots: dependencies: '@jsdevtools/ono': 7.1.3 '@types/json-schema': 7.0.15 - js-yaml: 4.3.0 + js-yaml: 4.2.0 '@hey-api/openapi-ts@0.98.2(@typescript/typescript6@6.0.2)(magicast@0.5.3)': dependencies: @@ -10772,7 +10736,7 @@ snapshots: '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jsdevtools/ono@7.1.3': {} @@ -11785,7 +11749,7 @@ snapshots: '@resvg/resvg-wasm@2.4.0': {} - '@rgrove/parse-xml@4.2.3': {} + '@rgrove/parse-xml@5.0.0': {} '@rolldown/pluginutils@1.0.1': {} @@ -12302,6 +12266,18 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@4.1.11)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.2 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + optionalDependencies: + vitest: 4.1.11(@types/node@25.9.7)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.2(@types/node@25.9.7)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1))(happy-dom@20.14.5) + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@babel/runtime': 7.29.7 @@ -12858,7 +12834,7 @@ snapshots: '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.2(@types/node@25.9.7)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1))(vitest@4.1.11) '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.2(@types/node@25.9.7)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1)) playwright: 1.63.0 - tinyrainbow: 3.1.0 + tinyrainbow: 3.1.1 vitest: 4.1.11(@types/node@25.9.7)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.2(@types/node@25.9.7)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1))(happy-dom@20.12.0) transitivePeerDependencies: - bufferutil @@ -12871,7 +12847,7 @@ snapshots: '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.2(@types/node@25.9.7)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.1))(vitest@4.1.11) '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.2(@types/node@25.9.7)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.1)) playwright: 1.63.0 - tinyrainbow: 3.1.0 + tinyrainbow: 3.1.1 vitest: 4.1.11(@types/node@25.9.7)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.2(@types/node@25.9.7)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.1))(happy-dom@20.12.0) transitivePeerDependencies: - bufferutil @@ -12942,14 +12918,14 @@ snapshots: dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.11 - ast-v8-to-istanbul: 1.0.4 + ast-v8-to-istanbul: 1.0.6 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 - magicast: 0.5.3 - obug: 2.1.3 - std-env: 4.1.0 - tinyrainbow: 3.1.0 + magicast: 0.5.5 + obug: 2.2.1 + std-env: 4.2.0 + tinyrainbow: 3.1.1 vitest: 4.1.11(@types/node@25.9.7)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.2(@types/node@25.9.7)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1))(happy-dom@20.12.0) optionalDependencies: '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.2(@types/node@25.9.7)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1))(vitest@4.1.11) @@ -13030,7 +13006,7 @@ snapshots: '@oxc-project/runtime': 0.149.0 '@oxc-project/types': 0.149.0 lightningcss: 1.33.0 - postcss: 8.5.26 + postcss: 8.5.28 yuku-codegen: 0.9.5 yuku-parser: 0.9.5 optionalDependencies: @@ -13055,7 +13031,7 @@ snapshots: '@oxc-project/runtime': 0.149.0 '@oxc-project/types': 0.149.0 lightningcss: 1.33.0 - postcss: 8.5.26 + postcss: 8.5.28 yuku-codegen: 0.9.5 yuku-parser: 0.9.5 optionalDependencies: @@ -13261,7 +13237,7 @@ snapshots: ansis@4.3.1: {} - are-docs-informative@0.0.2: {} + are-docs-informative@0.1.1: {} argparse@2.0.1: {} @@ -13283,7 +13259,7 @@ snapshots: dependencies: tslib: 2.8.1 - ast-v8-to-istanbul@1.0.4: + ast-v8-to-istanbul@1.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -13321,6 +13297,10 @@ snapshots: bowser@2.14.1: {} + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -13363,14 +13343,14 @@ snapshots: confbox: 0.2.4 defu: 6.1.7 dotenv: 17.4.2 - exsolve: 1.1.0 + exsolve: 1.1.1 giget: 3.3.0 jiti: 2.7.0 ohash: 2.0.11 pathe: 2.0.3 perfect-debounce: 2.1.0 - pkg-types: 2.3.1 - rc9: 3.0.1 + pkg-types: 2.3.3 + rc9: 3.1.0 optionalDependencies: magicast: 0.5.3 @@ -13515,7 +13495,7 @@ snapshots: clsx@2.1.1: {} - cn@0.2.6: {} + cn@0.3.0: {} code-inspector-plugin@1.6.6(supports-color@11.0.0): dependencies: @@ -13568,8 +13548,6 @@ snapshots: content-type@2.1.0: {} - convert-hrtime@5.0.0: {} - convert-source-map@2.0.0: {} copy-to-clipboard@4.0.2: {} @@ -13962,7 +13940,7 @@ snapshots: electron-to-chromium@1.5.417: {} - elkjs@0.11.1: {} + elkjs@0.12.0: {} embla-carousel-autoplay@8.6.0(embla-carousel@8.6.0): dependencies: @@ -14038,8 +14016,6 @@ snapshots: es-module-lexer@1.7.0: {} - es-module-lexer@2.3.1: {} - es-module-lexer@2.3.2: {} es-toolkit@1.52.0: {} @@ -14097,15 +14073,13 @@ snapshots: esquery: 1.7.0 jsonc-eslint-parser: 3.3.0 - eslint-markdown@0.12.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-markdown@0.17.0(@eslint/markdown@8.0.3(supports-color@11.0.0))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)): dependencies: - '@eslint/markdown': 7.5.1(supports-color@11.0.0) + '@eslint/markdown': 8.0.3(supports-color@11.0.0) micromark-util-normalize-identifier: 2.0.1 parse5: 8.0.1 optionalDependencies: eslint: 10.10.0(jiti@2.7.0)(supports-color@11.0.0) - transitivePeerDependencies: - - supports-color eslint-plugin-antfu@3.2.3(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)): dependencies: @@ -14129,17 +14103,17 @@ snapshots: - '@eslint/css' - typescript - eslint-plugin-command@3.5.3(@typescript-eslint/typescript-estree@8.70.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0))(@typescript-eslint/utils@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)): + eslint-plugin-command@4.0.0(@typescript-eslint/typescript-estree@8.70.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0))(@typescript-eslint/utils@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)): dependencies: - '@es-joy/jsdoccomment': 0.88.0 + '@es-joy/jsdoccomment': 0.92.0 '@typescript-eslint/typescript-estree': 8.70.0(@typescript/typescript6@6.0.2)(supports-color@11.0.0) '@typescript-eslint/utils': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) eslint: 10.10.0(jiti@2.7.0)(supports-color@11.0.0) - eslint-plugin-erasable-syntax-only@0.4.2(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-erasable-syntax-only@0.7.1(@typescript-eslint/parser@8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: '@typescript-eslint/parser': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) - '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/utils': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) cached-factory: 0.3.0 eslint: 10.10.0(jiti@2.7.0)(supports-color@11.0.0) typescript: '@typescript/typescript6@6.0.2' @@ -14153,14 +14127,15 @@ snapshots: eslint: 10.10.0(jiti@2.7.0)(supports-color@11.0.0) eslint-compat-utils: 0.5.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) - eslint-plugin-jsdoc@63.3.3(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-jsdoc@64.5.2(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: - '@es-joy/jsdoccomment': 0.91.0 + '@es-joy/jsdoccomment': 0.97.0 '@es-joy/resolve.exports': 1.2.0 - are-docs-informative: 0.0.2 - comment-parser: 1.4.7 + '@typescript-eslint/utils': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + are-docs-informative: 0.1.1 + comment-parser: 1.4.8 debug: 4.4.3(supports-color@11.0.0) - escape-string-regexp: 4.0.0 + escape-string-regexp: 5.0.0 eslint: 10.10.0(jiti@2.7.0)(supports-color@11.0.0) espree: 11.2.0 esquery: 1.7.0 @@ -14170,6 +14145,8 @@ snapshots: semver: 7.8.5 spdx-expression-parse: 5.0.0 to-valid-identifier: 1.0.0 + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color @@ -14188,7 +14165,7 @@ snapshots: transitivePeerDependencies: - '@eslint/json' - eslint-plugin-markdown-preferences@0.41.1(@eslint/markdown@8.0.3(supports-color@11.0.0))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-markdown-preferences@0.42.0(@eslint/markdown@8.0.3(supports-color@11.0.0))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: '@eslint/markdown': 8.0.3(supports-color@11.0.0) diff-sequences: 29.6.3 @@ -14203,6 +14180,7 @@ snapshots: micromark-extension-math: 3.1.0 micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 micromark-util-symbol: 2.0.1 string-width: 8.2.2 transitivePeerDependencies: @@ -14222,13 +14200,16 @@ snapshots: optionalDependencies: typescript: '@typescript/typescript6@6.0.2' - eslint-plugin-no-barrel-files@1.3.1(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): + eslint-plugin-no-barrel-files@2.2.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: - '@typescript-eslint/utils': 8.69.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/parser': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) + '@typescript-eslint/utils': 8.70.0(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) eslint: 10.10.0(jiti@2.7.0)(supports-color@11.0.0) + minimatch: 10.2.6 + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - - typescript eslint-plugin-perfectionist@5.11.1(@typescript/typescript6@6.0.2)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0): dependencies: @@ -14379,26 +14360,29 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-unicorn@71.1.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)): + eslint-plugin-unicorn@75.0.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) + '@eslint/css-tree': 4.0.5 browserslist: 4.28.8 change-case: 5.4.4 ci-info: 4.4.0 core-js-compat: 3.50.0 detect-indent: 7.0.2 + entities: 8.0.0 eslint: 10.10.0(jiti@2.7.0)(supports-color@11.0.0) find-up-simple: 1.0.1 globals: 17.11.0 + identifier-regex: 1.1.0 indent-string: 5.0.0 is-builtin-module: 5.0.0 - is-identifier: 1.1.0 pluralize: 8.0.0 quote-js-string: 0.1.0 regjsparser: 0.13.2 reserved-identifiers: 1.2.0 semver: 7.8.5 strip-indent: 4.1.1 + yaml: 2.9.1 eslint-plugin-yml@3.8.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)): dependencies: @@ -14489,7 +14473,7 @@ snapshots: event-target-bus@1.1.0: {} - eventsource-parser@3.1.1: {} + eventsource-parser@4.1.1: {} expand-template@2.0.3: optional: true @@ -14591,10 +14575,10 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - framer-motion@12.43.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + framer-motion@13.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - motion-dom: 12.43.0 - motion-utils: 12.39.0 + motion-dom: 13.3.0 + motion-utils: 13.3.0 tslib: 2.8.1 optionalDependencies: react: 19.3.0 @@ -14609,8 +14593,6 @@ snapshots: fsevents@2.3.3: optional: true - function-timeout@1.0.2: {} - fuse.js@7.5.0: {} gensync@1.0.0-beta.2: {} @@ -14955,11 +14937,6 @@ snapshots: is-hexadecimal@2.0.1: {} - is-identifier@1.1.0: - dependencies: - identifier-regex: 1.1.0 - super-regex: 1.1.0 - is-in-ssh@1.0.0: {} is-inside-container@1.0.0: @@ -15028,7 +15005,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.3.0: + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -15036,9 +15013,9 @@ snapshots: dependencies: argparse: 2.0.1 - jsdoc-type-pratt-parser@7.2.0: {} - - jsdoc-type-pratt-parser@8.0.0: {} + jsdoc-type-pratt-parser@9.0.1: + dependencies: + '@types/estree': 1.0.9 jsdoc-type-pratt-parser@9.2.1: dependencies: @@ -15297,12 +15274,13 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 source-map-js: 1.2.1 + optional: true - make-asynchronous@1.1.0: + magicast@0.5.5: dependencies: - p-event: 6.0.1 - type-fest: 4.41.0 - web-worker: 1.5.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 make-dir@4.0.0: dependencies: @@ -15783,6 +15761,10 @@ snapshots: dependencies: brace-expansion: 5.0.6 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + minimist@1.2.8: {} minipass@7.1.3: {} @@ -15809,15 +15791,15 @@ snapshots: dependencies: color-name: 1.1.4 - motion-dom@12.43.0: + motion-dom@13.3.0: dependencies: - motion-utils: 12.39.0 + motion-utils: 13.3.0 - motion-utils@12.39.0: {} + motion-utils@13.3.0: {} - motion@12.43.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + motion@13.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - framer-motion: 12.43.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + framer-motion: 13.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) tslib: 2.8.1 optionalDependencies: react: 19.3.0 @@ -15956,6 +15938,8 @@ snapshots: obug@2.1.3: {} + obug@2.2.1: {} + obug@3.0.0: {} ohash@2.0.11: {} @@ -16306,10 +16290,6 @@ snapshots: oxlint-tsgolint: 7.0.2001 vite-plus: 0.3.2(@types/node@25.9.7)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.1) - p-event@6.0.1: - dependencies: - p-timeout: 6.1.4 - p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -16318,8 +16298,6 @@ snapshots: dependencies: p-limit: 3.1.0 - p-timeout@6.1.4: {} - package-manager-detector@1.8.0: {} pad-right@0.2.2: @@ -16477,12 +16455,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.26: - dependencies: - nanoid: 3.3.18 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.28: dependencies: nanoid: 3.3.18 @@ -16563,11 +16535,6 @@ snapshots: radash@12.1.1: {} - rc9@3.0.1: - dependencies: - defu: 6.1.7 - destr: 2.0.5 - rc9@3.1.0: dependencies: defu: 6.1.7 @@ -17083,7 +17050,7 @@ snapshots: state-local@1.0.7: {} - std-env@4.1.0: {} + std-env@4.2.0: {} std-semver@1.0.8: {} @@ -17232,12 +17199,6 @@ snapshots: stylis@4.4.0: {} - super-regex@1.1.0: - dependencies: - function-timeout: 1.0.2 - make-asynchronous: 1.1.0 - time-span: 5.1.0 - supports-color@10.2.2: {} supports-color@11.0.0: {} @@ -17299,10 +17260,6 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - time-span@5.1.0: - dependencies: - convert-hrtime: 5.0.0 - tiny-case@1.0.3: {} tiny-inflate@1.0.3: {} @@ -17315,6 +17272,8 @@ snapshots: tinyexec@1.2.4: {} + tinyexec@1.3.0: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) @@ -17326,6 +17285,8 @@ snapshots: tinyrainbow@3.1.0: {} + tinyrainbow@3.1.1: {} + tinyspy@4.0.4: {} tldts-core@7.4.13: {} @@ -17872,17 +17833,17 @@ snapshots: '@vitest/snapshot': 4.1.11 '@vitest/spy': 4.1.11 '@vitest/utils': 4.1.11 - es-module-lexer: 2.3.1 + es-module-lexer: 2.3.2 expect-type: 1.4.0 magic-string: 0.30.21 - obug: 2.1.3 + obug: 2.2.1 pathe: 2.0.3 picomatch: 4.0.7 - std-env: 4.1.0 + std-env: 4.2.0 tinybench: 2.9.0 - tinyexec: 1.2.4 + tinyexec: 1.3.0 tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 + tinyrainbow: 3.1.1 vite: '@voidzero-dev/vite-plus-core@0.3.2(@types/node@25.9.7)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1)' why-is-node-running: 2.3.0 optionalDependencies: @@ -17903,17 +17864,17 @@ snapshots: '@vitest/snapshot': 4.1.11 '@vitest/spy': 4.1.11 '@vitest/utils': 4.1.11 - es-module-lexer: 2.3.1 + es-module-lexer: 2.3.2 expect-type: 1.4.0 magic-string: 0.30.21 - obug: 2.1.3 + obug: 2.2.1 pathe: 2.0.3 picomatch: 4.0.7 - std-env: 4.1.0 + std-env: 4.2.0 tinybench: 2.9.0 - tinyexec: 1.2.4 + tinyexec: 1.3.0 tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 + tinyrainbow: 3.1.1 vite: '@voidzero-dev/vite-plus-core@0.3.2(@types/node@25.9.7)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1)' why-is-node-running: 2.3.0 optionalDependencies: @@ -17934,17 +17895,17 @@ snapshots: '@vitest/snapshot': 4.1.11 '@vitest/spy': 4.1.11 '@vitest/utils': 4.1.11 - es-module-lexer: 2.3.1 + es-module-lexer: 2.3.2 expect-type: 1.4.0 magic-string: 0.30.21 - obug: 2.1.3 + obug: 2.2.1 pathe: 2.0.3 picomatch: 4.0.7 - std-env: 4.1.0 + std-env: 4.2.0 tinybench: 2.9.0 - tinyexec: 1.2.4 + tinyexec: 1.3.0 tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 + tinyrainbow: 3.1.1 vite: '@voidzero-dev/vite-plus-core@0.3.2(@types/node@25.9.7)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.1)' why-is-node-running: 2.3.0 optionalDependencies: @@ -17966,8 +17927,6 @@ snapshots: web-vitals@5.1.0: {} - web-worker@1.5.0: {} - webpack-sources@3.5.0: {} webpack-virtual-modules@0.6.2: {} @@ -18148,7 +18107,7 @@ time: '@eslint-react/eslint-plugin@5.19.1': '2026-09-14T17:56:33.726Z' '@eslint/markdown@8.0.3': '2026-07-01T13:28:44.339Z' '@floating-ui/react@0.27.20': '2026-07-11T08:41:33.223Z' - '@formatjs/intl-localematcher@0.8.14': '2026-09-12T10:54:55.218Z' + '@formatjs/intl-localematcher@0.9.0': '2026-09-12T16:07:12.757Z' '@heroicons/react@2.2.0': '2024-11-18T15:33:27.317Z' '@hey-api/openapi-ts@0.98.2': '2026-06-08T05:37:17.524Z' '@hono/node-server@2.1.1': '2026-08-14T01:14:02.732Z' @@ -18169,7 +18128,7 @@ time: '@orpc/tanstack-query@1.15.1': '2026-09-15T07:09:56.725Z' '@playwright/test@1.63.0': '2026-09-04T22:44:00.304Z' '@remixicon/react@4.9.0': '2026-01-29T10:53:18.993Z' - '@rgrove/parse-xml@4.2.3': '2026-07-26T23:12:17.833Z' + '@rgrove/parse-xml@5.0.0': '2026-08-30T03:19:42.049Z' '@sentry/react@10.75.0': '2026-09-16T14:58:08.681Z' '@storybook/addon-a11y@10.6.0': '2026-09-02T13:57:15.272Z' '@storybook/addon-docs@10.6.0': '2026-09-02T13:56:43.113Z' @@ -18194,7 +18153,7 @@ time: '@tanstack/react-query@5.103.1': '2026-09-16T15:01:15.056Z' '@tanstack/react-virtual@3.14.13': '2026-09-14T10:22:44.535Z' '@testing-library/dom@10.4.2': '2026-09-13T18:08:36.116Z' - '@testing-library/jest-dom@6.9.1': '2025-10-01T20:04:22.720Z' + '@testing-library/jest-dom@7.0.1': '2026-08-09T23:44:33.598Z' '@testing-library/react@16.3.3': '2026-08-27T17:41:18.735Z' '@testing-library/user-event@14.6.7': '2026-09-02T01:56:32.109Z' '@tsslint/cli@3.1.4': '2026-06-16T18:21:29.075Z' @@ -18223,7 +18182,7 @@ time: chokidar@5.0.0: '2025-11-25T23:28:06.854Z' class-variance-authority@0.7.1: '2024-11-26T08:20:34.604Z' cli-table3@0.6.5: '2024-05-12T16:36:50.079Z' - cn@0.2.6: '2026-09-06T12:13:35.681Z' + cn@0.3.0: '2026-09-12T11:11:13.029Z' code-inspector-plugin@1.6.6: '2026-07-07T04:00:26.523Z' concurrently@10.0.5: '2026-08-15T03:33:20.323Z' copy-to-clipboard@4.0.2: '2026-04-24T22:15:18.933Z' @@ -18233,32 +18192,32 @@ time: dompurify@3.4.15: '2026-09-06T11:39:57.312Z' echarts-for-react@3.0.6: '2026-01-21T04:38:21.243Z' echarts@6.1.0: '2026-05-19T17:52:11.076Z' - elkjs@0.11.1: '2026-03-03T12:21:48.463Z' + elkjs@0.12.0: '2026-07-17T08:07:54.693Z' embla-carousel-autoplay@8.6.0: '2025-04-04T17:37:46.303Z' embla-carousel-fade@8.6.0: '2025-04-04T17:37:50.278Z' embla-carousel-react@8.6.0: '2025-04-04T17:37:53.976Z' emoji-mart@5.6.0: '2024-04-25T14:22:21.440Z' es-toolkit@1.52.0: '2026-08-28T07:22:17.687Z' eslint-config-flat-gitignore@2.4.0: '2026-08-27T07:24:43.063Z' - eslint-markdown@0.12.1: '2026-07-10T23:35:26.055Z' + eslint-markdown@0.17.0: '2026-09-16T06:20:36.564Z' eslint-plugin-antfu@3.2.3: '2026-05-11T02:24:38.348Z' eslint-plugin-better-tailwindcss@4.7.0: '2026-07-19T13:26:01.366Z' - eslint-plugin-command@3.5.3: '2026-07-14T04:59:31.991Z' - eslint-plugin-erasable-syntax-only@0.4.2: '2026-06-18T00:56:01.038Z' - eslint-plugin-jsdoc@63.3.3: '2026-08-02T18:02:10.488Z' + eslint-plugin-command@4.0.0: '2026-07-31T04:38:09.766Z' + eslint-plugin-erasable-syntax-only@0.7.1: '2026-08-19T04:46:35.017Z' + eslint-plugin-jsdoc@64.5.2: '2026-09-16T13:00:55.286Z' eslint-plugin-jsonc@3.4.2: '2026-08-22T12:13:35.450Z' - eslint-plugin-markdown-preferences@0.41.1: '2026-04-09T23:28:41.552Z' + eslint-plugin-markdown-preferences@0.42.0: '2026-07-14T00:58:47.572Z' eslint-plugin-n@18.3.0: '2026-08-08T14:03:55.663Z' - eslint-plugin-no-barrel-files@1.3.1: '2026-04-12T18:28:18.653Z' + eslint-plugin-no-barrel-files@2.2.0: '2026-07-28T19:13:34.360Z' eslint-plugin-perfectionist@5.11.1: '2026-09-15T11:18:22.662Z' eslint-plugin-pnpm@1.9.1: '2026-09-02T01:31:47.410Z' eslint-plugin-regexp@3.3.0: '2026-09-04T01:16:25.907Z' eslint-plugin-storybook@10.6.0: '2026-09-02T14:01:21.299Z' eslint-plugin-toml@1.5.0: '2026-07-25T01:46:13.957Z' - eslint-plugin-unicorn@71.1.0: '2026-07-06T22:07:48.695Z' + eslint-plugin-unicorn@75.0.0: '2026-09-16T06:50:59.617Z' eslint-plugin-yml@3.8.1: '2026-08-05T03:50:22.441Z' eslint@10.10.0: '2026-09-04T14:34:21.799Z' - eventsource-parser@3.1.1: '2026-08-10T15:55:29.938Z' + eventsource-parser@4.1.1: '2026-09-15T18:30:29.195Z' fast-deep-equal@3.1.3: '2020-06-08T07:27:28.474Z' foxact@0.3.11: '2026-09-05T12:25:54.102Z' fuse.js@7.5.0: '2026-07-13T17:23:36.705Z' @@ -18288,7 +18247,7 @@ time: mermaid@11.17.2: '2026-08-25T11:52:39.930Z' mime@4.1.0: '2025-09-12T17:53:01.376Z' mitt@3.0.1: '2023-07-04T17:31:47.638Z' - motion@12.43.0: '2026-07-28T14:29:31.291Z' + motion@13.4.0: '2026-09-16T13:10:18.420Z' negotiator@1.1.0: '2026-08-20T16:45:05.235Z' next-themes@0.4.6: '2025-03-11T21:02:05.882Z' next@16.3.5: '2026-09-11T17:26:46.049Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2b2e2a51380..e6160a92379 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -73,7 +73,7 @@ catalog: '@eslint-react/eslint-plugin': 5.19.1 '@eslint/markdown': 8.0.3 '@floating-ui/react': 0.27.20 - '@formatjs/intl-localematcher': 0.8.14 + '@formatjs/intl-localematcher': 0.9.0 '@heroicons/react': 2.2.0 '@hey-api/openapi-ts': 0.98.2 '@hono/node-server': 2.1.1 @@ -95,7 +95,7 @@ catalog: '@orpc/tanstack-query': 1.15.1 '@playwright/test': 1.63.0 '@remixicon/react': 4.9.0 - '@rgrove/parse-xml': 4.2.3 + '@rgrove/parse-xml': 5.0.0 '@sentry/react': 10.75.0 '@storybook/addon-a11y': 10.6.0 '@storybook/addon-docs': 10.6.0 @@ -120,7 +120,7 @@ catalog: '@tanstack/react-query': 5.103.1 '@tanstack/react-virtual': 3.14.13 '@testing-library/dom': 10.4.2 - '@testing-library/jest-dom': 6.9.1 + '@testing-library/jest-dom': 7.0.1 '@testing-library/react': 16.3.3 '@testing-library/user-event': 14.6.7 '@tsslint/cli': 3.1.4 @@ -148,7 +148,7 @@ catalog: chokidar: 5.0.0 class-variance-authority: 0.7.1 cli-table3: 0.6.5 - cn: 0.2.6 + cn: 0.3.0 code-inspector-plugin: 1.6.6 concurrently: 10.0.5 copy-to-clipboard: 4.0.2 @@ -158,7 +158,7 @@ catalog: dompurify: 3.4.15 echarts: 6.1.0 echarts-for-react: 3.0.6 - elkjs: 0.11.1 + elkjs: 0.12.0 embla-carousel-autoplay: 8.6.0 embla-carousel-fade: 8.6.0 embla-carousel-react: 8.6.0 @@ -166,24 +166,24 @@ catalog: es-toolkit: 1.52.0 eslint: 10.10.0 eslint-config-flat-gitignore: 2.4.0 - eslint-markdown: 0.12.1 + eslint-markdown: 0.17.0 eslint-plugin-antfu: 3.2.3 eslint-plugin-better-tailwindcss: 4.7.0 - eslint-plugin-command: 3.5.3 - eslint-plugin-erasable-syntax-only: 0.4.2 - eslint-plugin-jsdoc: 63.3.3 + eslint-plugin-command: 4.0.0 + eslint-plugin-erasable-syntax-only: 0.7.1 + eslint-plugin-jsdoc: 64.5.2 eslint-plugin-jsonc: 3.4.2 - eslint-plugin-markdown-preferences: 0.41.1 + eslint-plugin-markdown-preferences: 0.42.0 eslint-plugin-n: 18.3.0 - eslint-plugin-no-barrel-files: 1.3.1 + eslint-plugin-no-barrel-files: 2.2.0 eslint-plugin-perfectionist: 5.11.1 eslint-plugin-pnpm: 1.9.1 eslint-plugin-regexp: 3.3.0 eslint-plugin-storybook: 10.6.0 eslint-plugin-toml: 1.5.0 - eslint-plugin-unicorn: 71.1.0 + eslint-plugin-unicorn: 75.0.0 eslint-plugin-yml: 3.8.1 - eventsource-parser: 3.1.1 + eventsource-parser: 4.1.1 fast-deep-equal: 3.1.3 foxact: 0.3.11 fuse.js: 7.5.0 @@ -212,7 +212,7 @@ catalog: mermaid: 11.17.2 mime: 4.1.0 mitt: 3.0.1 - motion: 12.43.0 + motion: 13.4.0 negotiator: 1.1.0 next: 16.3.5 next-themes: 0.4.6 diff --git a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx index 1f4ead65353..0e378cd8e2e 100644 --- a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx @@ -107,7 +107,7 @@ export default function CheckCode() { onClick={() => router.back()} className="flex h-9 cursor-pointer appearance-none items-center justify-center text-text-tertiary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > - + {t(($) => $.back, { ns: 'login' })} diff --git a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx index 7e09f107e72..f586ae17452 100644 --- a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx @@ -158,7 +158,7 @@ export default function CheckCode() { onClick={() => router.back()} className="flex h-9 cursor-pointer appearance-none items-center justify-center text-text-tertiary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > - + {t(($) => $.back, { ns: 'login' })} diff --git a/web/app/(shareLayout)/webapp-signin/normalForm.tsx b/web/app/(shareLayout)/webapp-signin/normalForm.tsx index 4232354bb4c..bfa8906f023 100644 --- a/web/app/(shareLayout)/webapp-signin/normalForm.tsx +++ b/web/app/(shareLayout)/webapp-signin/normalForm.tsx @@ -64,7 +64,7 @@ const NormalForm = () => {
-
+
@@ -84,7 +84,7 @@ const NormalForm = () => {
-
+
@@ -104,7 +104,7 @@ const NormalForm = () => {
-
+
@@ -196,7 +196,7 @@ const NormalForm = () => { {allMethodsAreDisabled && ( <>
-
+

diff --git a/web/app/account/(commonLayout)/account-page/__tests__/email-change-modal.spec.tsx b/web/app/account/(commonLayout)/account-page/__tests__/email-change-modal.spec.tsx new file mode 100644 index 00000000000..dacc69eaf32 --- /dev/null +++ b/web/app/account/(commonLayout)/account-page/__tests__/email-change-modal.spec.tsx @@ -0,0 +1,105 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { checkEmailExisted, resetEmail, sendVerifyCode, verifyEmail } from '@/service/common' +import EmailChangeModal from '../email-change-modal' + +vi.mock('@/service/common', () => ({ + checkEmailExisted: vi.fn(), + resetEmail: vi.fn(), + sendVerifyCode: vi.fn(), + verifyEmail: vi.fn(), +})) + +vi.mock('@/service/use-common', () => ({ + useLogout: () => ({ mutateAsync: vi.fn() }), +})) + +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) })) + +describe('EmailChangeModal', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(sendVerifyCode).mockResolvedValue({ result: 'success', data: 'email-token' }) + vi.mocked(verifyEmail).mockResolvedValue({ + result: 'success', + email: 'old@example.com', + is_valid: true, + token: 'verified-token', + }) + vi.mocked(checkEmailExisted).mockResolvedValue({ result: 'success' }) + }) + + it('submits the labeled verification fields with Enter through both email phases', async () => { + const user = userEvent.setup() + render() + + await user.click( + screen.getByRole('button', { name: 'common.account.changeEmail.sendVerifyCode' }), + ) + const originCode = await screen.findByRole('textbox', { + name: 'common.account.changeEmail.codeLabel', + }) + await user.type(originCode, '123456{Enter}') + expect(verifyEmail).toHaveBeenCalledWith({ + email: 'old@example.com', + code: '123456', + token: 'email-token', + }) + + const email = await screen.findByRole('textbox', { + name: 'common.account.changeEmail.emailLabel', + }) + await user.type(email, 'new@example.com') + await waitFor(() => + expect( + screen.getByRole('button', { name: 'common.account.changeEmail.sendVerifyCode' }), + ).toBeEnabled(), + ) + await user.type(email, '{Enter}') + expect(sendVerifyCode).toHaveBeenLastCalledWith({ + email: 'new@example.com', + phase: 'new_email', + token: 'verified-token', + }) + + const newCode = await screen.findByRole('textbox', { + name: 'common.account.changeEmail.codeLabel', + }) + expect(newCode).toHaveValue('') + await user.type(newCode, '654321{Enter}') + await waitFor(() => + expect(resetEmail).toHaveBeenCalledWith({ + new_email: 'new@example.com', + token: 'verified-token', + }), + ) + }) + + it('associates an unavailable-email error and clears it when the user edits the address', async () => { + vi.mocked(checkEmailExisted).mockRejectedValue({ + status: 400, + json: async () => ({ code: 'email_already_in_use' }), + }) + const user = userEvent.setup() + render() + + await user.click( + screen.getByRole('button', { name: 'common.account.changeEmail.sendVerifyCode' }), + ) + await user.type( + await screen.findByRole('textbox', { name: 'common.account.changeEmail.codeLabel' }), + '123456{Enter}', + ) + const email = await screen.findByRole('textbox', { + name: 'common.account.changeEmail.emailLabel', + }) + await user.type(email, 'used@example.com') + + await screen.findByText('common.account.changeEmail.existingEmail') + expect(email).toBeInvalid() + expect(email).toHaveAccessibleDescription('common.account.changeEmail.existingEmail') + await user.clear(email) + expect(email).not.toBeInvalid() + expect(screen.queryByText('common.account.changeEmail.existingEmail')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx index 968d76a36e5..391a8d36281 100644 --- a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx +++ b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx @@ -1,12 +1,14 @@ import type { ResponseError } from '@/service/fetch' import { Button } from '@langgenius/dify-ui/button' -import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' +import { Field, FieldError, FieldLabel } from '@langgenius/dify-ui/field' +import { IconButton } from '@langgenius/dify-ui/icon-button' +import { Input } from '@langgenius/dify-ui/input' import { toast } from '@langgenius/dify-ui/toast' import { RiCloseLine } from '@remixicon/react' import { useDebounceFn } from 'ahooks' import { useCallback, useEffect, useRef, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' -import Input from '@/app/components/base/input' import { useRouter } from '@/next/navigation' import { checkEmailExisted, resetEmail, sendVerifyCode, verifyEmail } from '@/service/common' import { useLogout } from '@/service/use-common' @@ -224,201 +226,228 @@ const EmailChangeModal = ({ onClose, email }: Props) => { return (

!open && onClose()}> -
- -
- {step === STEP.start && ( - <> -
- {t(($) => $['account.changeEmail.title'], { ns: 'common' })} -
-
-
- {t(($) => $['account.changeEmail.authTip'], { ns: 'common' })} -
-
- $['account.changeEmail.content1']} - ns="common" - components={{ email: }} - values={{ email }} - /> -
-
-
-
- - -
- - )} - {step === STEP.verifyOrigin && ( - <> -
- {t(($) => $['account.changeEmail.verifyEmail'], { ns: 'common' })} -
-
-
- $['account.changeEmail.content2']} - ns="common" - components={{ email: }} - values={{ email }} - /> -
-
-
-
- {t(($) => $['account.changeEmail.codeLabel'], { ns: 'common' })} -
- $['account.changeEmail.codePlaceholder'], { ns: 'common' })} - value={code} - onChange={(e) => setCode(e.target.value)} - maxLength={6} - /> -
-
- - -
-
- {t(($) => $['account.changeEmail.resendTip'], { ns: 'common' })} - {time > 0 && ( - - {t(($) => $['account.changeEmail.resendCount'], { ns: 'common', count: time })} - - )} - {!time && ( - - {t(($) => $['account.changeEmail.resend'], { ns: 'common' })} - - )} -
- - )} - {step === STEP.newEmail && ( - <> -
- {t(($) => $['account.changeEmail.newEmail'], { ns: 'common' })} -
-
-
- {t(($) => $['account.changeEmail.content3'], { ns: 'common' })} -
-
-
-
- {t(($) => $['account.changeEmail.emailLabel'], { ns: 'common' })} -
- $['account.changeEmail.emailPlaceholder'], { ns: 'common' })} - value={mail} - onChange={(e) => handleNewEmailValueChange(e.target.value)} - destructive={newEmailExited || unAvailableEmail} - /> - {newEmailExited && ( -
- {t(($) => $['account.changeEmail.existingEmail'], { ns: 'common' })} +
{ + event.preventDefault() + if (step === STEP.start) void sendCodeToOriginEmail() + else if (step === STEP.verifyOrigin) void handleVerifyOriginEmail() + else if (step === STEP.newEmail) void sendCodeToNewEmail() + else void submitNewEmail() + }} + > + $['operation.close'], { ns: 'common' })} + className="absolute top-5 right-5 size-8" + onClick={onClose} + > + + + {step === STEP.start && ( + <> + + {t(($) => $['account.changeEmail.title'], { ns: 'common' })} + +
+
+ {t(($) => $['account.changeEmail.authTip'], { ns: 'common' })}
- )} - {unAvailableEmail && ( -
- {t(($) => $['account.changeEmail.unAvailableEmail'], { ns: 'common' })} +
+ $['account.changeEmail.content1']} + ns="common" + components={{ + email: , + }} + values={{ email }} + />
- )} -
-
- - -
- - )} - {step === STEP.verifyNew && ( - <> -
- {t(($) => $['account.changeEmail.verifyNew'], { ns: 'common' })} -
-
-
- $['account.changeEmail.content4']} - ns="common" - components={{ email: }} - values={{ email: mail }} +
+
+
+ + +
+ + )} + {step === STEP.verifyOrigin && ( + <> + + {t(($) => $['account.changeEmail.verifyEmail'], { ns: 'common' })} + +
+
+ $['account.changeEmail.content2']} + ns="common" + components={{ + email: , + }} + values={{ email }} + /> +
+
+ + + {t(($) => $['account.changeEmail.codeLabel'], { ns: 'common' })} + + $['account.changeEmail.codePlaceholder'], { ns: 'common' })} + value={code} + onValueChange={(value) => setCode(value)} + autoComplete="one-time-code" + maxLength={6} /> -
-
-
-
- {t(($) => $['account.changeEmail.codeLabel'], { ns: 'common' })} -
- $['account.changeEmail.codePlaceholder'], { ns: 'common' })} - value={code} - onChange={(e) => setCode(e.target.value)} - maxLength={6} - /> -
-
- - -
-
- {t(($) => $['account.changeEmail.resendTip'], { ns: 'common' })} - {time > 0 && ( - - {t(($) => $['account.changeEmail.resendCount'], { ns: 'common', count: time })} - - )} - {!time && ( - +
+
- - )} + {t(($) => $['account.changeEmail.continue'], { ns: 'common' })} + + +
+
+ {t(($) => $['account.changeEmail.resendTip'], { ns: 'common' })} + {time > 0 && ( + + {t(($) => $['account.changeEmail.resendCount'], { ns: 'common', count: time })} + + )} + {!time && ( + + )} +
+ + )} + {step === STEP.newEmail && ( + <> + + {t(($) => $['account.changeEmail.newEmail'], { ns: 'common' })} + +
+
+ {t(($) => $['account.changeEmail.content3'], { ns: 'common' })} +
+
+ + + {t(($) => $['account.changeEmail.emailLabel'], { ns: 'common' })} + + $['account.changeEmail.emailPlaceholder'], { + ns: 'common', + })} + value={mail} + onValueChange={(value) => handleNewEmailValueChange(value)} + type="email" + autoComplete="email" + /> + {newEmailExited && ( + + {t(($) => $['account.changeEmail.existingEmail'], { ns: 'common' })} + + )} + {unAvailableEmail && ( + + {t(($) => $['account.changeEmail.unAvailableEmail'], { ns: 'common' })} + + )} + +
+ + +
+ + )} + {step === STEP.verifyNew && ( + <> + + {t(($) => $['account.changeEmail.verifyNew'], { ns: 'common' })} + +
+
+ $['account.changeEmail.content4']} + ns="common" + components={{ + email: , + }} + values={{ email: mail }} + /> +
+
+ + + {t(($) => $['account.changeEmail.codeLabel'], { ns: 'common' })} + + $['account.changeEmail.codePlaceholder'], { ns: 'common' })} + value={code} + onValueChange={(value) => setCode(value)} + autoComplete="one-time-code" + maxLength={6} + /> + +
+ + +
+
+ {t(($) => $['account.changeEmail.resendTip'], { ns: 'common' })} + {time > 0 && ( + + {t(($) => $['account.changeEmail.resendCount'], { ns: 'common', count: time })} + + )} + {!time && ( + + )} +
+ + )} +
) diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index 7853316aa0c..bbdf7f76188 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -198,7 +198,7 @@ export default function OAuthAuthorize() {
-
{userProfile.name}
+
{userProfile.name}
{userProfile.email}
diff --git a/web/app/components/app/__tests__/use-export-app-dsl.spec.tsx b/web/app/components/app/__tests__/use-export-app-dsl.spec.tsx index 315b6adb42c..cd54f987bc9 100644 --- a/web/app/components/app/__tests__/use-export-app-dsl.spec.tsx +++ b/web/app/components/app/__tests__/use-export-app-dsl.spec.tsx @@ -59,6 +59,33 @@ describe('useExportAppDsl', () => { mocks.getEnvironmentVariables.mockResolvedValue({ items: [] }) }) + it.each([ + ['download.ifpkg', 'download.ifpkg'], + ['blob', 'Support Agent.ifpkg'], + ])('downloads binary exports using %s without rebuilding their bytes', async (name, fileName) => { + const archive = new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x00, 0xff])], name, { + type: 'application/zip', + }) + mocks.exportAppDsl.mockResolvedValue(archive) + const { result } = renderHook(() => useExportAppDsl(), { wrapper: createWrapper() }) + + await act(async () => { + await expect( + result.current.exportAppDsl({ + appId: 'agent-app-id', + appName: 'Support Agent', + }), + ).resolves.toEqual({ status: 'downloaded' }) + }) + + expect(mocks.downloadBlob).toHaveBeenCalledWith({ data: archive, fileName }) + const [{ data }] = mocks.downloadBlob.mock.calls[0] as [{ data: Blob }] + expect(new Uint8Array(await data.arrayBuffer())).toEqual( + new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x00, 0xff]), + ) + expect(mocks.getEnvironmentVariables).not.toHaveBeenCalled() + }) + it('exports through the generated client and hands the YAML file to the browser', async () => { mocks.exportAppDsl.mockResolvedValue({ data: 'kind: app\nversion: 0.1.5\n' }) const { result } = renderHook(() => useExportAppDsl(), { wrapper: createWrapper() }) diff --git a/web/app/components/app/configuration/config-var/index.tsx b/web/app/components/app/configuration/config-var/index.tsx index 5edb0c22261..654a16c50e0 100644 --- a/web/app/components/app/configuration/config-var/index.tsx +++ b/web/app/components/app/configuration/config-var/index.tsx @@ -338,7 +338,7 @@ const ConfigVar: FC = ({ promptVariables, readonly, onPromptVar {hasVar && (
= ({ <>
-
+
diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx index 0707531b0b7..fc0af74cfae 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx @@ -166,6 +166,63 @@ describe('CreateFromDSLModal', () => { const getCreateButton = () => screen.getByRole('button', { name: /newApp\.Create/i }) + it.each(['agent.ifpkg', 'agent.IFPKG'])( + 'imports %s as a binary file and handles Agent warnings', + async (filename) => { + const user = userEvent.setup() + const file = new File([new Uint8Array([0x50, 0x4b, 0x00, 0xff])], filename) + const readText = vi.spyOn(file, 'text') + const onClose = vi.fn() + mockImportDSL.mockResolvedValue({ + id: 'package-import', + status: 'completed-with-warnings', + app_id: 'agent-app', + app_mode: AppModeEnum.AGENT, + warnings: [ + { code: 'agent_skill_missing', path: 'skills.s_000001', message: 'Missing Skill' }, + ], + }) + render() + await user.click(getCreateButton()) + + await waitFor(() => expect(mockImportDSL).toHaveBeenCalledWith({ file })) + expect(readText).not.toHaveBeenCalled() + expect(onClose).toHaveBeenCalledTimes(1) + expect(toastMocks.call).toHaveBeenCalledWith(expect.any(String), { + type: 'warning', + description: expect.anything(), + }) + expect(mockResolveImportedAppRedirectionTarget).toHaveBeenCalledWith({ + id: 'agent-app', + mode: AppModeEnum.AGENT, + permission_keys: undefined, + }) + }, + ) + + it('accepts an ifpkg from the file picker even when the ordinary App quota is full', async () => { + const user = userEvent.setup() + appCount = 10 + appLimit = 10 + mockImportDSL.mockResolvedValue({ + id: 'import', + status: 'completed', + app_mode: AppModeEnum.AGENT, + }) + render() + const input = document.querySelector('input[type="file"]') + expect(input).not.toBeNull() + if (!input) throw new Error('Missing file picker') + const file = new File(['PK'], 'agent.ifpkg') + await user.upload(input, file) + + expect(await screen.findByText('agent.ifpkg')).toBeInTheDocument() + expect(screen.getByText('IFPKG')).toBeInTheDocument() + expect(screen.queryByText('apps-full')).not.toBeInTheDocument() + await user.click(getCreateButton()) + await waitFor(() => expect(mockImportDSL).toHaveBeenCalledWith({ file })) + }) + it('should render the file tab and show the dropped file', async () => { render( { + if ( + source.type === CreateFromDSLModalTab.FROM_FILE && + source.file.name.toLowerCase().endsWith('.ifpkg') + ) + return requestImport({ body: { file: source.file } }) + const body = source.type === CreateFromDSLModalTab.FROM_FILE ? ({ @@ -134,9 +140,14 @@ function CreateFromDSLModal({ select: (data) => data.apps, }), ) - const isAppQuotaUnavailable = deploymentEdition === 'CLOUD' && appQuota === undefined + const isPackageImport = + currentTab === CreateFromDSLModalTab.FROM_FILE && + currentFile?.name.toLowerCase().endsWith('.ifpkg') === true + const isAppQuotaUnavailable = + !isPackageImport && deploymentEdition === 'CLOUD' && appQuota === undefined // A limit of 0 means unlimited. const isAppsFull = + !isPackageImport && deploymentEdition === 'CLOUD' && appQuota !== undefined && appQuota.limit > 0 && @@ -309,6 +320,8 @@ function CreateFromDSLModal({ className="px-6 py-4" >
-
+

{t(($) => $['studio.noCredentialsYet'])}

diff --git a/web/app/components/app/use-export-app-dsl.ts b/web/app/components/app/use-export-app-dsl.ts index 5d1975d2c18..32533de40d4 100644 --- a/web/app/components/app/use-export-app-dsl.ts +++ b/web/app/components/app/use-export-app-dsl.ts @@ -40,7 +40,7 @@ async function getSecretEnvironmentVariables(appId: string) { } async function exportAppDslFile({ appId, appName, includeSecret = false }: ExportAppDslInput) { - const { data } = await consoleClient.apps.byAppId.export.get( + const response = await consoleClient.apps.byAppId.export.get( { params: { app_id: appId }, query: { include_secret: includeSecret }, @@ -48,8 +48,17 @@ async function exportAppDslFile({ appId, appName, includeSecret = false }: Expor { context: { silent: true } }, ) + if (response instanceof Blob) { + const name = response instanceof File ? response.name : undefined + downloadBlob({ + data: response, + fileName: name && name !== 'blob' ? name : `${appName}.ifpkg`, + }) + return + } + downloadBlob({ - data: new Blob([data], { type: 'application/yaml' }), + data: new Blob([response.data], { type: 'application/yaml' }), fileName: `${appName}.yml`, }) } diff --git a/web/app/components/apps/hooks/__tests__/use-dsl-drag-drop.spec.ts b/web/app/components/apps/hooks/__tests__/use-dsl-drag-drop.spec.ts index 8a8a534fceb..36312a31572 100644 --- a/web/app/components/apps/hooks/__tests__/use-dsl-drag-drop.spec.ts +++ b/web/app/components/apps/hooks/__tests__/use-dsl-drag-drop.spec.ts @@ -188,24 +188,27 @@ describe('useDSLDragDrop', () => { }) describe('Drop functionality', () => { - it('should call onDSLFileDropped for .yaml file', () => { - const dropZoneRef = { current: container } - renderHook(() => - useDSLDragDrop({ - onDSLFileDropped: mockOnDSLFileDropped, - dropZoneRef, - }), - ) + it.each(['test.yaml', 'agent.ifpkg', 'agent.IFPKG'])( + 'should call onDSLFileDropped for %s', + (filename) => { + const dropZoneRef = { current: container } + renderHook(() => + useDSLDragDrop({ + onDSLFileDropped: mockOnDSLFileDropped, + dropZoneRef, + }), + ) - const file = createMockFile('test.yaml') - const dropEvent = createDragEvent('drop', [file]) + const file = createMockFile(filename) + const dropEvent = createDragEvent('drop', [file]) - act(() => { - container.dispatchEvent(dropEvent) - }) + act(() => { + container.dispatchEvent(dropEvent) + }) - expect(mockOnDSLFileDropped).toHaveBeenCalledWith(file) - }) + expect(mockOnDSLFileDropped).toHaveBeenCalledWith(file) + }, + ) it('should call onDSLFileDropped for .yml file', () => { const dropZoneRef = { current: container } diff --git a/web/app/components/apps/hooks/use-dsl-drag-drop.ts b/web/app/components/apps/hooks/use-dsl-drag-drop.ts index 53198fc4eba..dd33c84cfae 100644 --- a/web/app/components/apps/hooks/use-dsl-drag-drop.ts +++ b/web/app/components/apps/hooks/use-dsl-drag-drop.ts @@ -42,8 +42,7 @@ export const useDSLDragDrop = ({ if (files.length === 0) return const file = files[0] - if (file!.name.toLowerCase().endsWith('.yaml') || file!.name.toLowerCase().endsWith('.yml')) - onDSLFileDropped(file!) + if (/\.(?:yaml|yml|ifpkg)$/i.test(file!.name)) onDSLFileDropped(file!) }) useEffect(() => { diff --git a/web/app/components/base/chat/chat-with-history/header/index.tsx b/web/app/components/base/chat/chat-with-history/header/index.tsx index 6fdbc6f8ce0..de5123af87e 100644 --- a/web/app/components/base/chat/chat-with-history/header/index.tsx +++ b/web/app/components/base/chat/chat-with-history/header/index.tsx @@ -86,7 +86,7 @@ const Header = () => {
-
-
-
- + $['operation.search'], { ns: 'common' })} placeholder="Search emojis..." - onChange={async (e: ChangeEvent) => { - if (e.target.value === '') { + onValueChange={async (value) => { + if (value === '') { setIsSearching(false) } else { setIsSearching(true) - const emojis = await searchEmoji(e.target.value) + const emojis = await searchEmoji(value) setSearchedEmojis(emojis) } }} /> -
+ + +
diff --git a/web/app/components/base/error-boundary/index.tsx b/web/app/components/base/error-boundary/index.tsx index 9e6ac2b9b71..66adb153baa 100644 --- a/web/app/components/base/error-boundary/index.tsx +++ b/web/app/components/base/error-boundary/index.tsx @@ -121,13 +121,13 @@ class ErrorBoundaryInner extends React.Component< return (
- +

{customTitle || copy.title}

diff --git a/web/app/components/base/form/__tests__/index.spec.tsx b/web/app/components/base/form/__tests__/index.spec.tsx index 11283fdb979..57c3a9d747c 100644 --- a/web/app/components/base/form/__tests__/index.spec.tsx +++ b/web/app/components/base/form/__tests__/index.spec.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { useAppForm, withForm } from '../index' const FormHarness = ({ onSubmit }: { onSubmit: (value: Record) => void }) => { @@ -8,12 +9,15 @@ const FormHarness = ({ onSubmit }: { onSubmit: (value: Record) }) return ( -
+ { + event.preventDefault() + void form.handleSubmit() + }} + > } /> - + ) @@ -40,12 +44,16 @@ const WithFormHarness = () => { describe('form index exports', () => { it('should submit values through the generated app form', async () => { const onSubmit = vi.fn() + const user = userEvent.setup() render() - fireEvent.click(screen.getByRole('button', { name: /submit/i })) + const input = screen.getByRole('textbox', { name: 'Title' }) + await user.clear(input) + await user.type(input, 'Updated title') + await user.click(screen.getByRole('button', { name: /submit/i })) await waitFor(() => { - expect(onSubmit).toHaveBeenCalledWith({ title: 'Initial title' }) + expect(onSubmit).toHaveBeenCalledWith({ title: 'Updated title' }) }) }) diff --git a/web/app/components/base/form/components/base/__tests__/base-field.spec.tsx b/web/app/components/base/form/components/base/__tests__/base-field.spec.tsx index 169fb6f6d56..d448a797b65 100644 --- a/web/app/components/base/form/components/base/__tests__/base-field.spec.tsx +++ b/web/app/components/base/form/components/base/__tests__/base-field.spec.tsx @@ -344,7 +344,9 @@ describe('BaseField', () => { expect(screen.getByDisplayValue('abc')).toHaveAttribute('type', 'password') }) - it('should render number input with number type', () => { + it('keeps numeric edits, zero and an empty value distinct at the form boundary', async () => { + const user = userEvent.setup() + const onChange = vi.fn() renderBaseField({ formSchema: { type: FormTypeEnum.textNumber, @@ -353,9 +355,22 @@ describe('BaseField', () => { required: false, }, defaultValues: { count: 7 }, + onChange, }) - expect(screen.getByDisplayValue('7')).toHaveAttribute('type', 'number') + const input = screen.getByRole('textbox', { name: 'Count' }) + expect(input).toHaveValue('7') + await user.clear(input) + await user.type(input, '3.5') + await user.tab() + expect(onChange).toHaveBeenLastCalledWith('count', 3.5) + await user.clear(input) + await user.type(input, '0') + await user.tab() + expect(onChange).toHaveBeenLastCalledWith('count', 0) + await user.clear(input) + await user.tab() + expect(onChange).toHaveBeenLastCalledWith('count', null) }) it('should render translated object label content', () => { diff --git a/web/app/components/base/form/components/base/base-field.tsx b/web/app/components/base/form/components/base/base-field.tsx index 7d847bfe4b4..7e7c2383d90 100644 --- a/web/app/components/base/form/components/base/base-field.tsx +++ b/web/app/components/base/form/components/base/base-field.tsx @@ -3,6 +3,9 @@ import type { FieldState, FormSchema, TypeWithI18N } from '@/app/components/base import { cn } from '@langgenius/dify-ui/cn' import { Field, FieldItem, FieldLabel } from '@langgenius/dify-ui/field' import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset' +import { Input } from '@langgenius/dify-ui/input' +import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group' +import { NumberField, NumberFieldGroup, NumberFieldInput } from '@langgenius/dify-ui/number-field' import { Radio, RadioGroup } from '@langgenius/dify-ui/radio-group' import { Select, @@ -18,9 +21,9 @@ import { useStore } from '@tanstack/react-form' import { isValidElement, memo, useCallback, useId, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { CheckboxList } from '@/app/components/base/checkbox-list' +import { CopyFeedback } from '@/app/components/base/copy-feedback' import { FormItemValidateStatusEnum, FormTypeEnum } from '@/app/components/base/form/types' import { Infotip } from '@/app/components/base/infotip' -import Input from '@/app/components/base/input' import { useRenderI18nObject } from '@/hooks/use-i18n' import { useTriggerPluginDynamicOptions } from '@/service/use-triggers' @@ -28,8 +31,6 @@ const getExtraProps = (type: FormTypeEnum) => { switch (type) { case FormTypeEnum.secretInput: return { type: 'password', autoComplete: 'new-password' } - case FormTypeEnum.textNumber: - return { type: 'number' } default: return { type: 'text' } } @@ -85,7 +86,7 @@ const VALIDATE_STATUS_STYLE_MAP: Record< }, [FormItemValidateStatusEnum.Warning]: { componentClassName: - 'border-components-input-border-warning focus:border-components-input-border-warning', + 'border-components-input-border-warning focus:border-components-input-border-warning has-[input:focus]:border-components-input-border-warning', textClassName: 'text-text-warning', infoFieldName: 'warnings', }, @@ -273,6 +274,17 @@ const BaseField = ({ const selectPlaceholder = isDynamicSelect ? dynamicPlaceholder : translatedPlaceholder const handleSelectChange = isDynamicSelect ? field.handleChange : handleChange const selectDisabled = disabled || (isDynamicSelect && isDynamicOptionsLoading) + const textInputProps = { + id: controlId, + name: field.name, + ...controlProps, + value: stringValue ?? '', + onValueChange: handleChange, + onBlur: field.handleBlur, + disabled, + placeholder: translatedPlaceholder, + ...getExtraProps(formItemType), + } const content = ( <> @@ -301,28 +313,74 @@ const BaseField = ({ )}
- {[FormTypeEnum.textInput, FormTypeEnum.secretInput, FormTypeEnum.textNumber].includes( - formItemType, - ) && ( - + {showCopy ? ( + + + + + + + ) : ( + )} - value={value || ''} - onChange={(e) => { - handleChange(e.target.value) - }} - onBlur={field.handleBlur} - disabled={disabled} - placeholder={translatedPlaceholder} - {...getExtraProps(formItemType)} - showCopyIcon={showCopy} - /> + + )} + {formItemType === FormTypeEnum.textNumber && ( + + handleChange(value)} + > + + + {showCopy && ( + + )} + + + )} {isSelect && ( <> @@ -401,7 +459,7 @@ const BaseField = ({ > ({ - useFieldContext: () => mockField, -})) - -describe('TextField', () => { - beforeEach(() => { - vi.clearAllMocks() - mockField.state.value = 'Initial text' - }) - - it('should render current value', () => { - render() - expect(screen.getByLabelText('Name')).toHaveValue('Initial text') - }) - - it('should update value when users type', () => { - render() - fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Updated text' } }) - expect(mockField.handleChange).toHaveBeenCalledWith('Updated text') - }) -}) diff --git a/web/app/components/base/form/components/field/text.tsx b/web/app/components/base/form/components/field/text.tsx index 28a4919cee6..6ec2592cb88 100644 --- a/web/app/components/base/form/components/field/text.tsx +++ b/web/app/components/base/form/components/field/text.tsx @@ -1,16 +1,19 @@ -import type { InputProps } from '../../../input' +import type { InputProps } from '@langgenius/dify-ui/input' import type { LabelProps } from '../label' import { cn } from '@langgenius/dify-ui/cn' +import { Input } from '@langgenius/dify-ui/input' import * as React from 'react' import { useFieldContext } from '../..' -import Input from '../../../input' import Label from '../label' type TextFieldProps = { label: string labelOptions?: Omit className?: string -} & Omit +} & Omit< + InputProps, + 'className' | 'onChange' | 'onValueChange' | 'onBlur' | 'value' | 'defaultValue' | 'id' | 'name' +> const TextField = ({ label, labelOptions, className, ...inputProps }: TextFieldProps) => { const field = useFieldContext() @@ -20,8 +23,9 @@ const TextField = ({ label, labelOptions, className, ...inputProps }: TextFieldP