mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
Merge remote-tracking branch 'origin/main' into deploy/konwledge
This commit is contained in:
commit
db8c40b23e
@ -492,6 +492,8 @@ SENTRY_DSN=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
# Comma-separated parent or exact hostnames, for example: dify.ai,staging.dify.dev
|
||||
TURNSTILE_ALLOWED_HOSTNAMES=
|
||||
# Enable only after the compatible web client has been fully deployed.
|
||||
TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED=false
|
||||
|
||||
# DEBUG
|
||||
DEBUG=false
|
||||
@ -747,6 +749,8 @@ RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
|
||||
EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5
|
||||
CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5
|
||||
OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5
|
||||
EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES=5
|
||||
EMAIL_CODE_LOGIN_MAX_ATTEMPTS=5
|
||||
|
||||
CREATE_TIDB_SERVICE_JOB_ENABLED=false
|
||||
|
||||
|
||||
@ -107,6 +107,23 @@ forbidden_modules =
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:webapp-access-query-service-boundary]
|
||||
name = Web app access query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.webapp_access_query_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.enterprise
|
||||
services.feature_service
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
[importlinter:contract:feature-query-service-boundary]
|
||||
name = Feature query application service is framework and persistence neutral
|
||||
type = forbidden
|
||||
|
||||
@ -28,7 +28,6 @@ from dify_agent.layers.dify_plugin import (
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.drive import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
|
||||
from dify_agent.layers.execution_context import (
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
DifyExecutionContextLayerConfig,
|
||||
@ -56,7 +55,6 @@ AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
|
||||
DIFY_RUNTIME_LAYER_ID = "runtime"
|
||||
DIFY_CONFIG_LAYER_ID = "config"
|
||||
DIFY_DRIVE_LAYER_ID = "drive"
|
||||
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
|
||||
DIFY_CORE_TOOLS_LAYER_ID = "core_tools"
|
||||
DIFY_KNOWLEDGE_BASE_LAYER_ID = "knowledge"
|
||||
@ -72,24 +70,10 @@ def _shell_layer_deps() -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def _drive_layer_deps() -> dict[str, str]:
|
||||
return {"shell": DIFY_SHELL_LAYER_ID}
|
||||
|
||||
|
||||
def _config_layer_deps() -> dict[str, str]:
|
||||
return {"shell": DIFY_SHELL_LAYER_ID}
|
||||
|
||||
|
||||
def _shell_config_with_drive_ref(
|
||||
shell_config: DifyShellLayerConfig | None,
|
||||
drive_config: DifyDriveLayerConfig | None,
|
||||
) -> DifyShellLayerConfig:
|
||||
config = shell_config or DifyShellLayerConfig()
|
||||
if drive_config is None:
|
||||
return config
|
||||
return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref})
|
||||
|
||||
|
||||
def _markdown_backtick_fence(text: str) -> str:
|
||||
"""Choose a fence that will not terminate inside the prompt body."""
|
||||
longest_backtick_run = max((len(match.group(0)) for match in re.finditer(r"`+", text)), default=0)
|
||||
@ -224,9 +208,6 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
core_tools: DifyCoreToolsLayerConfig | None = None
|
||||
knowledge: DifyKnowledgeBaseLayerConfig | None = None
|
||||
config_layer_config: DifyConfigLayerConfig | None = None
|
||||
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
|
||||
# through the back proxy, never inline content.
|
||||
drive_config: DifyDriveLayerConfig | None = None
|
||||
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
|
||||
# the Agent Soul configures human involvement; a deferred call ends the run and
|
||||
# the workflow pauses via the existing HITL form mechanism (ENG-635).
|
||||
@ -273,9 +254,6 @@ class AgentBackendAgentAppRunInput(BaseModel):
|
||||
core_tools: DifyCoreToolsLayerConfig | None = None
|
||||
knowledge: DifyKnowledgeBaseLayerConfig | None = None
|
||||
config_layer_config: DifyConfigLayerConfig | None = None
|
||||
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
|
||||
# through the back proxy, never inline content.
|
||||
drive_config: DifyDriveLayerConfig | None = None
|
||||
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
|
||||
# the Agent Soul configures human involvement (ENG-635).
|
||||
ask_human_config: DifyAskHumanLayerConfig | None = None
|
||||
@ -307,7 +285,7 @@ class AgentBackendRunRequestBuilder:
|
||||
"""Build an Agent App conversation-turn run request.
|
||||
|
||||
Layer graph: optional Agent Soul system prompt → user prompt →
|
||||
execution context → optional shell / config / drive / history
|
||||
execution context → optional shell / config / history
|
||||
(multi-turn) → LLM → optional plugin-direct tools / core-routed tools /
|
||||
knowledge search / ask_human / structured output. Mirrors the
|
||||
workflow-node layer ordering minus the workflow-job / previous-node
|
||||
@ -345,9 +323,7 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
include_shell = (
|
||||
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
|
||||
)
|
||||
include_shell = run_input.include_shell or run_input.config_layer_config is not None
|
||||
if include_shell:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@ -357,16 +333,15 @@ class AgentBackendRunRequestBuilder:
|
||||
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
|
||||
)
|
||||
)
|
||||
# Sandboxed bash workspace (dify.shell). It enters before config/drive
|
||||
# so eager pulls materialize content in the same filesystem used by
|
||||
# model commands.
|
||||
# Sandboxed bash workspace (dify.shell). It enters before config so
|
||||
# eager pulls materialize content in the same filesystem used by model commands.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
)
|
||||
|
||||
@ -381,19 +356,6 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): the catalog plus
|
||||
# prompt-mentioned entries eagerly pulled through the shell layer.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
deps=_drive_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_history:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@ -495,7 +457,7 @@ class AgentBackendRunRequestBuilder:
|
||||
"""Build a workflow Agent Node run request without defining another wire schema.
|
||||
|
||||
Layer graph mirrors the workflow surface: prompts → execution context →
|
||||
optional shell / config / drive / history → LLM → optional
|
||||
optional shell / config / history → LLM → optional
|
||||
plugin-direct tools / core-routed tools / knowledge search /
|
||||
ask_human / structured output.
|
||||
"""
|
||||
@ -537,9 +499,7 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
include_shell = (
|
||||
run_input.include_shell or run_input.config_layer_config is not None or run_input.drive_config is not None
|
||||
)
|
||||
include_shell = run_input.include_shell or run_input.config_layer_config is not None
|
||||
if include_shell:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@ -549,16 +509,15 @@ class AgentBackendRunRequestBuilder:
|
||||
config=DifyRuntimeLayerConfig(backend_binding_ref=run_input.backend_binding_ref),
|
||||
)
|
||||
)
|
||||
# Sandboxed bash workspace (dify.shell). It enters before drive so
|
||||
# drive can materialize mentioned targets with `dify-agent drive pull`
|
||||
# in the same shell-visible filesystem used by model commands.
|
||||
# Sandboxed bash workspace (dify.shell). It enters before config so
|
||||
# eager pulls materialize content in the same filesystem used by model commands.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps=_shell_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=_shell_config_with_drive_ref(run_input.shell_config, run_input.drive_config),
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
)
|
||||
|
||||
@ -573,19 +532,6 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): the catalog plus
|
||||
# prompt-mentioned entries eagerly pulled through the shell layer.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
deps=_drive_layer_deps(),
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_history:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
|
||||
@ -20,7 +20,7 @@ def reset_password(email, new_password, password_confirm):
|
||||
Reset password of owner account
|
||||
Only available in SELF_HOSTED mode
|
||||
"""
|
||||
if str(new_password).strip() != str(password_confirm).strip():
|
||||
if new_password.strip() != password_confirm.strip():
|
||||
click.echo(click.style("Passwords do not match.", fg="red"))
|
||||
return
|
||||
normalized_email = email.strip().lower()
|
||||
@ -62,7 +62,7 @@ def reset_email(email, new_email, email_confirm):
|
||||
Replace account email
|
||||
:return:
|
||||
"""
|
||||
if str(new_email).strip() != str(email_confirm).strip():
|
||||
if new_email.strip() != email_confirm.strip():
|
||||
click.echo(click.style("New emails do not match.", fg="red"))
|
||||
return
|
||||
normalized_new_email = new_email.strip().lower()
|
||||
|
||||
@ -13,6 +13,13 @@ class TurnstileConfig(BaseSettings):
|
||||
default="",
|
||||
description="Comma-separated parent or exact hostnames accepted from Turnstile.",
|
||||
)
|
||||
TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Require a separate Turnstile challenge when verifying email login codes on Dify Cloud. "
|
||||
"Enable after the compatible web client has been deployed."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("TURNSTILE_SECRET_KEY", mode="before")
|
||||
@classmethod
|
||||
|
||||
@ -1549,6 +1549,10 @@ class LoginConfig(BaseSettings):
|
||||
description="expiry time in minutes for email code login token",
|
||||
default=5,
|
||||
)
|
||||
EMAIL_CODE_LOGIN_MAX_ATTEMPTS: PositiveInt = Field(
|
||||
description="maximum number of verification attempts for an email code login challenge",
|
||||
default=5,
|
||||
)
|
||||
ALLOW_REGISTER: bool = Field(
|
||||
description="whether to enable register",
|
||||
default=False,
|
||||
|
||||
@ -71,7 +71,6 @@ from .app import (
|
||||
agent_app_feature,
|
||||
agent_app_sandbox,
|
||||
agent_config_inspector,
|
||||
agent_drive_inspector,
|
||||
annotation,
|
||||
app,
|
||||
audio,
|
||||
@ -176,7 +175,6 @@ __all__ = [
|
||||
"agent_app_sandbox",
|
||||
"agent_composer",
|
||||
"agent_config_inspector",
|
||||
"agent_drive_inspector",
|
||||
"agent_providers",
|
||||
"agent_roster",
|
||||
"annotation",
|
||||
|
||||
@ -182,14 +182,7 @@ class WorkflowAgentComposerValidateApi(Resource):
|
||||
AgentComposerService.validate_knowledge_datasets(
|
||||
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
payload=req_data,
|
||||
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
|
||||
session=session, tenant_id=tenant_id, app_id=app_model.id, node_id=node_id
|
||||
),
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(payload=req_data)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
|
||||
|
||||
@ -413,22 +406,12 @@ class SnippetAgentComposerValidateApi(Resource):
|
||||
@with_session(write=False)
|
||||
@model_validate(ComposerSavePayload)
|
||||
def post(self, req_data: ComposerSavePayload, session: Session, tenant_id: str, snippet_id: UUID, node_id: str):
|
||||
app_id = _require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id)
|
||||
_require_snippet_app_id(session=session, tenant_id=tenant_id, snippet_id=snippet_id)
|
||||
ComposerConfigValidator.validate_publish_payload(req_data)
|
||||
AgentComposerService.validate_knowledge_datasets(
|
||||
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
payload=req_data,
|
||||
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
node_id=node_id,
|
||||
),
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(payload=req_data)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
|
||||
|
||||
@ -580,12 +563,7 @@ class AgentComposerValidateApi(Resource):
|
||||
AgentComposerService.validate_knowledge_datasets(
|
||||
session=session, tenant_id=tenant_id, agent_soul=req_data.agent_soul
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
payload=req_data,
|
||||
agent_id=str(agent_id),
|
||||
)
|
||||
findings = AgentComposerService.collect_validation_findings(payload=req_data)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
|
||||
|
||||
|
||||
@ -1,21 +1,12 @@
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
@ -24,41 +15,13 @@ from controllers.console.wraps import (
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import uuid_value
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.model import App, AppMode, UploadFile
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.skill_package_service import SkillManifest, SkillPackageError
|
||||
from services.agent.skill_standardize_service import SkillStandardizeService
|
||||
from services.agent.skill_tool_inference_service import (
|
||||
SkillToolInferenceError,
|
||||
SkillToolInferenceResult,
|
||||
SkillToolInferenceService,
|
||||
)
|
||||
from services.agent_drive_service import (
|
||||
AgentDriveError,
|
||||
AgentDriveService,
|
||||
DriveCommitItem,
|
||||
DriveFileRef,
|
||||
normalize_drive_key,
|
||||
)
|
||||
from models.model import App, AppMode
|
||||
from services.agent_service import AgentService
|
||||
|
||||
_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
|
||||
_AGENT_SKILL_UPLOAD_PARAMS = {
|
||||
"file": {
|
||||
"in": "formData",
|
||||
"type": "file",
|
||||
"required": True,
|
||||
"description": "Skill package (.zip or .skill).",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AgentLogQuery(BaseModel):
|
||||
message_id: str = Field(..., description="Message UUID")
|
||||
@ -70,27 +33,6 @@ class AgentLogQuery(BaseModel):
|
||||
return uuid_value(value)
|
||||
|
||||
|
||||
class AgentDriveFilePayload(BaseModel):
|
||||
upload_file_id: str = Field(..., description="UploadFile UUID from POST /console/api/files/upload")
|
||||
|
||||
@field_validator("upload_file_id")
|
||||
@classmethod
|
||||
def validate_upload_file_id(cls, value: str) -> str:
|
||||
return uuid_value(value)
|
||||
|
||||
|
||||
class AgentDriveMutationQuery(BaseModel):
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveDeleteFileQuery(AgentDriveMutationQuery):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
|
||||
|
||||
|
||||
class AgentDriveDeleteFileByAgentQuery(BaseModel):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
|
||||
|
||||
|
||||
class AgentLogMetaResponse(ResponseModel):
|
||||
status: str
|
||||
executor: str
|
||||
@ -128,204 +70,7 @@ class AgentLogResponse(ResponseModel):
|
||||
files: list[Any] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentUploadedSkillResponse(ResponseModel):
|
||||
name: str
|
||||
description: str
|
||||
path: str
|
||||
skill_md_key: str
|
||||
archive_key: str | None = None
|
||||
|
||||
|
||||
class AgentSkillUploadResponse(ResponseModel):
|
||||
skill: AgentUploadedSkillResponse
|
||||
manifest: SkillManifest
|
||||
|
||||
|
||||
class AgentDriveFileResponse(ResponseModel):
|
||||
name: str
|
||||
drive_key: str
|
||||
file_id: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
|
||||
|
||||
class AgentDriveFileCommitResponse(ResponseModel):
|
||||
file: AgentDriveFileResponse
|
||||
|
||||
|
||||
class AgentDriveDeleteResponse(ResponseModel):
|
||||
result: str
|
||||
removed_keys: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentDriveDeleteResponse,
|
||||
AgentDriveFileCommitResponse,
|
||||
AgentDriveFileResponse,
|
||||
AgentLogResponse,
|
||||
AgentUploadedSkillResponse,
|
||||
AgentSkillUploadResponse,
|
||||
SkillToolInferenceResult,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_id(session: Session, app_model: App, node_id: str | None) -> str | None:
|
||||
if node_id and app_model.mode != AppMode.AGENT:
|
||||
return AgentComposerService.resolve_workflow_node_agent_id(
|
||||
session=session, tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
|
||||
)
|
||||
return app_model.bound_agent_id_with_session(session=session)
|
||||
|
||||
|
||||
def _agent_not_bound() -> tuple[dict[str, str], int]:
|
||||
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
|
||||
|
||||
|
||||
def _upload_skill_for_app(*, session: Session, current_user: Account, app_model: App):
|
||||
"""Upload one skill package and commit its normalized files into the agent drive."""
|
||||
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
if "file" not in request.files:
|
||||
return {"code": "no_file", "message": "no skill file uploaded"}, 400
|
||||
if len(request.files) > 1:
|
||||
return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400
|
||||
|
||||
upload = request.files["file"]
|
||||
content = upload.stream.read()
|
||||
try:
|
||||
result = SkillStandardizeService().standardize(
|
||||
content=content,
|
||||
filename=upload.filename or "",
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
session=session,
|
||||
)
|
||||
except (SkillPackageError, AgentDriveError) as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
return result, 201
|
||||
|
||||
|
||||
def _commit_drive_file_for_app(*, session: Session, current_user: Account, app_model: App, allow_node_id: bool = True):
|
||||
payload = AgentDriveFilePayload.model_validate(console_ns.payload or {})
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
node_id = query.node_id if allow_node_id else None
|
||||
agent_id = _resolve_agent_id(session, app_model, node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
|
||||
upload_file = session.scalar(
|
||||
select(UploadFile).where(
|
||||
UploadFile.id == payload.upload_file_id,
|
||||
UploadFile.tenant_id == app_model.tenant_id,
|
||||
)
|
||||
)
|
||||
if upload_file is None:
|
||||
return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404
|
||||
|
||||
try:
|
||||
key = normalize_drive_key(f"files/{upload_file.name}")
|
||||
committed = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key=key,
|
||||
file_ref=DriveFileRef(kind="upload_file", id=upload_file.id),
|
||||
# ADD FILE uploads exist solely to live in the drive, so the
|
||||
# drive owns (and physically cleans) the value on delete.
|
||||
value_owned_by_drive=True,
|
||||
)
|
||||
],
|
||||
session=session,
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
row = committed[0]
|
||||
return {
|
||||
"file": {
|
||||
"name": upload_file.name,
|
||||
"drive_key": row["key"],
|
||||
"file_id": upload_file.id,
|
||||
"size": row.get("size"),
|
||||
"mime_type": row.get("mime_type"),
|
||||
},
|
||||
}, 201
|
||||
|
||||
|
||||
def _delete_drive_file_for_app(*, session: Session, current_user: Account, app_model: App, allow_node_id: bool = True):
|
||||
query = query_params_from_request(AgentDriveDeleteFileQuery)
|
||||
node_id = query.node_id if allow_node_id else None
|
||||
agent_id = _resolve_agent_id(session, app_model, node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
key = normalize_drive_key(query.key)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
try:
|
||||
result = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[DriveCommitItem(key=key, file_ref=None)],
|
||||
session=session,
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
removed_keys = [item["key"] for item in result if item.get("removed")]
|
||||
return {"result": "success", "removed_keys": removed_keys}
|
||||
|
||||
|
||||
def _delete_skill_for_app(
|
||||
*, session: Session, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True
|
||||
):
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
node_id = query.node_id if allow_node_id else None
|
||||
agent_id = _resolve_agent_id(session, app_model, node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
if "/" in slug or not slug.strip():
|
||||
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
|
||||
|
||||
try:
|
||||
result = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[
|
||||
DriveCommitItem(key=f"{slug}/SKILL.md", file_ref=None),
|
||||
DriveCommitItem(key=f"{slug}/.DIFY-SKILL-FULL.zip", file_ref=None),
|
||||
],
|
||||
session=session,
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
removed_keys = [item["key"] for item in result if item.get("removed")]
|
||||
return {"result": "success", "removed_keys": removed_keys}
|
||||
|
||||
|
||||
def _infer_skill_tools_for_app(*, session: Session, app_model: App, slug: str):
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
if "/" in slug or not slug.strip():
|
||||
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
|
||||
try:
|
||||
return SkillToolInferenceService().infer(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug, session=session
|
||||
)
|
||||
except SkillToolInferenceError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
register_response_schema_models(console_ns, AgentLogResponse)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/logs")
|
||||
@ -344,209 +89,6 @@ class AgentLogApi(Resource):
|
||||
@get_app_model(mode=[AppMode.AGENT_CHAT])
|
||||
@model_validate(AgentLogQuery)
|
||||
def get(self, req_data: AgentLogQuery, session: Session, app_model: App):
|
||||
"""Get agent logs"""
|
||||
"""Get agent logs."""
|
||||
|
||||
return AgentService.get_agent_logs(app_model, req_data.conversation_id, req_data.message_id, session)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/skills/upload")
|
||||
class AgentSkillUploadByAgentApi(Resource):
|
||||
@console_ns.doc("upload_agent_skill_by_agent")
|
||||
@console_ns.doc(description="Upload + standardize a Skill into an Agent App drive")
|
||||
@console_ns.doc(consumes=["multipart/form-data"], params={"agent_id": "Agent ID", **_AGENT_SKILL_UPLOAD_PARAMS})
|
||||
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
|
||||
@console_ns.response(400, "Invalid skill package or no bound agent")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _upload_skill_for_app(session=session, current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/upload")
|
||||
class AgentSkillUploadApi(Resource):
|
||||
@console_ns.doc("upload_agent_skill")
|
||||
@console_ns.doc(description="Upload + standardize a Skill into the agent drive")
|
||||
@console_ns.doc(
|
||||
consumes=["multipart/form-data"],
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
**query_params_from_model(AgentDriveMutationQuery),
|
||||
**_AGENT_SKILL_UPLOAD_PARAMS,
|
||||
},
|
||||
)
|
||||
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
|
||||
@console_ns.response(400, "Invalid skill package or no bound agent")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def post(self, session: Session, current_user: Account, app_model: App):
|
||||
"""Upload a Skill, validate it, and commit drive-backed skill files."""
|
||||
return _upload_skill_for_app(session=session, current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/files")
|
||||
class AgentDriveFilesByAgentApi(Resource):
|
||||
@console_ns.doc("commit_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Commit an uploaded file into the Agent App drive under files/<name>")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
|
||||
@console_ns.response(
|
||||
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _commit_drive_file_for_app(
|
||||
session=session, current_user=current_user, app_model=app_model, allow_node_id=False
|
||||
)
|
||||
|
||||
@console_ns.doc("delete_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Delete one Agent App drive file by key")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveDeleteFileByAgentQuery)})
|
||||
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _delete_drive_file_for_app(
|
||||
session=session, current_user=current_user, app_model=app_model, allow_node_id=False
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/files")
|
||||
class AgentDriveFilesApi(Resource):
|
||||
@console_ns.doc("commit_agent_drive_file")
|
||||
@console_ns.doc(description="Commit an uploaded file into the agent drive under files/<name> (ENG-625 D3)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)})
|
||||
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
|
||||
@console_ns.response(
|
||||
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def post(self, session: Session, current_user: Account, app_model: App):
|
||||
"""ADD FILE: commit one uploaded file into the bound agent's drive."""
|
||||
return _commit_drive_file_for_app(session=session, current_user=current_user, app_model=app_model)
|
||||
|
||||
@console_ns.doc("delete_agent_drive_file")
|
||||
@console_ns.doc(description="Delete one drive file by key via drive commit-null semantics")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveDeleteFileQuery)})
|
||||
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def delete(self, session: Session, current_user: Account, app_model: App):
|
||||
return _delete_drive_file_for_app(session=session, current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>")
|
||||
class AgentSkillByAgentApi(Resource):
|
||||
@console_ns.doc("delete_agent_skill_by_agent")
|
||||
@console_ns.doc(description="Delete a standardized skill from an Agent App drive")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
|
||||
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def delete(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID, slug: str):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _delete_skill_for_app(
|
||||
session=session, current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>")
|
||||
class AgentSkillApi(Resource):
|
||||
@console_ns.doc("delete_agent_skill")
|
||||
@console_ns.doc(description="Delete a standardized skill by removing its known drive keys via commit-null")
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"slug": "Skill slug (single path segment)",
|
||||
**query_params_from_model(AgentDriveMutationQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_session
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def delete(self, session: Session, current_user: Account, app_model: App, slug: str):
|
||||
return _delete_skill_for_app(session=session, current_user=current_user, app_model=app_model, slug=slug)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>/infer-tools")
|
||||
class AgentSkillInferToolsByAgentApi(Resource):
|
||||
@console_ns.doc("infer_agent_skill_tools_by_agent")
|
||||
@console_ns.doc(description="Infer CLI tool + ENV suggestions from a standardized Agent App skill")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Inference result (draft suggestions, nothing persisted)",
|
||||
console_ns.models[SkillToolInferenceResult.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, tenant_id: str, agent_id: UUID, slug: str):
|
||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _infer_skill_tools_for_app(session=session, app_model=app_model, slug=slug)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>/infer-tools")
|
||||
class AgentSkillInferToolsApi(Resource):
|
||||
@console_ns.doc("infer_agent_skill_tools")
|
||||
@console_ns.doc(
|
||||
description="Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)"
|
||||
)
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"slug": "Skill slug (single path segment)",
|
||||
**query_params_from_model(AgentDriveMutationQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Inference result (draft suggestions, nothing persisted)",
|
||||
console_ns.models[SkillToolInferenceResult.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def post(self, session: Session, app_model: App, slug: str):
|
||||
"""Suggest CLI tools/env for a skill. Saving still goes through composer validation."""
|
||||
return _infer_skill_tools_for_app(session=session, app_model=app_model, slug=slug)
|
||||
|
||||
@ -1,434 +0,0 @@
|
||||
"""Console read-only inspector for the agent drive (ENG-624).
|
||||
|
||||
``agent-drive`` looks at the *static* drive assets (standardized skills and
|
||||
committed files); the sibling ``agent-sandbox`` routes look at a *runtime*
|
||||
sandbox workspace. Unlike the sandbox routes this never proxies to the agent
|
||||
backend — drive data lives in the API's own DB/storage, served straight from
|
||||
``AgentDriveService``. Download hands the browser an **external** signed URL
|
||||
(the inner manifest hands agents internal ones — the two must never mix).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Response
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_response_schema_models,
|
||||
)
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models.model import App, AppMode
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent_drive_service import AgentDriveError, AgentDriveService
|
||||
|
||||
|
||||
class AgentDriveListQuery(BaseModel):
|
||||
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveListByAgentQuery(BaseModel):
|
||||
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
|
||||
|
||||
|
||||
class AgentDriveFileQuery(BaseModel):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveFileByAgentQuery(BaseModel):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
|
||||
|
||||
|
||||
class AgentDriveSkillInspectQuery(BaseModel):
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveItemResponse(ResponseModel):
|
||||
key: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
hash: str | None = None
|
||||
file_kind: str
|
||||
created_at: int | None = None
|
||||
is_skill: bool | None = None
|
||||
skill_metadata: str | None = None
|
||||
|
||||
|
||||
class AgentDriveListResponse(ResponseModel):
|
||||
items: list[AgentDriveItemResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentDriveSkillItemResponse(ResponseModel):
|
||||
path: str
|
||||
skill_md_key: str
|
||||
archive_key: str | None = None
|
||||
name: str
|
||||
description: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
hash: str | None = None
|
||||
created_at: int | None = None
|
||||
|
||||
|
||||
class AgentDriveSkillListResponse(ResponseModel):
|
||||
items: list[AgentDriveSkillItemResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentDriveSkillFileResponse(ResponseModel):
|
||||
path: str
|
||||
name: str
|
||||
type: str
|
||||
drive_key: str | None = None
|
||||
available_in_drive: bool
|
||||
|
||||
|
||||
class AgentDriveSkillMarkdownResponse(ResponseModel):
|
||||
key: str
|
||||
size: int | None = None
|
||||
truncated: bool
|
||||
binary: bool
|
||||
text: str | None = None
|
||||
|
||||
|
||||
class AgentDriveSkillInspectResponse(ResponseModel):
|
||||
path: str
|
||||
skill_md_key: str
|
||||
archive_key: str | None = None
|
||||
name: str
|
||||
description: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
hash: str | None = None
|
||||
created_at: int | None = None
|
||||
source: str
|
||||
files: list[AgentDriveSkillFileResponse] = Field(default_factory=list)
|
||||
file_tree: list[dict[str, Any]] = Field(default_factory=list)
|
||||
skill_md: AgentDriveSkillMarkdownResponse
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentDrivePreviewResponse(ResponseModel):
|
||||
key: str
|
||||
size: int | None = None
|
||||
truncated: bool
|
||||
binary: bool
|
||||
text: str | None = None
|
||||
|
||||
|
||||
class AgentDriveDownloadResponse(ResponseModel):
|
||||
url: str
|
||||
|
||||
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentDriveDownloadResponse,
|
||||
AgentDriveListResponse,
|
||||
AgentDrivePreviewResponse,
|
||||
AgentDriveSkillInspectResponse,
|
||||
AgentDriveSkillListResponse,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_id(session: Session, app_model: App, node_id: str | None) -> str | None:
|
||||
"""Agent identity for the drive: app-bound agent, or the workflow node binding."""
|
||||
if node_id:
|
||||
return AgentComposerService.resolve_workflow_node_agent_id(
|
||||
session=session, tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
|
||||
)
|
||||
return app_model.bound_agent_id_with_session(session=session)
|
||||
|
||||
|
||||
def _agent_not_bound() -> tuple[dict[str, object], int]:
|
||||
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
|
||||
|
||||
|
||||
def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
|
||||
def _json_response(data: Mapping[str, Any]):
|
||||
return Response(
|
||||
response=json.dumps(data, ensure_ascii=False, separators=(",", ":")),
|
||||
content_type="application/json; charset=utf-8",
|
||||
)
|
||||
|
||||
|
||||
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/files")
|
||||
class AgentDriveListByAgentApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_files_by_agent")
|
||||
@console_ns.doc(description="List agent drive entries for an Agent App")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveListByAgentQuery)})
|
||||
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveListByAgentQuery)
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().manifest(
|
||||
tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/skills")
|
||||
class AgentDriveSkillListByAgentApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_skills_by_agent")
|
||||
@console_ns.doc(description="List drive-backed skills for an Agent App")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id), session=session)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/skills/<path:skill_path>/inspect")
|
||||
class AgentDriveSkillInspectByAgentApi(Resource):
|
||||
@console_ns.doc("inspect_agent_drive_skill_by_agent")
|
||||
@console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "skill_path": "Skill path/slug, e.g. tender-analyzer"})
|
||||
@console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID, skill_path: str):
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return _json_response(
|
||||
AgentDriveService().inspect_skill(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
skill_path=skill_path,
|
||||
session=session,
|
||||
)
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/files/preview")
|
||||
class AgentDrivePreviewByAgentApi(Resource):
|
||||
@console_ns.doc("preview_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Truncated text preview of one Agent App drive value")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
|
||||
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return AgentDriveService().preview(
|
||||
tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/files/download")
|
||||
class AgentDriveDownloadByAgentApi(Resource):
|
||||
@console_ns.doc("download_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Time-limited external signed URL for one Agent App drive value")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
|
||||
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
url = AgentDriveService().download_url(
|
||||
tenant_id=tenant_id, agent_id=str(agent_id), key=query.key, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files")
|
||||
class AgentDriveListApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_files")
|
||||
@console_ns.doc(description="List agent drive entries (read-only inspector; one endpoint for both tabs)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
|
||||
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App):
|
||||
query = query_params_from_request(AgentDriveListQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
items = AgentDriveService().manifest(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
# the inner manifest exposes file_id for agent-side pulls; the console
|
||||
# inspector is a pure read surface and does not need value pointers
|
||||
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/skills")
|
||||
class AgentDriveSkillListApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_skills")
|
||||
@console_ns.doc(description="List drive-backed skills for the bound agent")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
|
||||
@console_ns.response(200, "Drive skills", console_ns.models[AgentDriveSkillListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App):
|
||||
query = query_params_from_request(AgentDriveListQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
items = AgentDriveService().list_skills(tenant_id=app_model.tenant_id, agent_id=agent_id, session=session)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/skills/<path:skill_path>/inspect")
|
||||
class AgentDriveSkillInspectApi(Resource):
|
||||
@console_ns.doc("inspect_agent_drive_skill")
|
||||
@console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"skill_path": "Skill path/slug, e.g. tender-analyzer",
|
||||
**query_params_from_model(AgentDriveSkillInspectQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App, skill_path: str):
|
||||
query = query_params_from_request(AgentDriveSkillInspectQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
return _json_response(
|
||||
AgentDriveService().inspect_skill(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
skill_path=skill_path,
|
||||
session=session,
|
||||
)
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/preview")
|
||||
class AgentDrivePreviewApi(Resource):
|
||||
@console_ns.doc("preview_agent_drive_file")
|
||||
@console_ns.doc(description="Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
|
||||
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App):
|
||||
query = query_params_from_request(AgentDriveFileQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
return AgentDriveService().preview(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/download")
|
||||
class AgentDriveDownloadApi(Resource):
|
||||
@console_ns.doc("download_agent_drive_file")
|
||||
@console_ns.doc(description="Time-limited external signed URL for one drive value (no streaming proxy)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
|
||||
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_session(write=False)
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, session: Session, app_model: App):
|
||||
query = query_params_from_request(AgentDriveFileQuery)
|
||||
agent_id = _resolve_agent_id(session, app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
url = AgentDriveService().download_url(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key, session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"url": url}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentDriveDownloadApi",
|
||||
"AgentDriveDownloadByAgentApi",
|
||||
"AgentDriveListApi",
|
||||
"AgentDriveListByAgentApi",
|
||||
"AgentDrivePreviewApi",
|
||||
"AgentDrivePreviewByAgentApi",
|
||||
"AgentDriveSkillInspectApi",
|
||||
"AgentDriveSkillInspectByAgentApi",
|
||||
"AgentDriveSkillListApi",
|
||||
"AgentDriveSkillListByAgentApi",
|
||||
]
|
||||
@ -95,6 +95,12 @@ class EmailCodeError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class EmailCodeLoginServiceUnavailableError(BaseHTTPException):
|
||||
error_code = "email_code_login_service_unavailable"
|
||||
description = "Email code verification is temporarily unavailable. Please try again later."
|
||||
code = 503
|
||||
|
||||
|
||||
class EmailOrPasswordMismatchError(BaseHTTPException):
|
||||
error_code = "email_or_password_mismatch"
|
||||
description = "The email or password is mismatched."
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
import flask_login
|
||||
from flask import make_response, request
|
||||
@ -22,6 +23,7 @@ from controllers.console import console_ns
|
||||
from controllers.console.auth.error import (
|
||||
AuthenticationFailedError,
|
||||
EmailCodeError,
|
||||
EmailCodeLoginServiceUnavailableError,
|
||||
EmailPasswordLoginLimitError,
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
@ -61,6 +63,10 @@ from libs.token import (
|
||||
from models.account import Account
|
||||
from services.account_service import AccountService, InvitationDetailDict, RegisterService, TenantService
|
||||
from services.billing_service import BillingService
|
||||
from services.email_code_login_challenge import (
|
||||
EmailCodeLoginChallengeStatus,
|
||||
EmailCodeLoginChallengeUnavailableError,
|
||||
)
|
||||
from services.entities.auth_entities import LoginFailureReason, LoginPayloadBase
|
||||
from services.errors.account import (
|
||||
AccountRegisterError,
|
||||
@ -71,6 +77,7 @@ from services.errors.account import (
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
|
||||
from services.feature_service import FeatureService
|
||||
from services.turnstile_service import (
|
||||
EMAIL_CODE_VERIFY_ACTION,
|
||||
TurnstileChallengeRejectedError,
|
||||
TurnstileService,
|
||||
TurnstileUpstreamError,
|
||||
@ -92,14 +99,20 @@ class EmailPayload(BaseModel):
|
||||
class EmailCodeSendPayload(EmailPayload):
|
||||
turnstile_token: str | None = Field(
|
||||
default=None,
|
||||
max_length=2048,
|
||||
description="Cloudflare Turnstile token. Required at runtime for Dify Cloud.",
|
||||
)
|
||||
|
||||
|
||||
class EmailCodeLoginPayload(BaseModel):
|
||||
email: EmailStr = Field(...)
|
||||
code: str = Field(...)
|
||||
token: str = Field(...)
|
||||
code: str
|
||||
token: UUID
|
||||
turnstile_token: str | None = Field(
|
||||
default=None,
|
||||
max_length=2048,
|
||||
description="Cloudflare Turnstile token for email-code verification.",
|
||||
)
|
||||
language: str | None = Field(default=None)
|
||||
timezone: str | None = Field(default=None)
|
||||
|
||||
@ -310,23 +323,55 @@ class EmailCodeLoginApi(Resource):
|
||||
original_email = req_data.email
|
||||
user_email = original_email.lower()
|
||||
language = req_data.language
|
||||
ip_address = extract_remote_ip(request)
|
||||
|
||||
token_data = AccountService.get_email_code_login_data(req_data.token)
|
||||
if token_data is None:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE_TOKEN)
|
||||
raise InvalidTokenError()
|
||||
|
||||
token_email = token_data.get("email")
|
||||
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
|
||||
if normalized_token_email != user_email:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH)
|
||||
raise InvalidEmailError()
|
||||
|
||||
if token_data["code"] != req_data.code:
|
||||
# ``code`` is Base64 on the wire and is decoded by
|
||||
# ``decrypt_code_field`` before model validation reaches this handler.
|
||||
if len(req_data.code) != 6 or not req_data.code.isascii() or not req_data.code.isdigit():
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE)
|
||||
raise EmailCodeError()
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and (
|
||||
dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED or req_data.turnstile_token
|
||||
):
|
||||
try:
|
||||
TurnstileService.verify(
|
||||
token=req_data.turnstile_token,
|
||||
remote_ip=ip_address,
|
||||
expected_action=EMAIL_CODE_VERIFY_ACTION,
|
||||
)
|
||||
except TurnstileChallengeRejectedError as exc:
|
||||
logger.info("Turnstile rejected an email-code verification challenge")
|
||||
raise TurnstileVerificationFailedError() from exc
|
||||
except TurnstileUpstreamError as exc:
|
||||
logger.warning("Turnstile verification is unavailable", exc_info=True)
|
||||
raise TurnstileServiceUnavailableError() from exc
|
||||
|
||||
try:
|
||||
verification = AccountService.verify_email_code_login_challenge(
|
||||
email=user_email,
|
||||
code=req_data.code,
|
||||
token=str(req_data.token),
|
||||
)
|
||||
except EmailCodeLoginChallengeUnavailableError as exc:
|
||||
logger.warning("Email-code challenge verification is unavailable", exc_info=True)
|
||||
raise EmailCodeLoginServiceUnavailableError() from exc
|
||||
|
||||
if verification.status == EmailCodeLoginChallengeStatus.INVALID_TOKEN:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE_TOKEN)
|
||||
raise InvalidTokenError()
|
||||
|
||||
if verification.status == EmailCodeLoginChallengeStatus.EMAIL_MISMATCH:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.EMAIL_CODE_EMAIL_MISMATCH)
|
||||
raise InvalidEmailError()
|
||||
|
||||
if verification.status in {
|
||||
EmailCodeLoginChallengeStatus.INVALID_CODE,
|
||||
EmailCodeLoginChallengeStatus.EXHAUSTED,
|
||||
}:
|
||||
_log_console_login_failure(email=user_email, reason=LoginFailureReason.INVALID_EMAIL_CODE)
|
||||
raise EmailCodeError()
|
||||
|
||||
AccountService.revoke_email_code_login_token(req_data.token)
|
||||
try:
|
||||
account = _get_account_with_case_fallback(original_email)
|
||||
except Unauthorized as exc:
|
||||
@ -346,7 +391,6 @@ class EmailCodeLoginApi(Resource):
|
||||
else:
|
||||
TenantService.create_owner_tenant(account, session=db.session())
|
||||
|
||||
ip_address = extract_remote_ip(request)
|
||||
if account is None:
|
||||
try:
|
||||
account = AccountService.create_account_and_tenant(
|
||||
|
||||
@ -209,6 +209,8 @@ class ModelProviderCredentialApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, provider: str):
|
||||
|
||||
@ -349,6 +349,8 @@ class ModelProviderModelCredentialApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
|
||||
@ -14,12 +14,11 @@ api = ExternalApi(
|
||||
|
||||
files_ns = Namespace("files", description="File operations", path="/")
|
||||
|
||||
from . import agent_drive_archive, image_preview, tool_files, upload
|
||||
from . import image_preview, tool_files, upload
|
||||
|
||||
api.add_namespace(files_ns)
|
||||
|
||||
__all__ = [
|
||||
"agent_drive_archive",
|
||||
"api",
|
||||
"bp",
|
||||
"files_ns",
|
||||
|
||||
@ -1,69 +0,0 @@
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from controllers.common.file_response import enforce_download_for_html
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.files import files_ns
|
||||
from extensions.ext_database import db
|
||||
from models.agent import AgentDriveFileKind
|
||||
from services.agent_drive_service import AgentDriveError, AgentDriveService
|
||||
|
||||
|
||||
class AgentDriveArchiveMemberQuery(BaseModel):
|
||||
tenant_id: str = Field(..., description="Tenant ID")
|
||||
agent_id: str = Field(..., description="Agent ID")
|
||||
key: str = Field(..., description="Virtual drive key")
|
||||
archive_file_kind: AgentDriveFileKind = Field(..., description="Archive file kind")
|
||||
archive_file_id: str = Field(..., description="Archive file id")
|
||||
member_path: str = Field(..., description="Zip member path")
|
||||
timestamp: str = Field(..., description="Unix timestamp")
|
||||
nonce: str = Field(..., description="Random nonce")
|
||||
sign: str = Field(..., description="HMAC signature")
|
||||
as_attachment: bool = Field(default=False, description="Download as attachment")
|
||||
|
||||
|
||||
register_schema_models(files_ns, AgentDriveArchiveMemberQuery)
|
||||
|
||||
|
||||
@files_ns.route("/agent-drive/archive-member")
|
||||
class AgentDriveArchiveMemberApi(Resource):
|
||||
@files_ns.doc("get_agent_drive_archive_member")
|
||||
@files_ns.doc(description="Download a lazily resolved Agent Skill archive member by signed parameters")
|
||||
def get(self):
|
||||
args = AgentDriveArchiveMemberQuery.model_validate(request.args.to_dict(flat=True))
|
||||
if not AgentDriveService.verify_archive_member_signature(
|
||||
tenant_id=args.tenant_id,
|
||||
agent_id=args.agent_id,
|
||||
key=args.key,
|
||||
archive_file_kind=args.archive_file_kind,
|
||||
archive_file_id=args.archive_file_id,
|
||||
member_path=args.member_path,
|
||||
timestamp=args.timestamp,
|
||||
nonce=args.nonce,
|
||||
sign=args.sign,
|
||||
):
|
||||
raise Forbidden("Invalid request.")
|
||||
try:
|
||||
payload, mime_type, filename = AgentDriveService().load_archive_member_for_signed_request(
|
||||
tenant_id=args.tenant_id,
|
||||
agent_id=args.agent_id,
|
||||
key=args.key,
|
||||
archive_file_kind=args.archive_file_kind,
|
||||
archive_file_id=args.archive_file_id,
|
||||
member_path=args.member_path,
|
||||
session=db.session(),
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
raise NotFound(exc.message) from exc
|
||||
|
||||
response = Response(payload, mimetype=mime_type, direct_passthrough=True, headers={})
|
||||
response.headers["Content-Length"] = str(len(payload))
|
||||
if args.as_attachment and filename:
|
||||
encoded_filename = quote(filename)
|
||||
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
enforce_download_for_html(response, mime_type=mime_type, filename=filename, extension="")
|
||||
return response
|
||||
@ -24,7 +24,6 @@ from .app import dsl as _app_dsl
|
||||
from .knowledge import retrieval as _knowledge_retrieval
|
||||
from .knowledge_fs import storage as _knowledge_fs_storage
|
||||
from .plugin import agent_config as _agent_config
|
||||
from .plugin import agent_drive as _agent_drive
|
||||
from .plugin import plugin as _plugin
|
||||
from .workspace import workspace as _workspace
|
||||
|
||||
@ -32,7 +31,6 @@ api.add_namespace(inner_api_ns)
|
||||
|
||||
__all__ = [
|
||||
"_agent_config",
|
||||
"_agent_drive",
|
||||
"_agent_files",
|
||||
"_agent_llm",
|
||||
"_agent_tools",
|
||||
|
||||
@ -1,105 +0,0 @@
|
||||
"""Inner API for the agent drive (agent 网盘) control plane.
|
||||
|
||||
These endpoints are called by the dify-agent server (not the sandbox) with the
|
||||
inner API key. The drive ref is the URL segment ``agent-<agent_id>``; the
|
||||
path-like file key travels in the query/body, never as a URL path segment (so
|
||||
its ``/`` characters do not collide with routing). Drive-owned semantics:
|
||||
tenant scoped, no user-level FileAccessScope. Commit still canonicalizes the
|
||||
trusted execution-context user through the same EndUser lookup as plugin file
|
||||
upload before validating ToolFile ownership.
|
||||
"""
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.plugin.wraps import get_user
|
||||
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||
from extensions.ext_database import db
|
||||
from services.agent_drive_service import (
|
||||
AgentDriveError,
|
||||
AgentDriveService,
|
||||
DriveCommitItem,
|
||||
parse_agent_drive_ref,
|
||||
)
|
||||
|
||||
|
||||
class _CommitRequest(BaseModel):
|
||||
tenant_id: str
|
||||
user_id: str
|
||||
items: list[DriveCommitItem]
|
||||
|
||||
|
||||
def _error_response(exc: AgentDriveError) -> tuple[dict[str, str], int]:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
|
||||
@inner_api_ns.route("/drive/<string:drive_ref>/manifest")
|
||||
class AgentDriveManifestApi(Resource):
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("agent_drive_manifest")
|
||||
@inner_api_ns.doc(description="List an agent drive (optionally with download URLs)")
|
||||
def get(self, drive_ref: str):
|
||||
try:
|
||||
agent_id = parse_agent_drive_ref(drive_ref)
|
||||
tenant_id = (request.args.get("tenant_id") or "").strip()
|
||||
if not tenant_id:
|
||||
raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
|
||||
include_download_url = (request.args.get("include_download_url") or "").lower() in ("1", "true", "yes")
|
||||
items = AgentDriveService().manifest(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
prefix=request.args.get("prefix", ""),
|
||||
include_download_url=include_download_url,
|
||||
session=db.session(),
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _error_response(exc)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@inner_api_ns.route("/drive/<string:drive_ref>/skills")
|
||||
class AgentDriveSkillsApi(Resource):
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("agent_drive_skills")
|
||||
@inner_api_ns.doc(description="List the skill catalog of an agent drive")
|
||||
def get(self, drive_ref: str):
|
||||
try:
|
||||
agent_id = parse_agent_drive_ref(drive_ref)
|
||||
tenant_id = (request.args.get("tenant_id") or "").strip()
|
||||
if not tenant_id:
|
||||
raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
|
||||
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=agent_id, session=db.session())
|
||||
except AgentDriveError as exc:
|
||||
return _error_response(exc)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@inner_api_ns.route("/drive/<string:drive_ref>/commit")
|
||||
class AgentDriveCommitApi(Resource):
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("agent_drive_commit")
|
||||
@inner_api_ns.doc(description="Commit a batch of file refs into an agent drive")
|
||||
def post(self, drive_ref: str):
|
||||
try:
|
||||
agent_id = parse_agent_drive_ref(drive_ref)
|
||||
try:
|
||||
body = _CommitRequest.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as exc:
|
||||
raise AgentDriveError("invalid_request", str(exc), status_code=400) from exc
|
||||
user = get_user(body.tenant_id, body.user_id)
|
||||
items = AgentDriveService().commit(
|
||||
tenant_id=body.tenant_id,
|
||||
user_id=user.id,
|
||||
agent_id=agent_id,
|
||||
items=body.items,
|
||||
session=db.session(),
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return _error_response(exc)
|
||||
return {"items": items}
|
||||
@ -1,14 +1,14 @@
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.common.fields import Site as SiteResponse
|
||||
from controllers.common.schema import register_response_schema_models
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.wraps import validate_app_token
|
||||
from extensions.ext_database import db
|
||||
from models.account import TenantStatus
|
||||
from models.model import App, Site
|
||||
from extensions.ext_application_services import application_services
|
||||
from libs.helper import dump_response
|
||||
from models.model import App
|
||||
from services.app_definition_query_service import AppDefinitionUnavailableError
|
||||
|
||||
register_response_schema_models(service_api_ns, SiteResponse)
|
||||
|
||||
@ -49,13 +49,9 @@ class AppSiteApi(Resource):
|
||||
|
||||
Returns the site configuration for the application including theme, icons, and text.
|
||||
"""
|
||||
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
|
||||
try:
|
||||
configuration = application_services().app_definitions.get_site_configuration(app_model.id)
|
||||
except AppDefinitionUnavailableError:
|
||||
raise Forbidden() from None
|
||||
|
||||
if not site:
|
||||
raise Forbidden()
|
||||
|
||||
assert app_model.tenant
|
||||
if app_model.tenant.status == TenantStatus.ARCHIVE:
|
||||
raise Forbidden()
|
||||
|
||||
return SiteResponse.model_validate(site).model_dump(mode="json")
|
||||
return dump_response(SiteResponse, configuration)
|
||||
|
||||
@ -8,8 +8,17 @@ from werkzeug.exceptions import Unauthorized
|
||||
|
||||
from constants import HEADER_NAME_APP_CODE
|
||||
from controllers.common import fields
|
||||
from controllers.common.fields import Parameters
|
||||
from controllers.common.errors import InvalidArgumentError
|
||||
from controllers.common.fields import AccessModeResponse, Parameters
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.error import (
|
||||
AgentNotPublishedError,
|
||||
AppUnavailableError,
|
||||
WebAppAccessServiceUnavailableError,
|
||||
WebAppNotFoundError,
|
||||
)
|
||||
from controllers.web.wraps import WebApiResource
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from libs.helper import dump_response
|
||||
@ -17,15 +26,15 @@ from libs.passport import PassportService
|
||||
from libs.token import extract_webapp_passport
|
||||
from models.model import App, EndUser
|
||||
from services.app_definition_query_service import AppDefinitionNotPublishedError, AppDefinitionUnavailableError
|
||||
from services.app_service import AppService
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.feature_service import FeatureService
|
||||
from services.webapp_access_query_service import (
|
||||
WebAppAccessAppNotFoundError,
|
||||
WebAppAccessReferenceRequiredError,
|
||||
WebAppAccessUnavailableError,
|
||||
)
|
||||
from services.webapp_auth_service import WebAppAuthService
|
||||
|
||||
from . import web_ns
|
||||
from .error import AgentNotPublishedError, AppUnavailableError
|
||||
from .wraps import WebApiResource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -54,7 +63,7 @@ register_response_schema_models(
|
||||
web_ns,
|
||||
Parameters,
|
||||
AppMetaResponse,
|
||||
fields.AccessModeResponse,
|
||||
AccessModeResponse,
|
||||
fields.BooleanResultResponse,
|
||||
)
|
||||
|
||||
@ -122,28 +131,27 @@ class AppAccessMode(Resource):
|
||||
responses={
|
||||
200: "Success",
|
||||
400: "Bad Request",
|
||||
404: "App Not Found",
|
||||
500: "Internal Server Error",
|
||||
503: "Web App Access Service Unavailable",
|
||||
}
|
||||
)
|
||||
@web_ns.response(200, "Success", web_ns.models[fields.AccessModeResponse.__name__])
|
||||
@web_ns.response(200, "Success", web_ns.models[AccessModeResponse.__name__])
|
||||
def get(self):
|
||||
raw_args = request.args.to_dict()
|
||||
args = AppAccessModeQuery.model_validate(raw_args)
|
||||
|
||||
features = FeatureService.get_system_features()
|
||||
if not features.webapp_auth.enabled:
|
||||
return {"accessMode": "public"}
|
||||
|
||||
app_id = args.app_id
|
||||
if args.app_code:
|
||||
app_id = AppService.get_app_id_by_code(args.app_code, session=db.session())
|
||||
|
||||
if not app_id:
|
||||
raise ValueError("appId or appCode must be provided")
|
||||
|
||||
res = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id)
|
||||
|
||||
return {"accessMode": res.access_mode}
|
||||
try:
|
||||
access_mode = application_services().webapp_access.get_access_mode(
|
||||
app_id=args.app_id,
|
||||
app_code=args.app_code,
|
||||
)
|
||||
except WebAppAccessReferenceRequiredError as e:
|
||||
raise InvalidArgumentError(description=str(e)) from None
|
||||
except WebAppAccessAppNotFoundError:
|
||||
raise WebAppNotFoundError() from None
|
||||
except WebAppAccessUnavailableError:
|
||||
raise WebAppAccessServiceUnavailableError() from None
|
||||
return dump_response(AccessModeResponse, {"access_mode": access_mode})
|
||||
|
||||
|
||||
@web_ns.route("/webapp/permission")
|
||||
|
||||
@ -121,6 +121,18 @@ class WebAppAuthAccessDeniedError(BaseHTTPException):
|
||||
code = 401
|
||||
|
||||
|
||||
class WebAppNotFoundError(BaseHTTPException):
|
||||
error_code = "app_not_found"
|
||||
description = "App not found."
|
||||
code = 404
|
||||
|
||||
|
||||
class WebAppAccessServiceUnavailableError(BaseHTTPException):
|
||||
error_code = "web_app_access_unavailable"
|
||||
description = "Web app access service is unavailable."
|
||||
code = 503
|
||||
|
||||
|
||||
class InvokeRateLimitError(BaseHTTPException):
|
||||
"""Raised when the Invoke returns rate limit error."""
|
||||
|
||||
|
||||
@ -36,9 +36,9 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
|
||||
soul_dump = agent_soul.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
|
||||
for section in sorted(RESERVED_AGENT_BACKEND_FEATURES):
|
||||
value = _get_nested(soul_dump, section)
|
||||
has_value = bool(value)
|
||||
has_value = value
|
||||
if isinstance(value, dict):
|
||||
has_value = any(bool(item) for item in value.values())
|
||||
has_value = any(item for item in value.values())
|
||||
if has_value:
|
||||
warnings.append(
|
||||
{
|
||||
|
||||
@ -23,6 +23,13 @@ class DeploymentEdition(StrEnum):
|
||||
CLOUD = "CLOUD"
|
||||
|
||||
|
||||
class WebAppAccessMode(StrEnum):
|
||||
PUBLIC = "public"
|
||||
PRIVATE = "private"
|
||||
PRIVATE_ALL = "private_all"
|
||||
SSO_VERIFIED = "sso_verified"
|
||||
|
||||
|
||||
class HostedTrialProvider(StrEnum):
|
||||
"""Enum representing hosted model provider names for trial access."""
|
||||
|
||||
|
||||
@ -1,22 +1,26 @@
|
||||
"""Composition root for application services used by transport adapters."""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
from flask import Flask, current_app
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from constants.dsl_version import CURRENT_APP_DSL_VERSION
|
||||
from core.db.session_factory import get_session_maker
|
||||
from core.schemas.schema_manager import SchemaManager
|
||||
from enums import DeploymentEdition
|
||||
from enums import DeploymentEdition, WebAppAccessMode
|
||||
from extensions.ext_redis import RedisClientWrapper, redis_client
|
||||
from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository
|
||||
from repositories.app_definition_query_repository import AppDefinitionQueryRepository
|
||||
from repositories.data_source_api_key_auth_repository import SQLAlchemyDataSourceApiKeyAuthBindingRepository
|
||||
from repositories.explore_banner_query_repository import ExploreBannerQueryRepository
|
||||
from repositories.installation_state_repository import InstallationStateRepository
|
||||
from repositories.webapp_access_query_repository import WebAppAccessQueryRepository
|
||||
from repositories.workspace_member_query_repository import WorkspaceMemberQueryRepository
|
||||
from repositories.workspace_query_repository import WorkspaceQueryRepository
|
||||
from services.account_activation_adapters import (
|
||||
@ -32,6 +36,8 @@ from services.auth.data_source_api_key_auth_gateways import (
|
||||
TenantApiKeyAuthCredentialEncryptor,
|
||||
)
|
||||
from services.auth.data_source_api_key_auth_service import DataSourceApiKeyAuthService
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.errors.enterprise import EnterpriseServiceError
|
||||
from services.explore_banner_query_service import ExploreBannerQueryService
|
||||
from services.feature_query_service import FeatureQueryService
|
||||
from services.feature_service import FeatureService
|
||||
@ -40,6 +46,10 @@ from services.init_validation_service import InitValidationService
|
||||
from services.schema_definition_service import SchemaDefinitionService
|
||||
from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner
|
||||
from services.setup_service import SetupService
|
||||
from services.webapp_access_query_service import (
|
||||
WebAppAccessQueryService,
|
||||
WebAppAccessUnavailableError,
|
||||
)
|
||||
from services.workspace_member_query_service import WorkspaceMemberQueryService
|
||||
from services.workspace_member_role_resolver import DeploymentWorkspaceMemberRoleResolver
|
||||
from services.workspace_plan_gateway import DeploymentWorkspacePlanGateway
|
||||
@ -48,11 +58,23 @@ from services.workspace_query_service import WorkspaceQueryService
|
||||
_EXTENSION_KEY = "application_services"
|
||||
|
||||
|
||||
def _get_enterprise_webapp_access_mode(app_id: str) -> WebAppAccessMode:
|
||||
try:
|
||||
settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id)
|
||||
except (EnterpriseServiceError, httpx.RequestError, json.JSONDecodeError, UnicodeDecodeError, ValidationError) as e:
|
||||
raise WebAppAccessUnavailableError from e
|
||||
try:
|
||||
return WebAppAccessMode(settings.access_mode)
|
||||
except ValueError as e:
|
||||
raise WebAppAccessUnavailableError from e
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationServices:
|
||||
account_activation: AccountActivationService
|
||||
app_definitions: AppDefinitionQueryService
|
||||
data_source_api_key_auth: DataSourceApiKeyAuthService
|
||||
webapp_access: WebAppAccessQueryService
|
||||
explore_banner_queries: ExploreBannerQueryService
|
||||
schema_definitions: SchemaDefinitionService
|
||||
setup: SetupService
|
||||
@ -94,9 +116,14 @@ def build_application_services(
|
||||
validator=ProviderApiKeyAuthCredentialValidator(),
|
||||
encryptor=TenantApiKeyAuthCredentialEncryptor(),
|
||||
),
|
||||
webapp_access=WebAppAccessQueryService(
|
||||
access=WebAppAccessQueryRepository(session_factory=database_client),
|
||||
webapp_auth_enabled=FeatureService.is_webapp_auth_enabled(),
|
||||
access_mode_for_app=_get_enterprise_webapp_access_mode,
|
||||
),
|
||||
explore_banner_queries=ExploreBannerQueryService(
|
||||
banners=ExploreBannerQueryRepository(client=database_client),
|
||||
is_enabled=FeatureService.is_explore_banner_enabled,
|
||||
enabled=FeatureService.is_explore_banner_enabled(),
|
||||
),
|
||||
schema_definitions=SchemaDefinitionService(source_factory=SchemaManager),
|
||||
setup=SetupService(
|
||||
|
||||
@ -0,0 +1,109 @@
|
||||
"""remove agent drive
|
||||
|
||||
Revision ID: 89919253ca7a
|
||||
Revises: 56124e050600
|
||||
Create Date: 2026-08-17 17:40:52.081816
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
from models.types import StringUUID
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "89919253ca7a"
|
||||
down_revision = "56124e050600"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _rewrite_json_rows(table_name: str, column_name: str, transform) -> None:
|
||||
# Offline SQL generation cannot run this read-modify-write cleanup.
|
||||
if op.get_context().as_sql:
|
||||
return
|
||||
|
||||
connection = op.get_bind()
|
||||
rows = connection.execute(sa.text(f"SELECT id, {column_name} FROM {table_name}"))
|
||||
for row_id, raw_value in rows:
|
||||
if raw_value is None:
|
||||
continue
|
||||
value = json.loads(raw_value)
|
||||
if not transform(value):
|
||||
continue
|
||||
connection.execute(
|
||||
sa.text(f"UPDATE {table_name} SET {column_name} = :value WHERE id = :id"),
|
||||
{"id": row_id, "value": json.dumps(value, ensure_ascii=False, separators=(",", ":"))},
|
||||
)
|
||||
|
||||
|
||||
def _remove_soul_files(value: object) -> bool:
|
||||
if not isinstance(value, dict) or "files" not in value:
|
||||
return False
|
||||
del value["files"]
|
||||
return True
|
||||
|
||||
|
||||
def _remove_node_job_drive_keys(value: object) -> bool:
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
changed = False
|
||||
metadata = value.get("metadata")
|
||||
if isinstance(metadata, dict):
|
||||
file_refs = metadata.get("file_refs")
|
||||
if isinstance(file_refs, list):
|
||||
for file_ref in file_refs:
|
||||
if isinstance(file_ref, dict) and "drive_key" in file_ref:
|
||||
del file_ref["drive_key"]
|
||||
changed = True
|
||||
declared_outputs = value.get("declared_outputs")
|
||||
if isinstance(declared_outputs, list):
|
||||
for output in declared_outputs:
|
||||
if not isinstance(output, dict):
|
||||
continue
|
||||
check = output.get("check")
|
||||
if not isinstance(check, dict):
|
||||
continue
|
||||
benchmark_file_ref = check.get("benchmark_file_ref")
|
||||
if isinstance(benchmark_file_ref, dict) and "drive_key" in benchmark_file_ref:
|
||||
del benchmark_file_ref["drive_key"]
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_rewrite_json_rows("agent_config_snapshots", "config_snapshot", _remove_soul_files)
|
||||
_rewrite_json_rows("agent_config_drafts", "config_snapshot", _remove_soul_files)
|
||||
_rewrite_json_rows("workflow_agent_node_bindings", "node_job_config", _remove_node_job_drive_keys)
|
||||
op.drop_table("agent_drive_files")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.create_table(
|
||||
"agent_drive_files",
|
||||
sa.Column("tenant_id", StringUUID(), nullable=False),
|
||||
sa.Column("agent_id", StringUUID(), nullable=False),
|
||||
sa.Column("key", sa.String(length=512), nullable=False),
|
||||
sa.Column("file_kind", sa.String(length=32), nullable=False),
|
||||
sa.Column("file_id", StringUUID(), nullable=False),
|
||||
sa.Column("value_owned_by_drive", sa.Boolean(), server_default=sa.text("false"), nullable=False),
|
||||
sa.Column("is_skill", sa.Boolean(), server_default=sa.text("false"), nullable=False),
|
||||
sa.Column("skill_metadata", sa.Text().with_variant(mysql.LONGTEXT(), "mysql"), nullable=True),
|
||||
sa.Column("size", sa.BigInteger(), nullable=True),
|
||||
sa.Column("hash", sa.String(length=255), nullable=True),
|
||||
sa.Column("mime_type", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_by", StringUUID(), nullable=True),
|
||||
sa.Column("id", StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="agent_drive_file_pkey"),
|
||||
sa.UniqueConstraint("tenant_id", "agent_id", "key", name="agent_drive_file_scope_key_unique"),
|
||||
)
|
||||
op.create_index(
|
||||
"agent_drive_files_tenant_agent_is_skill_key_idx",
|
||||
"agent_drive_files",
|
||||
["tenant_id", "agent_id", "is_skill", "key"],
|
||||
)
|
||||
@ -17,8 +17,6 @@ from .agent import (
|
||||
AgentConfigSnapshot,
|
||||
AgentConfigVersionKind,
|
||||
AgentDebugConversation,
|
||||
AgentDriveFile,
|
||||
AgentDriveFileKind,
|
||||
AgentHomeSnapshot,
|
||||
AgentIconType,
|
||||
AgentKind,
|
||||
@ -218,8 +216,6 @@ __all__ = [
|
||||
"AgentConfigSnapshot",
|
||||
"AgentConfigVersionKind",
|
||||
"AgentDebugConversation",
|
||||
"AgentDriveFile",
|
||||
"AgentDriveFileKind",
|
||||
"AgentHomeSnapshot",
|
||||
"AgentIconType",
|
||||
"AgentKind",
|
||||
|
||||
@ -536,55 +536,3 @@ class AgentWorkspaceBinding(DefaultFieldsMixin, Base):
|
||||
retired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
pending_form_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
pending_tool_call_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
|
||||
class AgentDriveFileKind(StrEnum):
|
||||
"""Kind of existing file record an agent-drive KV entry points at."""
|
||||
|
||||
UPLOAD_FILE = "upload_file"
|
||||
TOOL_FILE = "tool_file"
|
||||
|
||||
|
||||
class AgentDriveFile(DefaultFieldsMixin, Base):
|
||||
"""Per-agent path-like KV index into existing file records (agent 网盘 / agent drive).
|
||||
|
||||
A row maps a path-like ``key`` to a *pointer* (``file_kind`` + ``file_id``) at an
|
||||
existing ``UploadFile`` / ``ToolFile`` — it never stores file bytes. Scope/ownership
|
||||
is ``tenant_id -> agent-<agent_id>`` (the drive ref; no standalone ``drive_id`` this
|
||||
phase). ``key`` is opaque/path-like and carries no directory, permission, or
|
||||
parent-child semantics on the API side; it maps 1:1 to a sandbox-relative path when
|
||||
synced. ``value_owned_by_drive`` gates physical cleanup: only drive-owned values
|
||||
(created by the agent runtime or Skill standardization, not shared with other
|
||||
business records) have their storage object + record deleted when the KV entry is
|
||||
overwritten or removed; otherwise only the KV row is dropped. Skills are represented
|
||||
by the canonical ``<path>/SKILL.md`` row with ``is_skill=True`` and a serialized
|
||||
``skill_metadata`` string. Lifecycle never relies on ``UploadFile.used/used_by``
|
||||
(not a reliable refcount).
|
||||
"""
|
||||
|
||||
__tablename__ = "agent_drive_files"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="agent_drive_file_pkey"),
|
||||
UniqueConstraint("tenant_id", "agent_id", "key", name="agent_drive_file_scope_key_unique"),
|
||||
Index("agent_drive_files_tenant_agent_is_skill_key_idx", "tenant_id", "agent_id", "is_skill", "key"),
|
||||
)
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
# drive ref = agent-<agent_id>; this phase has no standalone drive_id.
|
||||
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
# path-like opaque key; not a filesystem (no dir/permission/parent semantics).
|
||||
# Bounded at 512 so the (tenant_id, agent_id, key) unique index stays within
|
||||
# MySQL's 3072-byte index limit (CHAR(36)*2 + VARCHAR(512) utf8mb4 = 2336).
|
||||
key: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
file_kind: Mapped[AgentDriveFileKind] = mapped_column(EnumText(AgentDriveFileKind, length=32), nullable=False)
|
||||
# points at UploadFile.id / ToolFile.id (the value), never the bytes.
|
||||
file_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
value_owned_by_drive: Mapped[bool] = mapped_column(
|
||||
sa.Boolean, nullable=False, default=False, server_default=sa.text("false")
|
||||
)
|
||||
is_skill: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=False, server_default=sa.text("false"))
|
||||
skill_metadata: Mapped[str | None] = mapped_column(LongText, nullable=True)
|
||||
size: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True)
|
||||
hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
mime_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
|
||||
@ -150,33 +150,6 @@ class AgentFileRefConfig(AgentFlexibleConfig):
|
||||
transfer_method: str | None = Field(default=None, max_length=64)
|
||||
url: str | None = None
|
||||
remote_url: str | None = None
|
||||
# Drive key once the file is committed to the agent drive ("files/<name>",
|
||||
# ENG-625). Files without it are plain upload references and stay invisible
|
||||
# to the runtime drive manifest.
|
||||
drive_key: str | None = Field(default=None, max_length=512)
|
||||
|
||||
|
||||
class AgentSkillRefConfig(AgentFlexibleConfig):
|
||||
id: str | None = Field(default=None, max_length=255)
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = None
|
||||
file_id: str | None = Field(default=None, max_length=255)
|
||||
path: str | None = None
|
||||
# Standardization outputs (ENG-594) — previously riding along via
|
||||
# ``extra="allow"``, promoted to the explicit schema because the runtime
|
||||
# drive manifest (ENG-623) keys off them.
|
||||
skill_md_key: str | None = Field(default=None, max_length=512)
|
||||
skill_md_file_id: str | None = Field(default=None, max_length=255)
|
||||
full_archive_key: str | None = Field(default=None, max_length=512)
|
||||
full_archive_file_id: str | None = Field(default=None, max_length=255)
|
||||
# Zip member path listing from standardization (ENG-371): lets infer-tools
|
||||
# show the model strong signals like ``scripts/*.sh`` without unpacking.
|
||||
manifest_files: list[str] | None = None
|
||||
|
||||
|
||||
class AgentSoulFilesConfig(BaseModel):
|
||||
skills: list[AgentSkillRefConfig] = Field(default_factory=list)
|
||||
files: list[AgentFileRefConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
def validate_config_name(name: str) -> str:
|
||||
@ -820,7 +793,6 @@ class AgentSoulConfig(BaseModel):
|
||||
config_skills: list[AgentConfigSkillRefConfig] = Field(default_factory=list)
|
||||
config_files: list[AgentConfigFileRefConfig] = Field(default_factory=list)
|
||||
config_note: str = ""
|
||||
files: AgentSoulFilesConfig = Field(default_factory=AgentSoulFilesConfig)
|
||||
sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig)
|
||||
memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig)
|
||||
model: AgentSoulModelConfig | None = None
|
||||
|
||||
@ -972,85 +972,6 @@ Stop a running Agent App chat message generation
|
||||
| 200 | Agent debug conversation refreshed | **application/json**: [AgentDebugConversationRefreshResponse](#agentdebugconversationrefreshresponse)<br> |
|
||||
| 403 | Insufficient permissions | |
|
||||
|
||||
### [GET] /agent/{agent_id}/drive/files
|
||||
List agent drive entries for an Agent App
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | Agent ID | Yes | string (uuid) |
|
||||
| prefix | query | Key prefix filter: '<slug>/' for one skill, 'files/' for files | No | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Drive entries | **application/json**: [AgentDriveListResponse](#agentdrivelistresponse)<br> |
|
||||
|
||||
### [GET] /agent/{agent_id}/drive/files/download
|
||||
Time-limited external signed URL for one Agent App drive value
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | Agent ID | Yes | string (uuid) |
|
||||
| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Signed URL | **application/json**: [AgentDriveDownloadResponse](#agentdrivedownloadresponse)<br> |
|
||||
|
||||
### [GET] /agent/{agent_id}/drive/files/preview
|
||||
Truncated text preview of one Agent App drive value
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | Agent ID | Yes | string (uuid) |
|
||||
| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Preview | **application/json**: [AgentDrivePreviewResponse](#agentdrivepreviewresponse)<br> |
|
||||
|
||||
### [GET] /agent/{agent_id}/drive/skills
|
||||
List drive-backed skills for an Agent App
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | Agent ID | Yes | string (uuid) |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Drive skills | **application/json**: [AgentDriveSkillListResponse](#agentdriveskilllistresponse)<br> |
|
||||
|
||||
### [GET] /agent/{agent_id}/drive/skills/{skill_path}/inspect
|
||||
Inspect one drive-backed skill for slash-menu hover/detail UI
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | Agent ID | Yes | string (uuid) |
|
||||
| skill_path | path | Skill path/slug, e.g. tender-analyzer | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Drive skill inspect view | **application/json**: [AgentDriveSkillInspectResponse](#agentdriveskillinspectresponse)<br> |
|
||||
|
||||
### [POST] /agent/{agent_id}/features
|
||||
Update an Agent App's presentation features (opener, follow-up, citations, ...)
|
||||
|
||||
@ -1096,43 +1017,6 @@ Create or update Agent App message feedback
|
||||
| 200 | Feedback updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
|
||||
| 404 | Agent or message not found | |
|
||||
|
||||
### [DELETE] /agent/{agent_id}/files
|
||||
Delete one Agent App drive file by key
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | Agent ID | Yes | string (uuid) |
|
||||
| key | query | Drive key, e.g. files/sample.pdf | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | File removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)<br> |
|
||||
|
||||
### [POST] /agent/{agent_id}/files
|
||||
Commit an uploaded file into the Agent App drive under files/<name>
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | Agent ID | Yes | string (uuid) |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [AgentDriveFilePayload](#agentdrivefilepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)<br> |
|
||||
|
||||
### [GET] /agent/{agent_id}/log-sources
|
||||
#### Parameters
|
||||
|
||||
@ -1322,60 +1206,6 @@ Read a text/binary preview file in an Agent App conversation sandbox
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)<br> |
|
||||
|
||||
### [POST] /agent/{agent_id}/skills/upload
|
||||
Upload + standardize a Skill into an Agent App drive
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | Agent ID | Yes | string (uuid) |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **multipart/form-data**: { **"file"**: binary }<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | Skill uploaded into drive | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)<br> |
|
||||
| 400 | Invalid skill package or no bound agent | |
|
||||
|
||||
### [DELETE] /agent/{agent_id}/skills/{slug}
|
||||
Delete a standardized skill from an Agent App drive
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | Agent ID | Yes | string (uuid) |
|
||||
| slug | path | Skill slug (single path segment) | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)<br> |
|
||||
|
||||
### [POST] /agent/{agent_id}/skills/{slug}/infer-tools
|
||||
Infer CLI tool + ENV suggestions from a standardized Agent App skill
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | Agent ID | Yes | string (uuid) |
|
||||
| slug | path | Skill slug (single path segment) | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)<br> |
|
||||
|
||||
### [GET] /agent/{agent_id}/statistics/summary
|
||||
#### Parameters
|
||||
|
||||
@ -2192,132 +2022,6 @@ Run draft workflow for advanced chat application
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Config skill inspect view | **application/json**: [AgentConfigSkillInspectResponse](#agentconfigskillinspectresponse)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/agent/drive/files
|
||||
List agent drive entries (read-only inspector; one endpoint for both tabs)
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | Application ID | Yes | string (uuid) |
|
||||
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
|
||||
| prefix | query | Key prefix filter: '<slug>/' for one skill, 'files/' for files | No | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Drive entries | **application/json**: [AgentDriveListResponse](#agentdrivelistresponse)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/agent/drive/files/download
|
||||
Time-limited external signed URL for one drive value (no streaming proxy)
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | Application ID | Yes | string (uuid) |
|
||||
| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string |
|
||||
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Signed URL | **application/json**: [AgentDriveDownloadResponse](#agentdrivedownloadresponse)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/agent/drive/files/preview
|
||||
Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | Application ID | Yes | string (uuid) |
|
||||
| key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string |
|
||||
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Preview | **application/json**: [AgentDrivePreviewResponse](#agentdrivepreviewresponse)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/agent/drive/skills
|
||||
List drive-backed skills for the bound agent
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | Application ID | Yes | string (uuid) |
|
||||
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
|
||||
| prefix | query | Key prefix filter: '<slug>/' for one skill, 'files/' for files | No | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Drive skills | **application/json**: [AgentDriveSkillListResponse](#agentdriveskilllistresponse)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/agent/drive/skills/{skill_path}/inspect
|
||||
Inspect one drive-backed skill for slash-menu hover/detail UI
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | Application ID | Yes | string (uuid) |
|
||||
| skill_path | path | Skill path/slug, e.g. tender-analyzer | Yes | string |
|
||||
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Drive skill inspect view | **application/json**: [AgentDriveSkillInspectResponse](#agentdriveskillinspectresponse)<br> |
|
||||
|
||||
### [DELETE] /apps/{app_id}/agent/files
|
||||
Delete one drive file by key via drive commit-null semantics
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | Application ID | Yes | string (uuid) |
|
||||
| key | query | Drive key, e.g. files/sample.pdf | Yes | string |
|
||||
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | File removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/agent/files
|
||||
**ADD FILE: commit one uploaded file into the bound agent's drive**
|
||||
|
||||
Commit an uploaded file into the agent drive under files/<name> (ENG-625 D3)
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | Application ID | Yes | string (uuid) |
|
||||
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [AgentDriveFilePayload](#agentdrivefilepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/agent/logs
|
||||
**Get agent logs**
|
||||
|
||||
@ -2338,68 +2042,6 @@ Get agent execution logs for an application
|
||||
| 200 | Agent logs retrieved successfully | **application/json**: [AgentLogResponse](#agentlogresponse)<br> |
|
||||
| 400 | Invalid request parameters | |
|
||||
|
||||
### [POST] /apps/{app_id}/agent/skills/upload
|
||||
**Upload a Skill, validate it, and commit drive-backed skill files**
|
||||
|
||||
Upload + standardize a Skill into the agent drive
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | Application ID | Yes | string (uuid) |
|
||||
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **multipart/form-data**: { **"file"**: binary }<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | Skill uploaded into drive | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)<br> |
|
||||
| 400 | Invalid skill package or no bound agent | |
|
||||
|
||||
### [DELETE] /apps/{app_id}/agent/skills/{slug}
|
||||
Delete a standardized skill by removing its known drive keys via commit-null
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | Application ID | Yes | string (uuid) |
|
||||
| slug | path | Skill slug (single path segment) | Yes | string |
|
||||
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Skill removed | **application/json**: [AgentDriveDeleteResponse](#agentdrivedeleteresponse)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/agent/skills/{slug}/infer-tools
|
||||
**Suggest CLI tools/env for a skill**
|
||||
|
||||
Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)
|
||||
Saving still goes through composer validation.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | Application ID | Yes | string (uuid) |
|
||||
| slug | path | Skill slug (single path segment) | Yes | string |
|
||||
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/annotation-reply/{action}
|
||||
Enable or disable annotation reply for an app
|
||||
|
||||
@ -15968,135 +15610,6 @@ Stable Agent Soul reference to one normalized skill archive.
|
||||
| debug_conversation_id | string | | Yes |
|
||||
| debug_conversation_message_count | integer | | No |
|
||||
|
||||
#### AgentDriveDeleteFileByAgentQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| key | string | Drive key, e.g. files/sample.pdf | Yes |
|
||||
|
||||
#### AgentDriveDeleteResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| removed_keys | [ string ] | | No |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### AgentDriveDownloadResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| url | string | | Yes |
|
||||
|
||||
#### AgentDriveFileCommitResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| file | [AgentDriveFileResponse](#agentdrivefileresponse) | | Yes |
|
||||
|
||||
#### AgentDriveFilePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| upload_file_id | string | UploadFile UUID from POST /console/api/files/upload | Yes |
|
||||
|
||||
#### AgentDriveFileResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| drive_key | string | | Yes |
|
||||
| file_id | string | | Yes |
|
||||
| mime_type | string | | No |
|
||||
| name | string | | Yes |
|
||||
| size | integer | | No |
|
||||
|
||||
#### AgentDriveItemResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| created_at | integer | | No |
|
||||
| file_kind | string | | Yes |
|
||||
| hash | string | | No |
|
||||
| is_skill | boolean | | No |
|
||||
| key | string | | Yes |
|
||||
| mime_type | string | | No |
|
||||
| size | integer | | No |
|
||||
| skill_metadata | string | | No |
|
||||
|
||||
#### AgentDriveListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| items | [ [AgentDriveItemResponse](#agentdriveitemresponse) ] | | No |
|
||||
|
||||
#### AgentDrivePreviewResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| binary | boolean | | Yes |
|
||||
| key | string | | Yes |
|
||||
| size | integer | | No |
|
||||
| text | string | | No |
|
||||
| truncated | boolean | | Yes |
|
||||
|
||||
#### AgentDriveSkillFileResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| available_in_drive | boolean | | Yes |
|
||||
| drive_key | string | | No |
|
||||
| name | string | | Yes |
|
||||
| path | string | | Yes |
|
||||
| type | string | | Yes |
|
||||
|
||||
#### AgentDriveSkillInspectResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| archive_key | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| description | string | | Yes |
|
||||
| file_tree | [ object ] | | No |
|
||||
| files | [ [AgentDriveSkillFileResponse](#agentdriveskillfileresponse) ] | | No |
|
||||
| hash | string | | No |
|
||||
| mime_type | string | | No |
|
||||
| name | string | | Yes |
|
||||
| path | string | | Yes |
|
||||
| size | integer | | No |
|
||||
| skill_md | [AgentDriveSkillMarkdownResponse](#agentdriveskillmarkdownresponse) | | Yes |
|
||||
| skill_md_key | string | | Yes |
|
||||
| source | string | | Yes |
|
||||
| warnings | [ string ] | | No |
|
||||
|
||||
#### AgentDriveSkillItemResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| archive_key | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| description | string | | Yes |
|
||||
| hash | string | | No |
|
||||
| mime_type | string | | No |
|
||||
| name | string | | Yes |
|
||||
| path | string | | Yes |
|
||||
| size | integer | | No |
|
||||
| skill_md_key | string | | Yes |
|
||||
|
||||
#### AgentDriveSkillListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| items | [ [AgentDriveSkillItemResponse](#agentdriveskillitemresponse) ] | | No |
|
||||
|
||||
#### AgentDriveSkillMarkdownResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| binary | boolean | | Yes |
|
||||
| key | string | | Yes |
|
||||
| size | integer | | No |
|
||||
| text | string | | No |
|
||||
| truncated | boolean | | Yes |
|
||||
|
||||
#### AgentEnvVariableConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -16120,7 +15633,6 @@ Stable Agent Soul reference to one normalized skill archive.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| drive_key | string | | No |
|
||||
| file_id | string | | No |
|
||||
| id | string | | No |
|
||||
| name | string | | No |
|
||||
@ -16477,13 +15989,6 @@ section may be empty, which is how callers express "no knowledge layer".
|
||||
| status | string | | Yes |
|
||||
| total_tokens | integer | | Yes |
|
||||
|
||||
#### AgentLogQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conversation_id | string | Conversation UUID | Yes |
|
||||
| message_id | string | Message UUID | Yes |
|
||||
|
||||
#### AgentLogResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -16741,28 +16246,6 @@ Visibility and lifecycle scope of an Agent record.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### AgentSkillRefConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| file_id | string | | No |
|
||||
| full_archive_file_id | string | | No |
|
||||
| full_archive_key | string | | No |
|
||||
| id | string | | No |
|
||||
| manifest_files | [ string ] | | No |
|
||||
| name | string | | No |
|
||||
| path | string | | No |
|
||||
| skill_md_file_id | string | | No |
|
||||
| skill_md_key | string | | No |
|
||||
|
||||
#### AgentSkillUploadResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| manifest | [SkillManifest](#skillmanifest) | | Yes |
|
||||
| skill | [AgentUploadedSkillResponse](#agentuploadedskillresponse) | | Yes |
|
||||
|
||||
#### AgentSoulAppFeaturesConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -16786,7 +16269,6 @@ Visibility and lifecycle scope of an Agent record.
|
||||
| config_note | string | | No |
|
||||
| config_skills | [ [AgentConfigSkillRefConfig](#agentconfigskillrefconfig) ] | | No |
|
||||
| env | [AgentSoulEnvConfig](#agentsoulenvconfig) | | No |
|
||||
| files | [AgentSoulFilesConfig](#agentsoulfilesconfig) | | No |
|
||||
| human | [AgentSoulHumanConfig](#agentsoulhumanconfig) | | No |
|
||||
| knowledge | [AgentSoulKnowledgeConfig](#agentsoulknowledgeconfig) | | No |
|
||||
| memory | [AgentSoulMemoryConfig](#agentsoulmemoryconfig) | | No |
|
||||
@ -16844,13 +16326,6 @@ old Agent tool payloads can be read while new payloads stay explicit.
|
||||
| secret_refs | [ [AgentSecretRefConfig](#agentsecretrefconfig) ] | | No |
|
||||
| variables | [ [AgentEnvVariableConfig](#agentenvvariableconfig) ] | | No |
|
||||
|
||||
#### AgentSoulFilesConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| files | [ [AgentFileRefConfig](#agentfilerefconfig) ] | | No |
|
||||
| skills | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No |
|
||||
|
||||
#### AgentSoulHumanConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -17097,16 +16572,6 @@ Legacy Chat App model config used only for follow-up question generation.
|
||||
| tool_output | object | | Yes |
|
||||
| tool_parameters | object | | Yes |
|
||||
|
||||
#### AgentUploadedSkillResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| archive_key | string | | No |
|
||||
| description | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| path | string | | Yes |
|
||||
| skill_md_key | string | | Yes |
|
||||
|
||||
#### AgentUserSatisfactionRateStatisticResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -18136,17 +17601,6 @@ Button styles for user actions.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | Child chunk text content. | Yes |
|
||||
|
||||
#### CliToolSuggestion
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| command | string | | No |
|
||||
| description | string | | No |
|
||||
| env_suggestions | [ [EnvSuggestion](#envsuggestion) ] | | No |
|
||||
| inferred_from | string | | No |
|
||||
| install_commands | [ string ] | | No |
|
||||
| name | string | | Yes |
|
||||
|
||||
#### CloudPlan
|
||||
|
||||
Enum representing user plan types in the cloud platform.
|
||||
@ -19733,7 +19187,8 @@ Portable DSL reference that could not be restored in the target workspace.
|
||||
| email | string | | Yes |
|
||||
| language | string | | No |
|
||||
| timezone | string | | No |
|
||||
| token | string | | Yes |
|
||||
| token | string (uuid) | | Yes |
|
||||
| turnstile_token | string | Cloudflare Turnstile token for email-code verification. | No |
|
||||
|
||||
#### EmailCodeSendPayload
|
||||
|
||||
@ -19940,14 +19395,6 @@ declaration of an endpoint group
|
||||
| name | string | | Yes |
|
||||
| settings | object | | Yes |
|
||||
|
||||
#### EnvSuggestion
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| key | string | | Yes |
|
||||
| reason | string | | No |
|
||||
| secret_likely | boolean | | No |
|
||||
|
||||
#### EnvironmentVariableItemPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -26069,27 +25516,6 @@ Simple provider entity response.
|
||||
| title | string | | Yes |
|
||||
| use_icon_as_answer_icon | boolean | | Yes |
|
||||
|
||||
#### SkillManifest
|
||||
|
||||
Validated metadata extracted from a Skill package.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | Yes |
|
||||
| entry_path | string | | Yes |
|
||||
| files | [ string ] | | Yes |
|
||||
| hash | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| size | integer | | Yes |
|
||||
|
||||
#### SkillToolInferenceResult
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| cli_tools | [ [CliToolSuggestion](#clitoolsuggestion) ] | | No |
|
||||
| inferable | boolean | | Yes |
|
||||
| reason | string | | No |
|
||||
|
||||
#### SnippetDependencyCheckResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -826,7 +826,9 @@ Retrieve the access mode for a web application (public or restricted).
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [AccessModeResponse](#accessmoderesponse)<br> |
|
||||
| 400 | Bad Request | |
|
||||
| 404 | App Not Found | |
|
||||
| 500 | Internal Server Error | |
|
||||
| 503 | Web App Access Service Unavailable | |
|
||||
|
||||
### [GET] /webapp/permission
|
||||
Check if user has permission to access a web application.
|
||||
|
||||
@ -11,13 +11,14 @@ from core.app.apps.agent_app.app_variable_projection import agent_app_variables_
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from models.agent import AgentConfigSnapshot
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import App, AppMode, AppModelConfig, load_annotation_reply_config
|
||||
from models.model import App, AppMode, AppModelConfig, Site, load_annotation_reply_config
|
||||
from models.tools import ApiToolProvider
|
||||
from models.workflow import Workflow
|
||||
from services.app_definition_query_service import (
|
||||
AppDefinitionQuery,
|
||||
AppDefinitionSummary,
|
||||
AppParameterConfig,
|
||||
AppSiteConfiguration,
|
||||
AppToolIconSource,
|
||||
)
|
||||
|
||||
@ -145,6 +146,30 @@ class AppDefinitionQueryRepository(AppDefinitionQuery):
|
||||
author_name=app.author_name_with_session(session=session),
|
||||
)
|
||||
|
||||
@override
|
||||
def get_site_configuration(self, app_id: str) -> AppSiteConfiguration | None:
|
||||
with self._session_factory() as session:
|
||||
site = session.scalar(select(Site).where(Site.app_id == app_id).limit(1))
|
||||
if site is None:
|
||||
return None
|
||||
|
||||
return AppSiteConfiguration(
|
||||
title=site.title,
|
||||
chat_color_theme=site.chat_color_theme,
|
||||
chat_color_theme_inverted=site.chat_color_theme_inverted,
|
||||
icon_type=site.icon_type.value if site.icon_type is not None else None,
|
||||
icon=site.icon,
|
||||
icon_background=site.icon_background,
|
||||
description=site.description,
|
||||
copyright=site.copyright,
|
||||
privacy_policy=site.privacy_policy,
|
||||
input_placeholder=site.input_placeholder,
|
||||
custom_disclaimer=site.custom_disclaimer,
|
||||
default_language=site.default_language,
|
||||
show_workflow_steps=site.show_workflow_steps,
|
||||
use_icon_as_answer_icon=site.use_icon_as_answer_icon,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_tools(session: Session, app: App) -> list[dict[str, Any]]:
|
||||
if app.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
||||
|
||||
24
api/repositories/webapp_access_query_repository.py
Normal file
24
api/repositories/webapp_access_query_repository.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Database repository for web-app access queries."""
|
||||
|
||||
from typing import override
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import DBAPIError, TimeoutError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.model import Site
|
||||
from services.webapp_access_query_service import WebAppAccessQuery, WebAppAccessUnavailableError
|
||||
|
||||
|
||||
class WebAppAccessQueryRepository(WebAppAccessQuery):
|
||||
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
@override
|
||||
def find_app_id_by_code(self, app_code: str) -> str | None:
|
||||
try:
|
||||
with self._session_factory() as session:
|
||||
app_id = session.scalar(select(Site.app_id).where(Site.code == app_code).limit(1))
|
||||
return str(app_id) if app_id is not None else None
|
||||
except (DBAPIError, TimeoutError) as e:
|
||||
raise WebAppAccessUnavailableError from e
|
||||
@ -139,10 +139,10 @@ class WorkflowCollaborationRepository:
|
||||
self._redis.delete(self.sid_key(sid))
|
||||
|
||||
def session_exists(self, workflow_id: str, sid: str) -> bool:
|
||||
return bool(self._redis.hexists(self.workflow_key(workflow_id), sid))
|
||||
return self._redis.hexists(self.workflow_key(workflow_id), sid)
|
||||
|
||||
def sid_mapping_exists(self, sid: str) -> bool:
|
||||
return bool(self._redis.exists(self.sid_key(sid)))
|
||||
return self._redis.exists(self.sid_key(sid))
|
||||
|
||||
def get_session_sids(self, workflow_id: str) -> list[str]:
|
||||
raw_sids = self._redis.hkeys(self.workflow_key(workflow_id))
|
||||
@ -237,7 +237,7 @@ class WorkflowCollaborationRepository:
|
||||
self._redis.set(self.server_key(server_id), "1", ex=SERVER_HEARTBEAT_TTL_SECONDS)
|
||||
|
||||
def server_heartbeat_exists(self, server_id: str) -> bool:
|
||||
return bool(self._redis.exists(self.server_key(server_id)))
|
||||
return bool(self._redis.exists(self.server_key(server_id))) # tests assert `is True`
|
||||
|
||||
def refresh_server_sessions(self, server_id: str) -> None:
|
||||
"""Refresh Redis TTLs for sessions owned by a live websocket worker."""
|
||||
|
||||
@ -48,6 +48,10 @@ from models.account import (
|
||||
from models.dataset import Dataset
|
||||
from models.model import App, DifySetup
|
||||
from services.billing_service import BillingService
|
||||
from services.email_code_login_challenge import (
|
||||
EmailCodeLoginChallengeResult,
|
||||
EmailCodeLoginChallengeStore,
|
||||
)
|
||||
from services.enterprise.rbac_service import ListOption, RBACService
|
||||
from services.entities.auth_entities import (
|
||||
ChangeEmailNewEmailToken,
|
||||
@ -1017,14 +1021,17 @@ class AccountService:
|
||||
email = account.email if account else email
|
||||
if email is None:
|
||||
raise ValueError("Email must be provided.")
|
||||
email = email.lower()
|
||||
if cls.email_code_login_rate_limiter.is_rate_limited(email):
|
||||
from controllers.console.auth.error import EmailCodeLoginRateLimitExceededError
|
||||
|
||||
raise EmailCodeLoginRateLimitExceededError(int(cls.email_code_login_rate_limiter.time_window / 60))
|
||||
|
||||
code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)])
|
||||
token = TokenManager.generate_token(
|
||||
account=account, email=email, token_type="email_code_login", additional_data={"code": code}
|
||||
token = EmailCodeLoginChallengeStore.create(
|
||||
account_id=str(account.id) if account else None,
|
||||
email=email,
|
||||
code=code,
|
||||
)
|
||||
send_email_code_login_mail_task.delay(
|
||||
language=language,
|
||||
@ -1052,6 +1059,10 @@ class AccountService:
|
||||
def get_email_code_login_data(cls, token: str) -> dict[str, Any] | None:
|
||||
return TokenManager.get_token_data(token, "email_code_login")
|
||||
|
||||
@classmethod
|
||||
def verify_email_code_login_challenge(cls, *, email: str, code: str, token: str) -> EmailCodeLoginChallengeResult:
|
||||
return EmailCodeLoginChallengeStore.verify(email=email, code=code, token=token)
|
||||
|
||||
@classmethod
|
||||
def revoke_email_code_login_token(cls, token: str):
|
||||
TokenManager.revoke_token(token, "email_code_login")
|
||||
|
||||
@ -5,7 +5,6 @@ from typing import Any
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
|
||||
from libs.helper import to_timestamp
|
||||
@ -20,7 +19,6 @@ from models.agent import (
|
||||
AgentConfigSnapshot,
|
||||
AgentConfigVersionKind,
|
||||
AgentDebugConversation,
|
||||
AgentDriveFile,
|
||||
AgentIconType,
|
||||
AgentKind,
|
||||
AgentScope,
|
||||
@ -279,12 +277,7 @@ class AgentComposerService:
|
||||
state = cls._serialize_workflow_state(
|
||||
session=session, binding=binding, agent=agent, version=version, account_id=account_id
|
||||
)
|
||||
state["validation"] = cls.collect_validation_findings(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
agent_id=binding.agent_id,
|
||||
)
|
||||
state["validation"] = cls.collect_validation_findings(payload=payload)
|
||||
session.commit()
|
||||
binding_ids, home_snapshot_ids = WorkflowAgentRetirementService.retire_unowned(
|
||||
tenant_id=tenant_id,
|
||||
@ -365,16 +358,6 @@ class AgentComposerService:
|
||||
icon=source_agent.icon,
|
||||
icon_background=source_agent.icon_background,
|
||||
)
|
||||
cls._copy_agent_drive_rows(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
source_agent_id=source_agent.id,
|
||||
target_agent_id=inline_agent.id,
|
||||
account_id=account_id,
|
||||
agent_soul=agent_soul,
|
||||
node_job=WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict),
|
||||
)
|
||||
|
||||
binding.binding_type = WorkflowAgentBindingType.INLINE_AGENT
|
||||
binding.agent_id = inline_agent.id
|
||||
binding.current_snapshot_id = inline_agent.active_config_snapshot_id
|
||||
@ -581,12 +564,7 @@ class AgentComposerService:
|
||||
|
||||
session.flush()
|
||||
state = cls.load_agent_composer(session=session, tenant_id=tenant_id, agent_id=agent.id)
|
||||
state["validation"] = cls.collect_validation_findings(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
agent_id=agent.id,
|
||||
)
|
||||
state["validation"] = cls.collect_validation_findings(payload=payload)
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
@ -1051,12 +1029,9 @@ class AgentComposerService:
|
||||
def collect_validation_findings(
|
||||
cls,
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
payload: ComposerSavePayload,
|
||||
agent_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""ENG-617 soft findings, with DB-backed dataset and drive mention checks."""
|
||||
"""Collect non-blocking composer validation findings."""
|
||||
existing_knowledge_set_ids = (
|
||||
{knowledge_set.id for knowledge_set in payload.agent_soul.knowledge.sets}
|
||||
if payload.agent_soul is not None
|
||||
@ -1066,15 +1041,6 @@ class AgentComposerService:
|
||||
payload,
|
||||
existing_knowledge_set_ids=existing_knowledge_set_ids,
|
||||
)
|
||||
if agent_id and payload.agent_soul is not None:
|
||||
findings["warnings"].extend(
|
||||
cls._drive_mention_findings(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
prompt=payload.agent_soul.prompt.system_prompt,
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
@classmethod
|
||||
@ -1099,21 +1065,6 @@ class AgentComposerService:
|
||||
+ ", ".join(missing_ids)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve_bound_agent_id(cls, *, session: Session, tenant_id: str, app_id: str) -> str | None:
|
||||
"""The Agent App's bound roster agent id, if any (validate-endpoint context)."""
|
||||
return session.scalar(
|
||||
select(Agent.id)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve_workflow_node_agent_id(
|
||||
cls, *, session: Session, tenant_id: str, app_id: str, node_id: str
|
||||
@ -1128,54 +1079,6 @@ class AgentComposerService:
|
||||
)
|
||||
return binding.agent_id if binding else None
|
||||
|
||||
@classmethod
|
||||
def _drive_mention_findings(
|
||||
cls,
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
prompt: str,
|
||||
) -> list[dict[str, str | None]]:
|
||||
"""Soft warnings for missing drive-backed prompt mentions."""
|
||||
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
|
||||
from services.agent_drive_service import decode_drive_mention_ref
|
||||
|
||||
wanted_keys: dict[str, tuple[str, str]] = {}
|
||||
for mention in parse_prompt_mentions(prompt):
|
||||
if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
|
||||
continue
|
||||
decoded_key = decode_drive_mention_ref(mention.ref_id)
|
||||
if not decoded_key:
|
||||
continue
|
||||
wanted_keys[decoded_key] = (mention.kind.value, mention.label or decoded_key)
|
||||
if not wanted_keys:
|
||||
return []
|
||||
|
||||
existing_keys = set(
|
||||
session.scalars(
|
||||
select(AgentDriveFile.key).where(
|
||||
AgentDriveFile.tenant_id == tenant_id,
|
||||
AgentDriveFile.agent_id == agent_id,
|
||||
AgentDriveFile.key.in_(sorted(wanted_keys)),
|
||||
)
|
||||
)
|
||||
)
|
||||
findings: list[dict[str, str | None]] = []
|
||||
for key, (kind, display) in wanted_keys.items():
|
||||
if key in existing_keys:
|
||||
continue
|
||||
findings.append(
|
||||
{
|
||||
"code": "mention_target_missing",
|
||||
"surface": "agent_soul",
|
||||
"kind": kind,
|
||||
"id": key,
|
||||
"message": f"{kind} '{display}' has no drive entry for key '{key}'.",
|
||||
}
|
||||
)
|
||||
return findings
|
||||
|
||||
@classmethod
|
||||
def get_workflow_candidates(
|
||||
cls, *, session: Session, tenant_id: str, app_id: str, node_id: str, user_id: str
|
||||
@ -1721,15 +1624,6 @@ class AgentComposerService:
|
||||
operation=AgentConfigRevisionOperation.SAVE_TO_ROSTER,
|
||||
version_note=payload.version_note,
|
||||
)
|
||||
cls._copy_agent_drive_rows(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
source_agent_id=source_agent.id,
|
||||
target_agent_id=roster_agent.id,
|
||||
account_id=account_id,
|
||||
agent_soul=agent_soul,
|
||||
node_job=payload.node_job or WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict),
|
||||
)
|
||||
binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT
|
||||
binding.agent_id = roster_agent.id
|
||||
binding.current_snapshot_id = roster_agent.active_config_snapshot_id
|
||||
@ -1801,99 +1695,6 @@ class AgentComposerService:
|
||||
agent.active_config_is_published = True
|
||||
return agent
|
||||
|
||||
@classmethod
|
||||
def _copy_agent_drive_rows(
|
||||
cls,
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
source_agent_id: str,
|
||||
target_agent_id: str,
|
||||
account_id: str,
|
||||
agent_soul: AgentSoulConfig,
|
||||
node_job: WorkflowNodeJobConfig | None = None,
|
||||
) -> None:
|
||||
exact_keys, prefixes = cls._drive_copy_scopes_from_agent_configs(agent_soul=agent_soul, node_job=node_job)
|
||||
predicates: list[ColumnElement[bool]] = []
|
||||
if exact_keys:
|
||||
predicates.append(AgentDriveFile.key.in_(sorted(exact_keys)))
|
||||
predicates.extend(AgentDriveFile.key.startswith(prefix) for prefix in sorted(prefixes))
|
||||
if not predicates:
|
||||
return
|
||||
|
||||
source_rows = list(
|
||||
session.scalars(
|
||||
select(AgentDriveFile).where(
|
||||
AgentDriveFile.tenant_id == tenant_id,
|
||||
AgentDriveFile.agent_id == source_agent_id,
|
||||
or_(*predicates),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if not source_rows:
|
||||
return
|
||||
|
||||
existing_target_keys = set(
|
||||
session.scalars(
|
||||
select(AgentDriveFile.key).where(
|
||||
AgentDriveFile.tenant_id == tenant_id,
|
||||
AgentDriveFile.agent_id == target_agent_id,
|
||||
AgentDriveFile.key.in_([row.key for row in source_rows]),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
for row in source_rows:
|
||||
if row.key in existing_target_keys:
|
||||
continue
|
||||
session.add(
|
||||
AgentDriveFile(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=target_agent_id,
|
||||
key=row.key,
|
||||
file_kind=row.file_kind,
|
||||
file_id=row.file_id,
|
||||
value_owned_by_drive=row.value_owned_by_drive,
|
||||
is_skill=row.is_skill,
|
||||
skill_metadata=row.skill_metadata,
|
||||
size=row.size,
|
||||
hash=row.hash,
|
||||
mime_type=row.mime_type,
|
||||
created_by=account_id,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _drive_copy_scopes_from_agent_configs(
|
||||
*, agent_soul: AgentSoulConfig, node_job: WorkflowNodeJobConfig | None = None
|
||||
) -> tuple[set[str], set[str]]:
|
||||
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
|
||||
from services.agent_drive_service import decode_drive_mention_ref
|
||||
|
||||
exact_keys: set[str] = set()
|
||||
prefixes: set[str] = set()
|
||||
|
||||
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt):
|
||||
if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
|
||||
continue
|
||||
drive_key = decode_drive_mention_ref(mention.ref_id)
|
||||
if not drive_key:
|
||||
continue
|
||||
if mention.kind == MentionKind.SKILL and "/" in drive_key:
|
||||
prefixes.add(f"{drive_key.rsplit('/', 1)[0]}/")
|
||||
else:
|
||||
exact_keys.add(drive_key)
|
||||
|
||||
if node_job is not None:
|
||||
for file_ref in node_job.metadata.file_refs or []:
|
||||
if file_ref.drive_key:
|
||||
exact_keys.add(file_ref.drive_key)
|
||||
for output in node_job.declared_outputs:
|
||||
benchmark_ref = output.check.benchmark_file_ref if output.check and output.check.enabled else None
|
||||
if benchmark_ref and benchmark_ref.drive_key:
|
||||
exact_keys.add(benchmark_ref.drive_key)
|
||||
|
||||
return exact_keys, prefixes
|
||||
|
||||
@classmethod
|
||||
def _create_roster_agent_for_composer(
|
||||
cls,
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
"""Normalize uploaded config skills into one canonical ToolFile reference.
|
||||
|
||||
Config skills are Agent Soul-backed assets, not drive rows. This service keeps
|
||||
the existing skill package validation rules, enforces the requested stable name,
|
||||
stores the normalized archive as one ToolFile, and returns the persisted Soul
|
||||
reference metadata used by ``AgentConfigService``.
|
||||
This service keeps the existing skill package validation rules, enforces the
|
||||
requested stable name, stores the normalized archive as one ToolFile, and
|
||||
returns the persisted Soul reference metadata used by ``AgentConfigService``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -3,8 +3,7 @@
|
||||
Agent runtime configuration is split across immutable Soul snapshots and
|
||||
workflow-node bindings, while App and Snippet DSLs must be independent of the
|
||||
source workspace's database identifiers. This module owns that translation.
|
||||
It deliberately excludes drive payloads and stored credentials from portable
|
||||
packages; same-workspace copies may use the separate server-side clone path.
|
||||
It deliberately excludes stored credentials from portable packages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -327,7 +326,6 @@ class AgentDslService:
|
||||
node_id: str,
|
||||
source_agent: Agent,
|
||||
source_snapshot: AgentConfigSnapshot,
|
||||
node_job: WorkflowNodeJobConfig,
|
||||
account_id: str,
|
||||
) -> tuple[Agent, AgentConfigSnapshot]:
|
||||
"""Clone a same-workspace Inline Agent for a pasted target node."""
|
||||
@ -350,17 +348,6 @@ class AgentDslService:
|
||||
source=AgentSource.WORKFLOW,
|
||||
operation=AgentConfigRevisionOperation.CREATE_VERSION,
|
||||
)
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
|
||||
AgentComposerService._copy_agent_drive_rows(
|
||||
tenant_id=workflow.tenant_id,
|
||||
source_agent_id=source_agent.id,
|
||||
target_agent_id=agent.id,
|
||||
account_id=account_id,
|
||||
agent_soul=soul,
|
||||
node_job=node_job,
|
||||
session=self.session,
|
||||
)
|
||||
return agent, snapshot
|
||||
|
||||
def extract_package_dependencies(self, packages: Mapping[str, AgentPackage]) -> list[str]:
|
||||
|
||||
@ -66,9 +66,7 @@ _RESIDUAL_MENTION_PATTERN = re.compile(r"\[§([A-Za-z_][A-Za-z0-9_]*:[^§]*?)§\
|
||||
WORKFLOW_VARIABLE_PATTERN = re.compile(r"\{\{#([^{}#]+?\.[^{}#]+?)#\}\}")
|
||||
|
||||
MAX_MENTIONS_PER_PROMPT = 200
|
||||
# Drive keys are validated up to 512 Unicode code points before URL encoding.
|
||||
# Worst case, one code point becomes 4 UTF-8 bytes and each byte becomes a
|
||||
# 3-character ``%XX`` escape, so a valid encoded drive key can reach 6144 chars.
|
||||
# Mention ids are bounded independently of their owning configuration schema.
|
||||
MAX_MENTION_REF_ID_LENGTH = 6144
|
||||
MAX_MENTION_LABEL_LENGTH = 255
|
||||
|
||||
@ -241,7 +239,7 @@ def scrub_mention_markers(text: str) -> str:
|
||||
|
||||
|
||||
def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
|
||||
"""Resolve non-drive soul-surface mentions to canonical display names."""
|
||||
"""Resolve Soul-surface mentions to canonical display names."""
|
||||
|
||||
def _resolve(mention: PromptMention) -> str | None:
|
||||
match mention.kind:
|
||||
|
||||
125
api/services/agent/runtime_config_service.py
Normal file
125
api/services/agent/runtime_config_service.py
Normal file
@ -0,0 +1,125 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.agent import (
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigSnapshot,
|
||||
AgentConfigVersionKind,
|
||||
AgentDebugConversation,
|
||||
AgentWorkspaceBinding,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import App, Conversation
|
||||
|
||||
|
||||
class AgentRuntimeConfigService:
|
||||
"""Resolve the Agent Soul generation that produced one conversation."""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self._session = session
|
||||
|
||||
def resolve_conversation_soul(
|
||||
self,
|
||||
*,
|
||||
app_model: App,
|
||||
conversation: Conversation,
|
||||
account_id: str | None,
|
||||
use_debug_draft: bool,
|
||||
) -> AgentSoulConfig | None:
|
||||
if use_debug_draft and account_id is not None:
|
||||
draft_soul = self._resolve_debug_draft_soul(
|
||||
app_model=app_model,
|
||||
conversation=conversation,
|
||||
account_id=account_id,
|
||||
)
|
||||
if draft_soul is not None:
|
||||
return draft_soul
|
||||
|
||||
binding_soul = self._resolve_binding_soul(app_model=app_model, conversation=conversation)
|
||||
if binding_soul is not None:
|
||||
return binding_soul
|
||||
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
|
||||
return AgentRosterService(self._session).get_published_agent_soul_for_app(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
)
|
||||
|
||||
def _resolve_debug_draft_soul(
|
||||
self,
|
||||
*,
|
||||
app_model: App,
|
||||
conversation: Conversation,
|
||||
account_id: str,
|
||||
) -> AgentSoulConfig | None:
|
||||
debug_conversation = self._session.scalar(
|
||||
select(AgentDebugConversation)
|
||||
.where(
|
||||
AgentDebugConversation.tenant_id == app_model.tenant_id,
|
||||
AgentDebugConversation.app_id == app_model.id,
|
||||
AgentDebugConversation.account_id == account_id,
|
||||
AgentDebugConversation.conversation_id == conversation.id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if debug_conversation is None:
|
||||
return None
|
||||
|
||||
draft_stmt = select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.tenant_id == app_model.tenant_id,
|
||||
AgentConfigDraft.agent_id == debug_conversation.agent_id,
|
||||
AgentConfigDraft.draft_type == debug_conversation.draft_type,
|
||||
)
|
||||
if debug_conversation.draft_type == AgentConfigDraftType.DEBUG_BUILD:
|
||||
draft_stmt = draft_stmt.where(AgentConfigDraft.account_id == account_id)
|
||||
draft = self._session.scalar(draft_stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1))
|
||||
if draft is None:
|
||||
return None
|
||||
return AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
|
||||
def _resolve_binding_soul(self, *, app_model: App, conversation: Conversation) -> AgentSoulConfig | None:
|
||||
if not conversation.agent_workspace_binding_id:
|
||||
return None
|
||||
binding = self._session.scalar(
|
||||
select(AgentWorkspaceBinding)
|
||||
.where(
|
||||
AgentWorkspaceBinding.id == conversation.agent_workspace_binding_id,
|
||||
AgentWorkspaceBinding.tenant_id == app_model.tenant_id,
|
||||
AgentWorkspaceBinding.app_id == app_model.id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if binding is None:
|
||||
return None
|
||||
|
||||
if binding.agent_config_version_kind == AgentConfigVersionKind.SNAPSHOT:
|
||||
snapshot = self._session.scalar(
|
||||
select(AgentConfigSnapshot)
|
||||
.where(
|
||||
AgentConfigSnapshot.id == binding.agent_config_version_id,
|
||||
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
|
||||
AgentConfigSnapshot.agent_id == binding.agent_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
|
||||
draft = self._session.scalar(
|
||||
select(AgentConfigDraft)
|
||||
.where(
|
||||
AgentConfigDraft.id == binding.agent_config_version_id,
|
||||
AgentConfigDraft.tenant_id == app_model.tenant_id,
|
||||
AgentConfigDraft.agent_id == binding.agent_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if draft is None:
|
||||
return None
|
||||
return AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
|
||||
|
||||
__all__ = ["AgentRuntimeConfigService"]
|
||||
@ -1,4 +1,4 @@
|
||||
"""Validate and normalize uploaded Skill packages for drive standardization.
|
||||
"""Validate and normalize uploaded Skill packages.
|
||||
|
||||
A Skill is a ``.zip`` / ``.skill`` archive that must contain a ``SKILL.md`` entry
|
||||
file (Anthropic Skills convention: YAML frontmatter with ``name`` + ``description``,
|
||||
@ -10,8 +10,7 @@ archive-root ``SKILL.md`` bytes.
|
||||
|
||||
It does NOT execute or load the skill — the agent backend owns execution. It also
|
||||
does not persist anything into Agent Soul or bind anything to config versions;
|
||||
``SkillStandardizeService`` consumes the normalized package and commits the
|
||||
canonical drive rows instead.
|
||||
``ConfigSkillNormalizeService`` consumes the normalized package for Agent config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -63,7 +62,7 @@ class SkillManifest(BaseModel):
|
||||
|
||||
|
||||
class NormalizedSkillPackage(BaseModel):
|
||||
"""Canonical skill package bytes and metadata ready to store in agent drive."""
|
||||
"""Canonical skill package bytes and metadata ready to store as Agent config."""
|
||||
|
||||
manifest: SkillManifest
|
||||
archive_bytes: bytes
|
||||
@ -72,10 +71,10 @@ class NormalizedSkillPackage(BaseModel):
|
||||
|
||||
|
||||
class SkillPackageService:
|
||||
"""Validate Skill archives and produce the normalized package stored in drive."""
|
||||
"""Validate Skill archives and produce a normalized package."""
|
||||
|
||||
def validate_and_normalize(self, *, content: bytes, filename: str) -> NormalizedSkillPackage:
|
||||
"""Return the canonical drive package for an uploaded skill archive.
|
||||
"""Return the canonical package for an uploaded skill archive.
|
||||
|
||||
The shallowest ``SKILL.md`` defines the skill root. When exactly one
|
||||
depth-2 ``<folder>/SKILL.md`` exists, normalization strips that top-level
|
||||
|
||||
@ -1,135 +0,0 @@
|
||||
"""Standardize an uploaded Skill into the agent drive (ENG-594).
|
||||
|
||||
A validated Skill package is normalized into two **drive-owned** objects committed
|
||||
to the agent drive (Agent Files §5.4 / §4):
|
||||
|
||||
* ``<slug>/SKILL.md`` — the canonical entry, the source of truth for loading.
|
||||
* ``<slug>/.DIFY-SKILL-FULL.zip`` — the full archive, kept only to restore the
|
||||
complete skill contents.
|
||||
|
||||
The archive's member list is stored in skill metadata and resolved lazily for
|
||||
inspect/preview/runtime. Upload must not eagerly materialize every archive member
|
||||
as a separate ToolFile; small archives with many files would otherwise perform
|
||||
hundreds of storage writes and DB commits inside the request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.tools.tool_file_manager import ToolFileManager
|
||||
from services.agent.skill_package_service import SkillPackageService
|
||||
from services.agent_drive_service import AgentDriveService, DriveCommitItem, DriveFileRef, DriveSkillMetadata
|
||||
|
||||
_FULL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip"
|
||||
_SKILL_MD_NAME = "SKILL.md"
|
||||
_SLUG_RE = re.compile(r"[^a-z0-9._-]+")
|
||||
|
||||
|
||||
def slugify_skill_name(name: str) -> str:
|
||||
slug = _SLUG_RE.sub("-", (name or "").strip().lower()).strip("-._")
|
||||
return slug or "skill"
|
||||
|
||||
|
||||
class SkillStandardizeService:
|
||||
"""Persist a normalized skill package into drive-owned files for one agent.
|
||||
|
||||
Instances are intentionally stateful: ``standardize()`` updates
|
||||
``last_committed_items`` with the drive commit result for the most recent call.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
package_service: SkillPackageService | None = None,
|
||||
drive_service: AgentDriveService | None = None,
|
||||
tool_file_manager: ToolFileManager | None = None,
|
||||
) -> None:
|
||||
self._package = package_service or SkillPackageService()
|
||||
self._drive = drive_service or AgentDriveService()
|
||||
self._tool_files = tool_file_manager or ToolFileManager()
|
||||
self.last_committed_items: list[dict[str, Any]] = []
|
||||
|
||||
def standardize(
|
||||
self,
|
||||
*,
|
||||
content: bytes,
|
||||
filename: str,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
session: Session,
|
||||
) -> dict[str, Any]:
|
||||
"""Create two ToolFiles, commit two drive-owned keys, and return skill metadata.
|
||||
|
||||
This writes ``<slug>/SKILL.md`` and ``<slug>/.DIFY-SKILL-FULL.zip``,
|
||||
stores the drive commit rows in ``last_committed_items``, and returns the
|
||||
console response shape ``{"skill": ..., "manifest": ...}``.
|
||||
"""
|
||||
package = self._package.validate_and_normalize(content=content, filename=filename)
|
||||
manifest = package.manifest
|
||||
slug = slugify_skill_name(manifest.name)
|
||||
|
||||
# Drive-owned files: canonical SKILL.md and the full archive. The
|
||||
# archive member tree is preserved in metadata and resolved lazily.
|
||||
md_tool_file = self._tool_files.create_file_by_raw(
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=None,
|
||||
file_binary=package.skill_md_bytes,
|
||||
mimetype="text/markdown",
|
||||
filename=_SKILL_MD_NAME,
|
||||
)
|
||||
archive_tool_file = self._tool_files.create_file_by_raw(
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=None,
|
||||
file_binary=package.archive_bytes,
|
||||
mimetype="application/zip",
|
||||
filename=_FULL_ARCHIVE_NAME,
|
||||
)
|
||||
|
||||
skill_md_key = f"{slug}/{_SKILL_MD_NAME}"
|
||||
archive_key = f"{slug}/{_FULL_ARCHIVE_NAME}"
|
||||
committed_items = self._drive.commit(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key=skill_md_key,
|
||||
file_ref=DriveFileRef(kind="tool_file", id=md_tool_file.id),
|
||||
value_owned_by_drive=True,
|
||||
is_skill=True,
|
||||
skill_metadata=DriveSkillMetadata(
|
||||
name=manifest.name,
|
||||
description=manifest.description,
|
||||
manifest_files=manifest.files,
|
||||
),
|
||||
),
|
||||
DriveCommitItem(
|
||||
key=archive_key,
|
||||
file_ref=DriveFileRef(kind="tool_file", id=archive_tool_file.id),
|
||||
value_owned_by_drive=True,
|
||||
),
|
||||
],
|
||||
session=session,
|
||||
)
|
||||
self.last_committed_items = committed_items
|
||||
|
||||
return {
|
||||
"skill": {
|
||||
"name": manifest.name,
|
||||
"description": manifest.description,
|
||||
"path": slug,
|
||||
"skill_md_key": skill_md_key,
|
||||
"archive_key": archive_key,
|
||||
},
|
||||
"manifest": manifest.model_dump(),
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["SkillStandardizeService", "slugify_skill_name"]
|
||||
@ -1,179 +0,0 @@
|
||||
"""Infer CLI tool + ENV suggestions from a standardized skill (ENG-371).
|
||||
|
||||
Reads the skill's SKILL.md from the agent drive, asks the tenant's default
|
||||
reasoning model once (a plain LLM call, never an agent run), and returns
|
||||
*draft* suggestions only — nothing is persisted here. The frontend prefills
|
||||
the TOOLS box (``inferred from <skill>`` badge) and the Pre-Authorize ENV
|
||||
panel, and saving still goes through the composer's full shell/env/secret/
|
||||
dangerous-command validation, so inference opens no bypass.
|
||||
|
||||
ENV suggestions carry only ``key`` + ``reason`` — the model never produces a
|
||||
value; users fill those in themselves and the runtime injects ``$VAR`` only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import json_repair
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.errors.error import ProviderTokenNotInitError
|
||||
from core.model_manager import ModelManager
|
||||
from graphon.model_runtime.entities.message_entities import SystemPromptMessage, UserPromptMessage
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from services.agent_drive_service import AgentDriveError, AgentDriveService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SkillToolInferenceError(Exception):
|
||||
"""Stable-code error for the infer-tools endpoint."""
|
||||
|
||||
def __init__(self, code: str, message: str, *, status_code: int = 400) -> None:
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class EnvSuggestion(BaseModel):
|
||||
key: str
|
||||
reason: str = ""
|
||||
secret_likely: bool = False
|
||||
|
||||
|
||||
class CliToolSuggestion(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
command: str = ""
|
||||
install_commands: list[str] = Field(default_factory=list)
|
||||
env_suggestions: list[EnvSuggestion] = Field(default_factory=list)
|
||||
inferred_from: str = ""
|
||||
|
||||
|
||||
class SkillToolInferenceResult(BaseModel):
|
||||
inferable: bool
|
||||
cli_tools: list[CliToolSuggestion] = Field(default_factory=list)
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = """\
|
||||
You analyze an agent skill document (SKILL.md) and infer which command-line \
|
||||
tools the skill depends on at runtime, so a user can pre-install them in the \
|
||||
agent's sandbox.
|
||||
|
||||
Rules:
|
||||
- Only suggest tools the document explicitly uses or clearly requires; never guess.
|
||||
- For each tool give: name, a one-line reason-style description referencing the \
|
||||
document, the base command, and install commands for a Debian-based sandbox \
|
||||
(apt-get / pip / npm).
|
||||
- If a step needs an environment variable (an API key, token, endpoint), add it \
|
||||
to env_suggestions with the variable key and the reason. NEVER produce a value. \
|
||||
Mark secret_likely=true for credentials.
|
||||
- If the document describes no external command-line dependency, return \
|
||||
{"inferable": false, "cli_tools": [], "reason": "<one short sentence why>"}.
|
||||
|
||||
Respond with JSON only, matching exactly:
|
||||
{"inferable": bool,
|
||||
"cli_tools": [{"name": str, "description": str, "command": str,
|
||||
"install_commands": [str], "env_suggestions":
|
||||
[{"key": str, "reason": str, "secret_likely": bool}]}],
|
||||
"reason": str | null}
|
||||
"""
|
||||
|
||||
|
||||
class SkillToolInferenceService:
|
||||
"""Single-shot LLM inference over a drive-stored SKILL.md."""
|
||||
|
||||
def __init__(self, *, drive_service: AgentDriveService | None = None) -> None:
|
||||
self._drive = drive_service or AgentDriveService()
|
||||
|
||||
def infer(self, *, tenant_id: str, agent_id: str, slug: str, session: Session) -> dict[str, Any]:
|
||||
skill_md = self._load_skill_md(tenant_id=tenant_id, agent_id=agent_id, slug=slug, session=session)
|
||||
|
||||
user_prompt = f"SKILL.md of skill '{slug}':\n\n{skill_md}"
|
||||
|
||||
raw = self._invoke(tenant_id=tenant_id, user_prompt=user_prompt)
|
||||
try:
|
||||
result = self._parse(raw)
|
||||
except (ValidationError, ValueError):
|
||||
logger.warning("skill tool inference output unparsable, retrying once")
|
||||
raw = self._invoke(tenant_id=tenant_id, user_prompt=user_prompt)
|
||||
try:
|
||||
result = self._parse(raw)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
raise SkillToolInferenceError(
|
||||
"inference_failed",
|
||||
"inference_failed: the model output could not be parsed into tool suggestions.",
|
||||
status_code=422,
|
||||
) from exc
|
||||
|
||||
for tool in result.cli_tools:
|
||||
tool.inferred_from = slug
|
||||
return result.model_dump(mode="json")
|
||||
|
||||
def _load_skill_md(self, *, tenant_id: str, agent_id: str, slug: str, session: Session) -> str:
|
||||
try:
|
||||
preview = self._drive.preview(
|
||||
tenant_id=tenant_id, agent_id=agent_id, key=f"{slug}/SKILL.md", session=session
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
if exc.code == "drive_key_not_found":
|
||||
raise SkillToolInferenceError(
|
||||
"skill_not_found", f"skill_not_found: no drive entry for skill '{slug}'.", status_code=404
|
||||
) from exc
|
||||
raise SkillToolInferenceError(exc.code, exc.message, status_code=exc.status_code) from exc
|
||||
if preview["binary"] or not preview["text"]:
|
||||
raise SkillToolInferenceError(
|
||||
"skill_not_found", f"skill_not_found: SKILL.md of '{slug}' is not readable text.", status_code=404
|
||||
)
|
||||
return str(preview["text"])
|
||||
|
||||
@staticmethod
|
||||
def _invoke(*, tenant_id: str, user_prompt: str) -> str:
|
||||
try:
|
||||
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
|
||||
model_instance = model_manager.get_default_model_instance(tenant_id=tenant_id, model_type=ModelType.LLM)
|
||||
except ProviderTokenNotInitError as exc:
|
||||
raise SkillToolInferenceError(
|
||||
"default_model_not_configured",
|
||||
"default_model_not_configured: the workspace has no default reasoning model.",
|
||||
status_code=400,
|
||||
) from exc
|
||||
try:
|
||||
response = model_instance.invoke_llm(
|
||||
prompt_messages=[
|
||||
SystemPromptMessage(content=_SYSTEM_PROMPT),
|
||||
UserPromptMessage(content=user_prompt),
|
||||
],
|
||||
model_parameters={"temperature": 0.1},
|
||||
stream=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise SkillToolInferenceError(
|
||||
"inference_failed", f"inference_failed: model invocation failed: {exc}", status_code=422
|
||||
) from exc
|
||||
return response.message.get_text_content()
|
||||
|
||||
@staticmethod
|
||||
def _parse(raw: str) -> SkillToolInferenceResult:
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
parsed = json_repair.loads(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("model output is not a JSON object")
|
||||
return SkillToolInferenceResult.model_validate(parsed)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CliToolSuggestion",
|
||||
"EnvSuggestion",
|
||||
"SkillToolInferenceError",
|
||||
"SkillToolInferenceResult",
|
||||
"SkillToolInferenceService",
|
||||
]
|
||||
@ -186,22 +186,17 @@ class WorkflowAgentPublishService:
|
||||
node_job=node_job,
|
||||
)
|
||||
ComposerConfigValidator.validate_publish_payload(payload)
|
||||
# ENG-623 §4.4: drive-backed refs must point at real drive rows before
|
||||
# publishing. This stays out of composer save so autosave/save-draft can
|
||||
# persist incomplete refs and surface them as non-blocking findings.
|
||||
cls._require_drive_refs_resolved_for_publish(session=session, binding=binding, agent_soul=agent_soul)
|
||||
cls._require_config_asset_refs_resolved_for_publish(binding=binding, agent_soul=agent_soul)
|
||||
|
||||
@classmethod
|
||||
def _require_drive_refs_resolved_for_publish(
|
||||
def _require_config_asset_refs_resolved_for_publish(
|
||||
cls,
|
||||
*,
|
||||
session: Session,
|
||||
binding: WorkflowAgentNodeBinding,
|
||||
agent_soul: AgentSoulConfig,
|
||||
) -> None:
|
||||
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
|
||||
|
||||
del session
|
||||
configured_skill_names = {item.name for item in agent_soul.config_skills if not item.is_missing}
|
||||
configured_file_names = {item.name for item in agent_soul.config_files if not item.is_missing}
|
||||
missing_refs: list[str] = []
|
||||
@ -359,7 +354,6 @@ class WorkflowAgentPublishService:
|
||||
node_id=node_id,
|
||||
source_agent_id=agent_id,
|
||||
source_snapshot_id=current_snapshot_id,
|
||||
node_job=node_job_config,
|
||||
account_id=account_id,
|
||||
)
|
||||
resolved_binding_type = WorkflowAgentBindingType.INLINE_AGENT
|
||||
@ -422,7 +416,6 @@ class WorkflowAgentPublishService:
|
||||
node_id: str,
|
||||
source_agent_id: str,
|
||||
source_snapshot_id: str,
|
||||
node_job: WorkflowNodeJobConfig,
|
||||
account_id: str,
|
||||
) -> tuple[Agent, str]:
|
||||
source_agent = session.scalar(
|
||||
@ -456,7 +449,6 @@ class WorkflowAgentPublishService:
|
||||
node_id=node_id,
|
||||
source_agent=source_agent,
|
||||
source_snapshot=source_snapshot,
|
||||
node_job=node_job,
|
||||
account_id=account_id,
|
||||
)
|
||||
return agent, snapshot.id
|
||||
@ -709,7 +701,6 @@ class WorkflowAgentPublishService:
|
||||
node_id=source.node_id,
|
||||
source_agent_id=agent_id,
|
||||
source_snapshot_id=snapshot_id,
|
||||
node_job=WorkflowNodeJobConfig.model_validate(source.node_job_config_dict),
|
||||
account_id=account_id,
|
||||
)
|
||||
agent_id = agent.id
|
||||
|
||||
@ -50,7 +50,6 @@ from models.model import UploadFile
|
||||
from models.tools import ToolFile
|
||||
from services.agent.config_skill_normalize_service import ConfigSkillNormalizeService
|
||||
from services.agent.skill_package_service import SkillPackageError
|
||||
from services.agent_drive_service import DriveFileRef
|
||||
|
||||
|
||||
class AgentConfigVersionKind(StrEnum):
|
||||
@ -64,6 +63,13 @@ class AgentConfigMutationSurface(StrEnum):
|
||||
CONSOLE = "console"
|
||||
|
||||
|
||||
class ConfigFileRef(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
kind: Literal["upload_file", "tool_file"]
|
||||
id: str
|
||||
|
||||
|
||||
class AgentConfigServiceError(Exception):
|
||||
"""Config operation failure mapped to HTTP status at controller boundaries."""
|
||||
|
||||
@ -82,14 +88,14 @@ class ConfigPushFileItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str
|
||||
file_ref: DriveFileRef | None = None
|
||||
file_ref: ConfigFileRef | None = None
|
||||
|
||||
|
||||
class ConfigPushSkillItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str
|
||||
file_ref: DriveFileRef | None = None
|
||||
file_ref: ConfigFileRef | None = None
|
||||
|
||||
|
||||
class ConfigPushPayload(BaseModel):
|
||||
@ -991,7 +997,7 @@ class AgentConfigService:
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
file_ref: DriveFileRef,
|
||||
file_ref: ConfigFileRef,
|
||||
) -> tuple[int | None, str | None, str | None]:
|
||||
if file_ref.kind == "tool_file":
|
||||
tool_file = self._require_tool_file_source(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -28,6 +28,23 @@ class AppDefinitionSummary(NamedTuple):
|
||||
author_name: str | None
|
||||
|
||||
|
||||
class AppSiteConfiguration(NamedTuple):
|
||||
title: str
|
||||
chat_color_theme: str | None
|
||||
chat_color_theme_inverted: bool
|
||||
icon_type: str | None
|
||||
icon: str | None
|
||||
icon_background: str | None
|
||||
description: str | None
|
||||
copyright: str | None
|
||||
privacy_policy: str | None
|
||||
input_placeholder: str | None
|
||||
custom_disclaimer: str | None
|
||||
default_language: str
|
||||
show_workflow_steps: bool
|
||||
use_icon_as_answer_icon: bool
|
||||
|
||||
|
||||
class AppDefinitionQuery(Protocol):
|
||||
def get_published_parameter_config(
|
||||
self,
|
||||
@ -40,6 +57,8 @@ class AppDefinitionQuery(Protocol):
|
||||
|
||||
def get_summary(self, app_id: str) -> AppDefinitionSummary | None: ...
|
||||
|
||||
def get_site_configuration(self, app_id: str) -> AppSiteConfiguration | None: ...
|
||||
|
||||
|
||||
class AppDefinitionUnavailableError(ValueError):
|
||||
"""Raised when an app definition is unavailable."""
|
||||
@ -111,3 +130,9 @@ class AppDefinitionQueryService:
|
||||
if summary is None:
|
||||
raise AppDefinitionUnavailableError("App not found")
|
||||
return summary
|
||||
|
||||
def get_site_configuration(self, app_id: str) -> AppSiteConfiguration:
|
||||
configuration = self._definitions.get_site_configuration(app_id)
|
||||
if configuration is None:
|
||||
raise AppDefinitionUnavailableError("Site not found")
|
||||
return configuration
|
||||
|
||||
268
api/services/email_code_login_challenge.py
Normal file
268
api/services/email_code_login_challenge.py
Normal file
@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum, StrEnum
|
||||
from hashlib import sha256
|
||||
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from configs import dify_config
|
||||
from extensions.ext_redis import redis_client
|
||||
from extensions.redis_names import serialize_redis_name
|
||||
|
||||
_TOKEN_TYPE = "email_code_login"
|
||||
_CHALLENGE_VERSION = 2
|
||||
|
||||
|
||||
# The per-email v2 challenge is the sole state for tokens created by this
|
||||
# implementation. Lua result codes must stay in sync with ``_LuaResult``.
|
||||
_VERIFY_CHALLENGE_LUA = """
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then
|
||||
return {0, -1}
|
||||
end
|
||||
|
||||
local decoded, data = pcall(cjson.decode, raw)
|
||||
if not decoded or type(data) ~= 'table' then
|
||||
return {5, -1}
|
||||
end
|
||||
|
||||
if data.token_type ~= ARGV[1] or tonumber(data.challenge_version) ~= tonumber(ARGV[5]) then
|
||||
return {5, -1}
|
||||
end
|
||||
|
||||
if data.state == 'consumed' or data.state == 'exhausted' then
|
||||
return {8, -1}
|
||||
end
|
||||
|
||||
if type(data.token) ~= 'string' or data.token ~= ARGV[2] then
|
||||
return {1, -1}
|
||||
end
|
||||
|
||||
if type(data.email) ~= 'string' or data.email ~= ARGV[3] then
|
||||
return {2, -1}
|
||||
end
|
||||
|
||||
if type(data.code) ~= 'string' then
|
||||
return {5, -1}
|
||||
end
|
||||
|
||||
local remaining = tonumber(data.remaining_attempts)
|
||||
if not remaining or remaining <= 0 then
|
||||
local tombstone = {
|
||||
token_type = data.token_type,
|
||||
challenge_version = data.challenge_version,
|
||||
state = 'exhausted',
|
||||
remaining_attempts = 0
|
||||
}
|
||||
redis.call('SET', KEYS[1], cjson.encode(tombstone), 'KEEPTTL')
|
||||
return {6, 0}
|
||||
end
|
||||
|
||||
if data.code == ARGV[4] then
|
||||
local tombstone = {
|
||||
token_type = data.token_type,
|
||||
challenge_version = data.challenge_version,
|
||||
state = 'consumed',
|
||||
remaining_attempts = 0
|
||||
}
|
||||
redis.call('SET', KEYS[1], cjson.encode(tombstone), 'KEEPTTL')
|
||||
return {4, -1}
|
||||
end
|
||||
|
||||
remaining = remaining - 1
|
||||
if remaining <= 0 then
|
||||
local tombstone = {
|
||||
token_type = data.token_type,
|
||||
challenge_version = data.challenge_version,
|
||||
state = 'exhausted',
|
||||
remaining_attempts = 0
|
||||
}
|
||||
redis.call('SET', KEYS[1], cjson.encode(tombstone), 'KEEPTTL')
|
||||
return {6, 0}
|
||||
end
|
||||
|
||||
data.remaining_attempts = remaining
|
||||
redis.call('SET', KEYS[1], cjson.encode(data), 'KEEPTTL')
|
||||
return {3, remaining}
|
||||
"""
|
||||
|
||||
|
||||
# Tokens created before this deployment only have the legacy per-token key.
|
||||
# This fallback gives those in-flight tokens the same atomic attempt budget.
|
||||
# A versioned token is never accepted here, so a consumed v2 challenge cannot
|
||||
# fall back even if a stale legacy key is present unexpectedly.
|
||||
_VERIFY_LEGACY_TOKEN_LUA = """
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then
|
||||
return {0, -1}
|
||||
end
|
||||
|
||||
local decoded, data = pcall(cjson.decode, raw)
|
||||
if not decoded or type(data) ~= 'table' then
|
||||
return {5, -1}
|
||||
end
|
||||
|
||||
if data.token_type ~= ARGV[1] or type(data.email) ~= 'string' or type(data.code) ~= 'string' then
|
||||
return {5, -1}
|
||||
end
|
||||
|
||||
if string.lower(data.email) ~= ARGV[2] then
|
||||
return {2, -1}
|
||||
end
|
||||
|
||||
if data.challenge_version ~= nil then
|
||||
return {7, -1}
|
||||
end
|
||||
|
||||
local remaining = tonumber(data.remaining_attempts)
|
||||
if not remaining then
|
||||
remaining = tonumber(ARGV[4])
|
||||
end
|
||||
if not remaining or remaining <= 0 then
|
||||
redis.call('DEL', KEYS[1])
|
||||
return {6, 0}
|
||||
end
|
||||
|
||||
if data.code == ARGV[3] then
|
||||
redis.call('DEL', KEYS[1])
|
||||
return {4, -1}
|
||||
end
|
||||
|
||||
remaining = remaining - 1
|
||||
if remaining <= 0 then
|
||||
redis.call('DEL', KEYS[1])
|
||||
return {6, 0}
|
||||
end
|
||||
|
||||
data.remaining_attempts = remaining
|
||||
redis.call('SET', KEYS[1], cjson.encode(data), 'KEEPTTL')
|
||||
return {3, remaining}
|
||||
"""
|
||||
|
||||
|
||||
class EmailCodeLoginChallengeStatus(StrEnum):
|
||||
VERIFIED = "verified"
|
||||
INVALID_TOKEN = "invalid_token"
|
||||
EMAIL_MISMATCH = "email_mismatch"
|
||||
INVALID_CODE = "invalid_code"
|
||||
EXHAUSTED = "exhausted"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailCodeLoginChallengeResult:
|
||||
status: EmailCodeLoginChallengeStatus
|
||||
remaining_attempts: int | None = None
|
||||
|
||||
|
||||
class EmailCodeLoginChallengeUnavailableError(RuntimeError):
|
||||
"""The Redis-backed email-code challenge could not be safely evaluated."""
|
||||
|
||||
|
||||
class _LuaResult(IntEnum):
|
||||
MISSING = 0
|
||||
TOKEN_MISMATCH = 1
|
||||
EMAIL_MISMATCH = 2
|
||||
INVALID_CODE = 3
|
||||
VERIFIED = 4
|
||||
CORRUPT = 5
|
||||
EXHAUSTED = 6
|
||||
VERSIONED_LEGACY_TOKEN = 7
|
||||
TERMINAL_CHALLENGE = 8
|
||||
|
||||
|
||||
class EmailCodeLoginChallengeStore:
|
||||
@classmethod
|
||||
def create(cls, *, email: str, code: str, account_id: str | None) -> str:
|
||||
normalized_email = email.lower()
|
||||
token = str(uuid.uuid4())
|
||||
payload = {
|
||||
"account_id": account_id,
|
||||
"email": normalized_email,
|
||||
"token_type": _TOKEN_TYPE,
|
||||
"code": code,
|
||||
"remaining_attempts": dify_config.EMAIL_CODE_LOGIN_MAX_ATTEMPTS,
|
||||
"challenge_version": _CHALLENGE_VERSION,
|
||||
"state": "active",
|
||||
"token": token,
|
||||
}
|
||||
expiry_seconds = int(dify_config.EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES * 60)
|
||||
|
||||
try:
|
||||
# Overwriting this one key makes a resend invalidate the previous
|
||||
# token for the normalized email without creating extra budgets.
|
||||
redis_client.setex(
|
||||
cls._challenge_key(normalized_email),
|
||||
expiry_seconds,
|
||||
json.dumps(payload, separators=(",", ":")),
|
||||
)
|
||||
except RedisError as exc:
|
||||
raise EmailCodeLoginChallengeUnavailableError("Could not create email-code challenge") from exc
|
||||
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
def verify(cls, *, email: str, code: str, token: str) -> EmailCodeLoginChallengeResult:
|
||||
normalized_email = email.lower()
|
||||
max_attempts = dify_config.EMAIL_CODE_LOGIN_MAX_ATTEMPTS
|
||||
|
||||
try:
|
||||
challenge_result = cls._eval(
|
||||
_VERIFY_CHALLENGE_LUA,
|
||||
cls._challenge_key(normalized_email),
|
||||
_TOKEN_TYPE,
|
||||
token,
|
||||
normalized_email,
|
||||
code,
|
||||
_CHALLENGE_VERSION,
|
||||
)
|
||||
if challenge_result[0] is not _LuaResult.MISSING:
|
||||
return cls._to_public_result(challenge_result)
|
||||
|
||||
# Only a token created before this deployment can reach the
|
||||
# legacy fallback because new tokens are never written there.
|
||||
legacy_result = cls._eval(
|
||||
_VERIFY_LEGACY_TOKEN_LUA,
|
||||
cls._legacy_token_key(token),
|
||||
_TOKEN_TYPE,
|
||||
normalized_email,
|
||||
code,
|
||||
max_attempts,
|
||||
)
|
||||
return cls._to_public_result(legacy_result)
|
||||
except (RedisError, TypeError, ValueError) as exc:
|
||||
raise EmailCodeLoginChallengeUnavailableError("Could not verify email-code challenge") from exc
|
||||
|
||||
@staticmethod
|
||||
def _eval(script: str, key: str, *args: str | int) -> tuple[_LuaResult, int | None]:
|
||||
# ``eval`` is delegated to the raw Redis client, so unlike the wrapper's
|
||||
# normal commands it needs an explicitly serialized physical key.
|
||||
response = redis_client.eval(script, 1, serialize_redis_name(key), *args)
|
||||
if not isinstance(response, (list, tuple)) or len(response) != 2:
|
||||
raise ValueError("Unexpected Redis Lua response")
|
||||
|
||||
lua_result = _LuaResult(int(response[0]))
|
||||
remaining = int(response[1])
|
||||
return lua_result, remaining if remaining >= 0 else None
|
||||
|
||||
@staticmethod
|
||||
def _to_public_result(result: tuple[_LuaResult, int | None]) -> EmailCodeLoginChallengeResult:
|
||||
lua_result, remaining = result
|
||||
status = {
|
||||
_LuaResult.VERIFIED: EmailCodeLoginChallengeStatus.VERIFIED,
|
||||
_LuaResult.EMAIL_MISMATCH: EmailCodeLoginChallengeStatus.EMAIL_MISMATCH,
|
||||
_LuaResult.INVALID_CODE: EmailCodeLoginChallengeStatus.INVALID_CODE,
|
||||
_LuaResult.EXHAUSTED: EmailCodeLoginChallengeStatus.EXHAUSTED,
|
||||
}.get(lua_result, EmailCodeLoginChallengeStatus.INVALID_TOKEN)
|
||||
return EmailCodeLoginChallengeResult(status=status, remaining_attempts=remaining)
|
||||
|
||||
@staticmethod
|
||||
def _challenge_key(normalized_email: str) -> str:
|
||||
email_digest = sha256(normalized_email.encode("utf-8")).hexdigest()
|
||||
return f"email_code_login:challenge:{{{email_digest}}}"
|
||||
|
||||
@staticmethod
|
||||
def _legacy_token_key(token: str) -> str:
|
||||
return f"{_TOKEN_TYPE}:token:{token}"
|
||||
@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@ -9,7 +8,7 @@ from cachetools.func import ttl_cache
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from configs import dify_config
|
||||
from enums import DeploymentEdition
|
||||
from enums import DeploymentEdition, WebAppAccessMode
|
||||
from extensions.ext_redis import redis_client
|
||||
from services.enterprise.base import (
|
||||
EnterpriseRequest,
|
||||
@ -31,13 +30,6 @@ VALID_LICENSE_CACHE_TTL = 600 # 10 minutes — valid licenses are stable
|
||||
INVALID_LICENSE_CACHE_TTL = 30 # 30 seconds — short so admin fixes are picked up quickly
|
||||
|
||||
|
||||
class WebAppAccessMode(enum.StrEnum):
|
||||
PUBLIC = "public"
|
||||
PRIVATE = "private"
|
||||
PRIVATE_ALL = "private_all"
|
||||
SSO_VERIFIED = "sso_verified"
|
||||
|
||||
|
||||
PERMISSION_CHECK_MODES: frozenset[WebAppAccessMode] = frozenset(
|
||||
{WebAppAccessMode.PRIVATE, WebAppAccessMode.PRIVATE_ALL}
|
||||
)
|
||||
@ -293,7 +285,7 @@ class EnterpriseService:
|
||||
params = {"appId": app_id}
|
||||
data = EnterpriseRequest.send_request("GET", "/webapp/access-mode/id", params=params)
|
||||
if not data:
|
||||
raise ValueError("No data found.")
|
||||
raise EnterpriseServiceError("No data found.")
|
||||
return WebAppSettings.model_validate(data)
|
||||
|
||||
@classmethod
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
ExploreBanner is the legacy contract name shared by the API, feature flag, and database model.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any, NamedTuple, Protocol
|
||||
|
||||
@ -28,13 +28,13 @@ class ExploreBannerQueryService:
|
||||
self,
|
||||
*,
|
||||
banners: ExploreBannerQuery,
|
||||
is_enabled: Callable[[], bool],
|
||||
enabled: bool,
|
||||
) -> None:
|
||||
self._banners = banners
|
||||
self._is_enabled = is_enabled
|
||||
self._enabled = enabled
|
||||
|
||||
def list_for_language(self, language: str) -> tuple[ExploreBannerRecord, ...]:
|
||||
if not self._is_enabled():
|
||||
if not self._enabled:
|
||||
return ()
|
||||
|
||||
banners = tuple(self._banners.list_enabled(language))
|
||||
|
||||
@ -104,10 +104,10 @@ class FeatureService:
|
||||
system_features.rbac_enabled = dify_config.RBAC_ENABLED
|
||||
|
||||
cls._fulfill_system_params_from_env(system_features)
|
||||
system_features.webapp_auth.enabled = cls.is_webapp_auth_enabled()
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
|
||||
system_features.branding.enabled = True
|
||||
system_features.webapp_auth.enabled = True
|
||||
system_features.enable_change_email = False
|
||||
cls._fulfill_params_from_enterprise(system_features)
|
||||
|
||||
@ -159,6 +159,10 @@ class FeatureService:
|
||||
def is_explore_banner_enabled() -> bool:
|
||||
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_EXPLORE_BANNER
|
||||
|
||||
@staticmethod
|
||||
def is_webapp_auth_enabled() -> bool:
|
||||
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE
|
||||
|
||||
@classmethod
|
||||
def _fulfill_system_params_from_env(cls, system_features: feature_entities.SystemFeatureModel):
|
||||
system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN
|
||||
|
||||
@ -6,6 +6,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
|
||||
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.llm_generator.llm_generator import LLMGenerator
|
||||
from core.memory.token_buffer_memory import TokenBufferMemory
|
||||
@ -17,15 +18,18 @@ from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||
from models import Account
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import FeedbackFromSource, FeedbackRating
|
||||
from models.model import (
|
||||
App,
|
||||
AppMode,
|
||||
AppModelConfig,
|
||||
Conversation,
|
||||
EndUser,
|
||||
Message,
|
||||
MessageFeedback,
|
||||
SuggestedQuestionsAfterAnswerConfig,
|
||||
load_annotation_reply_config,
|
||||
)
|
||||
from repositories.execution_extra_content_repository import ExecutionExtraContentRepository
|
||||
from repositories.sqlalchemy_execution_extra_content_repository import (
|
||||
@ -61,6 +65,38 @@ def attach_message_extra_contents(messages: Sequence[Message]) -> None:
|
||||
|
||||
|
||||
class MessageService:
|
||||
@classmethod
|
||||
def _get_agent_suggested_questions_config(
|
||||
cls,
|
||||
*,
|
||||
app_model: App,
|
||||
user: Account | EndUser,
|
||||
conversation: Conversation,
|
||||
invoke_from: InvokeFrom,
|
||||
session: Session,
|
||||
) -> SuggestedQuestionsAfterAnswerConfig:
|
||||
from services.agent.runtime_config_service import AgentRuntimeConfigService
|
||||
|
||||
agent_soul = AgentRuntimeConfigService(session).resolve_conversation_soul(
|
||||
app_model=app_model,
|
||||
conversation=conversation,
|
||||
account_id=user.id if isinstance(user, Account) else None,
|
||||
use_debug_draft=invoke_from == InvokeFrom.DEBUGGER,
|
||||
)
|
||||
app_model_config = (
|
||||
session.get(AppModelConfig, app_model.app_model_config_id) if app_model.app_model_config_id else None
|
||||
)
|
||||
annotation_reply = load_annotation_reply_config(session, app_model.id) if app_model_config else None
|
||||
features = merge_agent_app_features(
|
||||
agent_soul=agent_soul or AgentSoulConfig(),
|
||||
app_model_config=app_model_config,
|
||||
annotation_reply=annotation_reply,
|
||||
)
|
||||
suggested_questions = features.get("suggested_questions_after_answer")
|
||||
if not isinstance(suggested_questions, dict) or not suggested_questions.get("enabled", False):
|
||||
raise SuggestedQuestionsAfterAnswerDisabledError()
|
||||
return cast(SuggestedQuestionsAfterAnswerConfig, suggested_questions)
|
||||
|
||||
@classmethod
|
||||
def pagination_by_first_id(
|
||||
cls,
|
||||
@ -301,6 +337,14 @@ class MessageService:
|
||||
suggested_questions_after_answer_config = cast(
|
||||
SuggestedQuestionsAfterAnswerConfig, suggested_questions_after_answer
|
||||
)
|
||||
elif app_model.mode == AppMode.AGENT:
|
||||
suggested_questions_after_answer_config = cls._get_agent_suggested_questions_config(
|
||||
app_model=app_model,
|
||||
user=user,
|
||||
conversation=conversation,
|
||||
invoke_from=invoke_from,
|
||||
session=session,
|
||||
)
|
||||
else:
|
||||
if not conversation.override_model_configs:
|
||||
app_model_config = session.scalar(
|
||||
|
||||
@ -7,7 +7,8 @@ from configs import dify_config
|
||||
from core.helper.http_client_pooling import get_pooled_http_client
|
||||
|
||||
_SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||
_EXPECTED_ACTION = "signin_code"
|
||||
EMAIL_CODE_SEND_ACTION = "signin_code"
|
||||
EMAIL_CODE_VERIFY_ACTION = "signin_code_verify"
|
||||
_MAX_TOKEN_LENGTH = 2048
|
||||
_CLIENT_ERROR_CODES = frozenset(
|
||||
{
|
||||
@ -44,7 +45,13 @@ class _TurnstileResponse(BaseModel):
|
||||
|
||||
class TurnstileService:
|
||||
@classmethod
|
||||
def verify(cls, *, token: str | None, remote_ip: str | None) -> None:
|
||||
def verify(
|
||||
cls,
|
||||
*,
|
||||
token: str | None,
|
||||
remote_ip: str | None,
|
||||
expected_action: str = EMAIL_CODE_SEND_ACTION,
|
||||
) -> None:
|
||||
normalized_token = token.strip() if token else ""
|
||||
if not normalized_token or len(normalized_token) > _MAX_TOKEN_LENGTH:
|
||||
raise TurnstileChallengeRejectedError
|
||||
@ -74,7 +81,7 @@ class TurnstileService:
|
||||
raise TurnstileChallengeRejectedError
|
||||
raise TurnstileUpstreamError("Turnstile returned a server-side verification error")
|
||||
|
||||
if result.action != _EXPECTED_ACTION or not cls._is_allowed_hostname(result.hostname, allowed_hostnames):
|
||||
if result.action != expected_action or not cls._is_allowed_hostname(result.hostname, allowed_hostnames):
|
||||
raise TurnstileChallengeRejectedError
|
||||
|
||||
@staticmethod
|
||||
|
||||
49
api/services/webapp_access_query_service.py
Normal file
49
api/services/webapp_access_query_service.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""Application service for resolving web-app access."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol
|
||||
|
||||
from enums import WebAppAccessMode
|
||||
|
||||
|
||||
class WebAppAccessQuery(Protocol):
|
||||
def find_app_id_by_code(self, app_code: str) -> str | None: ...
|
||||
|
||||
|
||||
class WebAppAccessReferenceRequiredError(ValueError):
|
||||
"""Raised when neither an app ID nor an app code was provided."""
|
||||
|
||||
|
||||
class WebAppAccessAppNotFoundError(LookupError):
|
||||
"""Raised when an app code does not resolve to an app."""
|
||||
|
||||
|
||||
class WebAppAccessUnavailableError(RuntimeError):
|
||||
"""Raised when an access dependency cannot answer the query."""
|
||||
|
||||
|
||||
class WebAppAccessQueryService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
access: WebAppAccessQuery,
|
||||
webapp_auth_enabled: bool,
|
||||
access_mode_for_app: Callable[[str], WebAppAccessMode],
|
||||
) -> None:
|
||||
self._access = access
|
||||
self._webapp_auth_enabled = webapp_auth_enabled
|
||||
self._access_mode_for_app = access_mode_for_app
|
||||
|
||||
def get_access_mode(self, *, app_id: str | None, app_code: str | None) -> WebAppAccessMode:
|
||||
if not self._webapp_auth_enabled:
|
||||
return WebAppAccessMode.PUBLIC
|
||||
|
||||
if app_code:
|
||||
app_id = self._access.find_app_id_by_code(app_code)
|
||||
if app_id is None:
|
||||
raise WebAppAccessAppNotFoundError
|
||||
|
||||
if not app_id:
|
||||
raise WebAppAccessReferenceRequiredError("appId or appCode must be provided")
|
||||
|
||||
return self._access_mode_for_app(app_id)
|
||||
@ -24,7 +24,6 @@ from models import (
|
||||
PinnedConversation,
|
||||
SavedMessage,
|
||||
)
|
||||
from models.agent import AgentDriveFile, AgentDriveFileKind
|
||||
from models.human_input import HumanInputDelivery, HumanInputFormRecipient
|
||||
from models.tools import ToolConversationVariables, ToolFile
|
||||
|
||||
@ -49,9 +48,7 @@ def _cleanup_conversation_related_data(conversation_id: str) -> bool:
|
||||
"""Physically remove a soft-deleted conversation and its owned resources.
|
||||
|
||||
The storage object is deleted before its ``ToolFile`` row so a failed attempt
|
||||
retains the durable ``file_key`` needed by the next retry. ToolFiles promoted
|
||||
to Agent Drive are detached from the conversation, and their Drive references
|
||||
take over lifecycle ownership.
|
||||
retains the durable ``file_key`` needed by the next retry.
|
||||
"""
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
@ -68,25 +65,7 @@ def _cleanup_conversation_related_data(conversation_id: str) -> bool:
|
||||
.with_for_update()
|
||||
)
|
||||
)
|
||||
tool_file_ids = [tool_file.id for tool_file in tool_files]
|
||||
drive_files = list(
|
||||
session.scalars(
|
||||
select(AgentDriveFile)
|
||||
.where(
|
||||
AgentDriveFile.file_kind == AgentDriveFileKind.TOOL_FILE,
|
||||
AgentDriveFile.file_id.in_(tool_file_ids),
|
||||
)
|
||||
.order_by(AgentDriveFile.id)
|
||||
.with_for_update()
|
||||
)
|
||||
)
|
||||
drive_tool_file_ids = {drive_file.file_id for drive_file in drive_files}
|
||||
for drive_file in drive_files:
|
||||
drive_file.value_owned_by_drive = True
|
||||
for tool_file in tool_files:
|
||||
if tool_file.id in drive_tool_file_ids:
|
||||
tool_file.conversation_id = None
|
||||
continue
|
||||
_delete_storage_object(tool_file.file_key)
|
||||
session.delete(tool_file)
|
||||
|
||||
|
||||
@ -1,106 +0,0 @@
|
||||
"""
|
||||
Testcontainers integration tests for Service API Site controller.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.service_api.app.site import AppSiteApi
|
||||
from models.account import Tenant, TenantStatus
|
||||
from models.model import App, AppMode, Site
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(flask_app_with_containers) -> Flask:
|
||||
return flask_app_with_containers
|
||||
|
||||
|
||||
from inspect import unwrap
|
||||
|
||||
|
||||
def _create_tenant(db_session: Session, *, status: TenantStatus = TenantStatus.NORMAL) -> Tenant:
|
||||
tenant = Tenant(name="service-api-site-tenant", status=status)
|
||||
db_session.add(tenant)
|
||||
db_session.commit()
|
||||
return tenant
|
||||
|
||||
|
||||
def _create_app(db_session: Session, tenant_id: str) -> App:
|
||||
app_model = App(
|
||||
tenant_id=tenant_id,
|
||||
mode=AppMode.CHAT,
|
||||
name="service-api-site-app",
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
status="normal",
|
||||
)
|
||||
db_session.add(app_model)
|
||||
db_session.commit()
|
||||
return app_model
|
||||
|
||||
|
||||
def _create_site(db_session: Session, app_id: str) -> Site:
|
||||
site = Site(
|
||||
app_id=app_id,
|
||||
title="Service API Site",
|
||||
icon_type="emoji",
|
||||
icon="robot",
|
||||
icon_background="#ffffff",
|
||||
description="Service API test site",
|
||||
default_language="en-US",
|
||||
prompt_public=True,
|
||||
show_workflow_steps=True,
|
||||
customize_token_strategy="not_allow",
|
||||
use_icon_as_answer_icon=False,
|
||||
chat_color_theme="light",
|
||||
chat_color_theme_inverted=False,
|
||||
)
|
||||
db_session.add(site)
|
||||
db_session.commit()
|
||||
return site
|
||||
|
||||
|
||||
class TestAppSiteApi:
|
||||
def test_get_site_success(self, app: Flask, db_session_with_containers: Session) -> None:
|
||||
tenant = _create_tenant(db_session_with_containers)
|
||||
app_model = _create_app(db_session_with_containers, tenant.id)
|
||||
_create_site(db_session_with_containers, app_model.id)
|
||||
|
||||
with app.test_request_context("/site", method="GET", headers={"Authorization": "Bearer test-token"}):
|
||||
api = AppSiteApi()
|
||||
response = unwrap(api.get)(api, app_model=app_model)
|
||||
|
||||
assert response["title"] == "Service API Site"
|
||||
assert response["icon"] == "robot"
|
||||
assert response["description"] == "Service API test site"
|
||||
|
||||
def test_get_site_not_found(self, app: Flask, db_session_with_containers: Session) -> None:
|
||||
tenant = _create_tenant(db_session_with_containers)
|
||||
app_model = _create_app(db_session_with_containers, tenant.id)
|
||||
|
||||
with app.test_request_context("/site", method="GET", headers={"Authorization": "Bearer test-token"}):
|
||||
api = AppSiteApi()
|
||||
with pytest.raises(Forbidden):
|
||||
unwrap(api.get)(api, app_model=app_model)
|
||||
|
||||
def test_get_site_tenant_archived(self, app: Flask, db_session_with_containers: Session) -> None:
|
||||
tenant = _create_tenant(db_session_with_containers)
|
||||
app_model = _create_app(db_session_with_containers, tenant.id)
|
||||
_create_site(db_session_with_containers, app_model.id)
|
||||
|
||||
archived_tenant = db_session_with_containers.get(Tenant, tenant.id)
|
||||
assert archived_tenant is not None
|
||||
archived_tenant.status = TenantStatus.ARCHIVE
|
||||
db_session_with_containers.commit()
|
||||
|
||||
app_model = db_session_with_containers.get(App, app_model.id)
|
||||
assert app_model is not None
|
||||
|
||||
with app.test_request_context("/site", method="GET", headers={"Authorization": "Bearer test-token"}):
|
||||
api = AppSiteApi()
|
||||
with pytest.raises(Forbidden):
|
||||
unwrap(api.get)(api, app_model=app_model)
|
||||
@ -16,7 +16,6 @@ project-excludes = [
|
||||
"controllers/console/test_apikey.py",
|
||||
"controllers/console/workspace/test_workspace_wraps.py",
|
||||
"controllers/service_api/dataset/test_dataset.py",
|
||||
"controllers/service_api/test_site.py",
|
||||
"controllers/web/test_conversation.py",
|
||||
"controllers/web/test_site.py",
|
||||
"controllers/web/test_wraps.py",
|
||||
|
||||
@ -1,178 +0,0 @@
|
||||
from threading import Event, Thread
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import event, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import AppMode, Conversation, ToolFile
|
||||
from models.agent import AgentDriveFile, AgentDriveFileKind
|
||||
from models.enums import ConversationFromSource, ConversationStatus
|
||||
from tasks.delete_conversation_task import _cleanup_conversation_related_data
|
||||
|
||||
TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
APP_ID = "22222222-2222-2222-2222-222222222222"
|
||||
ACCOUNT_ID = "33333333-3333-3333-3333-333333333333"
|
||||
CONVERSATION_ID = "44444444-4444-4444-4444-444444444444"
|
||||
AGENT_ID = "55555555-5555-5555-5555-555555555555"
|
||||
|
||||
|
||||
def test_cleanup_deletes_owned_storage_and_preserves_drive_file(
|
||||
db_session_with_containers: Session,
|
||||
) -> None:
|
||||
conversation = Conversation(
|
||||
id=CONVERSATION_ID,
|
||||
app_id=APP_ID,
|
||||
mode=AppMode.CHAT,
|
||||
name="Deleted conversation",
|
||||
inputs={},
|
||||
status=ConversationStatus.NORMAL,
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=ACCOUNT_ID,
|
||||
is_deleted=True,
|
||||
)
|
||||
owned_file = ToolFile(
|
||||
user_id=ACCOUNT_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
conversation_id=CONVERSATION_ID,
|
||||
file_key=f"tools/{TENANT_ID}/owned.txt",
|
||||
mimetype="text/plain",
|
||||
name="owned.txt",
|
||||
size=5,
|
||||
)
|
||||
drive_file = ToolFile(
|
||||
user_id=ACCOUNT_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
conversation_id=CONVERSATION_ID,
|
||||
file_key=f"tools/{TENANT_ID}/drive.txt",
|
||||
mimetype="text/plain",
|
||||
name="drive.txt",
|
||||
size=5,
|
||||
)
|
||||
db_session_with_containers.add_all([conversation, owned_file, drive_file])
|
||||
db_session_with_containers.flush()
|
||||
drive_entry = AgentDriveFile(
|
||||
tenant_id=TENANT_ID,
|
||||
agent_id=AGENT_ID,
|
||||
key="drive.txt",
|
||||
file_kind=AgentDriveFileKind.TOOL_FILE,
|
||||
file_id=drive_file.id,
|
||||
value_owned_by_drive=False,
|
||||
is_skill=False,
|
||||
)
|
||||
db_session_with_containers.add(drive_entry)
|
||||
db_session_with_containers.commit()
|
||||
owned_file_id = owned_file.id
|
||||
drive_file_id = drive_file.id
|
||||
|
||||
with patch("tasks.delete_conversation_task.storage") as storage_mock:
|
||||
assert _cleanup_conversation_related_data(CONVERSATION_ID) is True
|
||||
|
||||
storage_mock.delete.assert_called_once_with(f"tools/{TENANT_ID}/owned.txt")
|
||||
db_session_with_containers.expire_all()
|
||||
assert db_session_with_containers.get(Conversation, CONVERSATION_ID) is None
|
||||
assert db_session_with_containers.get(ToolFile, owned_file_id) is None
|
||||
preserved = db_session_with_containers.get(ToolFile, drive_file_id)
|
||||
assert preserved is not None
|
||||
assert preserved.conversation_id is None
|
||||
preserved_drive_entry = db_session_with_containers.scalar(
|
||||
select(AgentDriveFile).where(AgentDriveFile.file_id == drive_file_id)
|
||||
)
|
||||
assert preserved_drive_entry is not None
|
||||
assert preserved_drive_entry.value_owned_by_drive is True
|
||||
|
||||
|
||||
def test_cleanup_preserves_drive_file_committed_while_waiting_for_tool_file_lock(
|
||||
db_session_with_containers: Session,
|
||||
) -> None:
|
||||
conversation = Conversation(
|
||||
id=CONVERSATION_ID,
|
||||
app_id=APP_ID,
|
||||
mode=AppMode.CHAT,
|
||||
name="Deleted conversation",
|
||||
inputs={},
|
||||
status=ConversationStatus.NORMAL,
|
||||
from_source=ConversationFromSource.CONSOLE,
|
||||
from_account_id=ACCOUNT_ID,
|
||||
is_deleted=True,
|
||||
)
|
||||
drive_file = ToolFile(
|
||||
user_id=ACCOUNT_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
conversation_id=CONVERSATION_ID,
|
||||
file_key=f"tools/{TENANT_ID}/concurrent-drive.txt",
|
||||
mimetype="text/plain",
|
||||
name="concurrent-drive.txt",
|
||||
size=5,
|
||||
)
|
||||
db_session_with_containers.add_all([conversation, drive_file])
|
||||
db_session_with_containers.commit()
|
||||
drive_file_id = drive_file.id
|
||||
|
||||
engine = db_session_with_containers.get_bind()
|
||||
drive_session = Session(engine)
|
||||
locked_file = drive_session.scalar(select(ToolFile).where(ToolFile.id == drive_file_id).with_for_update())
|
||||
assert locked_file is not None
|
||||
drive_session.add(
|
||||
AgentDriveFile(
|
||||
tenant_id=TENANT_ID,
|
||||
agent_id=AGENT_ID,
|
||||
key="concurrent-drive.txt",
|
||||
file_kind=AgentDriveFileKind.TOOL_FILE,
|
||||
file_id=drive_file_id,
|
||||
value_owned_by_drive=False,
|
||||
is_skill=False,
|
||||
)
|
||||
)
|
||||
drive_session.flush()
|
||||
|
||||
cleanup_result: list[bool] = []
|
||||
cleanup_errors: list[BaseException] = []
|
||||
|
||||
def run_cleanup() -> None:
|
||||
try:
|
||||
cleanup_result.append(_cleanup_conversation_related_data(CONVERSATION_ID))
|
||||
except BaseException as error:
|
||||
cleanup_errors.append(error)
|
||||
|
||||
tool_file_lock_started = Event()
|
||||
|
||||
def signal_tool_file_lock(
|
||||
_connection,
|
||||
_cursor,
|
||||
statement: str,
|
||||
_parameters,
|
||||
_context,
|
||||
_executemany,
|
||||
) -> None:
|
||||
normalized_statement = statement.lower()
|
||||
if "from tool_files" in normalized_statement and "for update" in normalized_statement:
|
||||
tool_file_lock_started.set()
|
||||
|
||||
event.listen(engine, "before_cursor_execute", signal_tool_file_lock)
|
||||
cleanup_thread = Thread(target=run_cleanup)
|
||||
try:
|
||||
with patch("tasks.delete_conversation_task.storage") as storage_mock:
|
||||
cleanup_thread.start()
|
||||
assert tool_file_lock_started.wait(timeout=5)
|
||||
drive_session.commit()
|
||||
cleanup_thread.join(timeout=5)
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", signal_tool_file_lock)
|
||||
drive_session.rollback()
|
||||
drive_session.close()
|
||||
cleanup_thread.join(timeout=5)
|
||||
|
||||
assert not cleanup_thread.is_alive()
|
||||
assert cleanup_errors == []
|
||||
assert cleanup_result == [True]
|
||||
storage_mock.delete.assert_not_called()
|
||||
|
||||
db_session_with_containers.expire_all()
|
||||
preserved = db_session_with_containers.get(ToolFile, drive_file_id)
|
||||
assert preserved is not None
|
||||
assert preserved.conversation_id is None
|
||||
preserved_drive_entry = db_session_with_containers.scalar(
|
||||
select(AgentDriveFile).where(AgentDriveFile.file_id == drive_file_id)
|
||||
)
|
||||
assert preserved_drive_entry is not None
|
||||
assert preserved_drive_entry.value_owned_by_drive is True
|
||||
@ -15,9 +15,7 @@ extend-select = ["ANN401", "ARG"]
|
||||
"controllers/console/agent/test_agent_controllers.py" = ["ARG001", "ARG002", "ARG003", "ARG005", "TID251"]
|
||||
"controllers/console/app/test_agent_app_sandbox.py" = ["ARG002", "ARG005"]
|
||||
"controllers/console/app/test_agent_config_inspector.py" = ["ARG005"]
|
||||
"controllers/console/app/test_agent_drive_inspector.py" = ["ARG005"]
|
||||
"controllers/console/app/test_agent_manage_guard.py" = ["ARG001"]
|
||||
"controllers/console/app/test_agent_skills.py" = ["ARG005"]
|
||||
"controllers/console/app/test_annotation_security.py" = ["ARG002"]
|
||||
"controllers/console/app/test_app_apis.py" = ["ARG001", "ARG002"]
|
||||
"controllers/console/app/test_app_import_api.py" = ["ARG001", "ARG002", "ARG005"]
|
||||
|
||||
@ -16,7 +16,6 @@ from dify_agent.layers.dify_plugin import (
|
||||
DifyPluginToolConfig,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.drive import DifyDriveLayerConfig
|
||||
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig
|
||||
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID
|
||||
@ -44,7 +43,7 @@ from clients.agent_backend import (
|
||||
AgentBackendWorkflowNodeRunInput,
|
||||
redact_for_agent_backend_log,
|
||||
)
|
||||
from clients.agent_backend.request_builder import DIFY_DRIVE_LAYER_ID, DIFY_SHELL_LAYER_ID
|
||||
from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID
|
||||
|
||||
|
||||
def _run_input() -> AgentBackendWorkflowNodeRunInput:
|
||||
@ -363,25 +362,6 @@ def test_workflow_request_builder_adds_shell_layer_when_include_shell():
|
||||
assert shell_config.env[0].name == "PROJECT_NAME"
|
||||
|
||||
|
||||
def test_workflow_request_builder_binds_drive_to_shell_when_configured():
|
||||
run_input = _run_input()
|
||||
run_input.include_shell = True
|
||||
run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1")
|
||||
|
||||
request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input)
|
||||
layers = {layer.name: layer for layer in request.composition.layers}
|
||||
layer_names = [layer.name for layer in request.composition.layers]
|
||||
|
||||
assert layers[DIFY_SHELL_LAYER_ID].deps == {
|
||||
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
"runtime": "runtime",
|
||||
}
|
||||
shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config)
|
||||
assert shell_config.agent_stub_drive_ref == "agent-agent-1"
|
||||
assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID}
|
||||
assert layer_names.index(DIFY_SHELL_LAYER_ID) < layer_names.index(DIFY_DRIVE_LAYER_ID)
|
||||
|
||||
|
||||
def test_agent_app_request_builder_omits_shell_layer_by_default():
|
||||
request = AgentBackendRunRequestBuilder().build_for_agent_app(_agent_app_input())
|
||||
assert DIFY_SHELL_LAYER_ID not in {layer.name for layer in request.composition.layers}
|
||||
@ -417,24 +397,6 @@ def test_agent_app_request_builder_adds_shell_layer_when_include_shell():
|
||||
assert shell_config.env[0].name == "APP_ENV"
|
||||
|
||||
|
||||
def test_agent_app_request_builder_binds_drive_to_shell_when_configured():
|
||||
run_input = _agent_app_input(include_shell=True)
|
||||
run_input.drive_config = DifyDriveLayerConfig(drive_ref="agent-agent-1")
|
||||
|
||||
request = AgentBackendRunRequestBuilder().build_for_agent_app(run_input)
|
||||
layers = {layer.name: layer for layer in request.composition.layers}
|
||||
layer_names = [layer.name for layer in request.composition.layers]
|
||||
|
||||
assert layers[DIFY_SHELL_LAYER_ID].deps == {
|
||||
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
"runtime": "runtime",
|
||||
}
|
||||
shell_config = cast(DifyShellLayerConfig, layers[DIFY_SHELL_LAYER_ID].config)
|
||||
assert shell_config.agent_stub_drive_ref == "agent-agent-1"
|
||||
assert layers[DIFY_DRIVE_LAYER_ID].deps == {"shell": DIFY_SHELL_LAYER_ID}
|
||||
assert layer_names.index(DIFY_SHELL_LAYER_ID) < layer_names.index(DIFY_DRIVE_LAYER_ID)
|
||||
|
||||
|
||||
def test_agent_app_request_builder_adds_knowledge_layer_when_configured():
|
||||
run_input = _agent_app_input()
|
||||
run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate(
|
||||
|
||||
@ -121,11 +121,19 @@ def test_turnstile_config_is_parsed() -> None:
|
||||
config = _make_config(
|
||||
TURNSTILE_SECRET_KEY=" test-secret ",
|
||||
TURNSTILE_ALLOWED_HOSTNAMES="dify.dev, Login.Example.COM. ",
|
||||
TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED="true",
|
||||
)
|
||||
|
||||
assert isinstance(config.TURNSTILE_SECRET_KEY, SecretStr)
|
||||
assert config.TURNSTILE_SECRET_KEY.get_secret_value() == "test-secret"
|
||||
assert frozenset({"dify.dev", "login.example.com"}) == config.TURNSTILE_ALLOWED_HOSTNAME_SET
|
||||
assert config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED is True
|
||||
|
||||
|
||||
def test_email_code_login_attempt_budget_is_parsed() -> None:
|
||||
config = _make_config(EMAIL_CODE_LOGIN_MAX_ATTEMPTS="7")
|
||||
|
||||
assert config.EMAIL_CODE_LOGIN_MAX_ATTEMPTS == 7
|
||||
|
||||
|
||||
def test_plugin_remote_install_port_rejects_host_port_spec() -> None:
|
||||
|
||||
@ -15,7 +15,6 @@ from controllers.console.agent import roster as roster_controller
|
||||
from controllers.console.agent.composer import (
|
||||
AgentComposerApi,
|
||||
AgentComposerCandidatesApi,
|
||||
AgentComposerValidateApi,
|
||||
WorkflowAgentComposerApi,
|
||||
WorkflowAgentComposerCandidatesApi,
|
||||
WorkflowAgentComposerCopyFromRosterApi,
|
||||
@ -260,10 +259,7 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
|
||||
"/agent/<uuid:agent_id>/build-draft",
|
||||
"/agent/<uuid:agent_id>/build-draft/apply",
|
||||
"/agent/<uuid:agent_id>/referencing-workflows",
|
||||
"/agent/<uuid:agent_id>/drive/files",
|
||||
"/agent/<uuid:agent_id>/sandbox/files",
|
||||
"/agent/<uuid:agent_id>/skills/upload",
|
||||
"/agent/<uuid:agent_id>/files",
|
||||
"/agent/<uuid:agent_id>/api-access",
|
||||
"/agent/<uuid:agent_id>/api-enable",
|
||||
"/agent/<uuid:agent_id>/api-keys",
|
||||
@ -1328,10 +1324,6 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
|
||||
lambda **kwargs: _workflow_composer_response(save_options=[kwargs["payload"].save_strategy.value]),
|
||||
)
|
||||
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService, "resolve_workflow_node_agent_id", lambda **kwargs: None
|
||||
)
|
||||
monkeypatch.setattr(composer_controller.AgentComposerService, "resolve_bound_agent_id", lambda **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService,
|
||||
"get_workflow_candidates",
|
||||
@ -1514,10 +1506,6 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
|
||||
captured["save"] = kwargs
|
||||
return _agent_app_composer_response()
|
||||
|
||||
def collect_validation_findings(**kwargs: object) -> dict:
|
||||
captured["validate"] = kwargs
|
||||
return {"warnings": [], "knowledge_retrieval_placeholder": []}
|
||||
|
||||
def get_agent_app_candidates(**kwargs: object) -> dict:
|
||||
captured["candidates"] = kwargs
|
||||
return _candidates_response("agent_app")
|
||||
@ -1525,9 +1513,6 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
|
||||
monkeypatch.setattr(composer_controller.AgentComposerService, "load_agent_composer", load_agent_composer)
|
||||
monkeypatch.setattr(composer_controller.AgentComposerService, "save_agent_composer", save_agent_composer)
|
||||
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService, "collect_validation_findings", collect_validation_findings
|
||||
)
|
||||
monkeypatch.setattr(composer_controller.AgentComposerService, "get_agent_app_candidates", get_agent_app_candidates)
|
||||
composer = unwrap(AgentComposerApi.get)(AgentComposerApi(), MagicMock(), "tenant-1", agent_id)
|
||||
assert composer["variant"] == "agent_app"
|
||||
@ -1545,15 +1530,6 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
|
||||
assert saved_composer["variant"] == "agent_app"
|
||||
assert saved_composer["active_config_is_published"] is True
|
||||
assert cast(dict[str, object], captured["save"])["agent_id"] == agent_id
|
||||
assert unwrap(AgentComposerValidateApi.post)(
|
||||
AgentComposerValidateApi(), composer_save_payload, MagicMock(), "tenant-1", agent_id
|
||||
) == {
|
||||
"result": "success",
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"knowledge_retrieval_placeholder": [],
|
||||
}
|
||||
assert cast(dict[str, object], captured["validate"])["agent_id"] == agent_id
|
||||
candidates = unwrap(AgentComposerCandidatesApi.get)(
|
||||
AgentComposerCandidatesApi(), MagicMock(), "tenant-1", account_id, agent_id
|
||||
)
|
||||
|
||||
@ -1,310 +0,0 @@
|
||||
"""Unit tests for the console agent drive inspector (ENG-624).
|
||||
|
||||
Handlers are unwrapped past the login/app-model decorators and invoked inside a
|
||||
bare Flask request context with the drive service mocked — covering agent
|
||||
resolution, query handling, and error mapping, not auth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.console.app import agent_drive_inspector as inspector
|
||||
from controllers.console.app.agent_drive_inspector import (
|
||||
AgentDriveDownloadApi,
|
||||
AgentDriveDownloadByAgentApi,
|
||||
AgentDriveListApi,
|
||||
AgentDriveListByAgentApi,
|
||||
AgentDrivePreviewApi,
|
||||
AgentDrivePreviewByAgentApi,
|
||||
AgentDriveSkillInspectApi,
|
||||
AgentDriveSkillInspectByAgentApi,
|
||||
AgentDriveSkillListApi,
|
||||
AgentDriveSkillListByAgentApi,
|
||||
)
|
||||
from services.agent_drive_service import AgentDriveError
|
||||
|
||||
_MOD = "controllers.console.app.agent_drive_inspector"
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def _raw(method):
|
||||
return unwrap(method)
|
||||
|
||||
|
||||
_APP = SimpleNamespace(
|
||||
id="app-1",
|
||||
tenant_id="tenant-1",
|
||||
bound_agent_id_with_session=lambda *, session: "agent-1",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_bound_agent_uses_injected_session(unbound_session: Session):
|
||||
resolver = MagicMock(return_value="agent-1")
|
||||
app_model = SimpleNamespace(bound_agent_id_with_session=resolver)
|
||||
result = inspector._resolve_agent_id(unbound_session, app_model, None)
|
||||
|
||||
assert result == "agent-1"
|
||||
resolver.assert_called_once_with(session=unbound_session)
|
||||
assert resolver.call_args.kwargs["session"] is unbound_session
|
||||
|
||||
|
||||
def test_list_filters_value_pointers_out_of_console_payload(unbound_session: Session):
|
||||
raw = _raw(AgentDriveListApi.get)
|
||||
with app.test_request_context("/?prefix=pdf-toolkit/"):
|
||||
with patch(f"{_MOD}.AgentDriveService") as drive:
|
||||
drive.return_value.manifest.return_value = [
|
||||
{
|
||||
"key": "pdf-toolkit/SKILL.md",
|
||||
"size": 5,
|
||||
"hash": "h",
|
||||
"mime_type": "text/markdown",
|
||||
"file_kind": "tool_file",
|
||||
"file_id": "tf-1",
|
||||
"created_at": 1718000000,
|
||||
}
|
||||
]
|
||||
body = raw(AgentDriveListApi(), unbound_session, _APP)
|
||||
assert body["items"][0]["key"] == "pdf-toolkit/SKILL.md"
|
||||
assert "file_id" not in body["items"][0]
|
||||
assert drive.return_value.manifest.call_args.kwargs["prefix"] == "pdf-toolkit/"
|
||||
|
||||
|
||||
def test_list_by_agent_filters_value_pointers_out_of_console_payload(unbound_session: Session):
|
||||
raw = _raw(AgentDriveListByAgentApi.get)
|
||||
with app.test_request_context("/?prefix=pdf-toolkit/"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.manifest.return_value = [
|
||||
{
|
||||
"key": "pdf-toolkit/SKILL.md",
|
||||
"size": 5,
|
||||
"hash": "h",
|
||||
"mime_type": "text/markdown",
|
||||
"file_kind": "tool_file",
|
||||
"file_id": "tf-1",
|
||||
"created_at": 1718000000,
|
||||
}
|
||||
]
|
||||
body = raw(AgentDriveListByAgentApi(), unbound_session, "tenant-1", "agent-1")
|
||||
assert body["items"][0]["key"] == "pdf-toolkit/SKILL.md"
|
||||
assert "file_id" not in body["items"][0]
|
||||
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
|
||||
assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "agent-1"
|
||||
assert drive.return_value.manifest.call_args.kwargs["session"] is unbound_session
|
||||
|
||||
|
||||
def test_list_resolves_workflow_node_binding_agent(unbound_session: Session):
|
||||
raw = _raw(AgentDriveListApi.get)
|
||||
with app.test_request_context("/?node_id=agent-node-1"):
|
||||
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive:
|
||||
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
|
||||
drive.return_value.manifest.return_value = []
|
||||
raw(AgentDriveListApi(), unbound_session, _APP)
|
||||
assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "wf-agent-9"
|
||||
assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "agent-node-1"
|
||||
|
||||
|
||||
def test_skill_list_by_agent_calls_service(unbound_session: Session):
|
||||
raw = _raw(AgentDriveSkillListByAgentApi.get)
|
||||
with app.test_request_context("/"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.list_skills.return_value = [
|
||||
{
|
||||
"path": "pdf-toolkit",
|
||||
"skill_md_key": "pdf-toolkit/SKILL.md",
|
||||
"archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip",
|
||||
"name": "PDF Toolkit",
|
||||
"description": "Work with PDFs.",
|
||||
"size": 5,
|
||||
"mime_type": "text/markdown",
|
||||
"hash": None,
|
||||
"created_at": 1718000000,
|
||||
}
|
||||
]
|
||||
body = raw(AgentDriveSkillListByAgentApi(), unbound_session, "tenant-1", "agent-1")
|
||||
assert body["items"][0]["path"] == "pdf-toolkit"
|
||||
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
|
||||
assert drive.return_value.list_skills.call_args.kwargs["agent_id"] == "agent-1"
|
||||
assert drive.return_value.list_skills.call_args.kwargs["session"] is unbound_session
|
||||
|
||||
|
||||
def test_skill_list_resolves_workflow_node_binding_agent(unbound_session: Session):
|
||||
raw = _raw(AgentDriveSkillListApi.get)
|
||||
with app.test_request_context("/?node_id=agent-node-1"):
|
||||
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive:
|
||||
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
|
||||
drive.return_value.list_skills.return_value = []
|
||||
body = raw(AgentDriveSkillListApi(), unbound_session, _APP)
|
||||
assert body == {"items": []}
|
||||
assert drive.return_value.list_skills.call_args.kwargs["agent_id"] == "wf-agent-9"
|
||||
|
||||
|
||||
def test_skill_inspect_by_agent_returns_strict_json_response(unbound_session: Session):
|
||||
raw = _raw(AgentDriveSkillInspectByAgentApi.get)
|
||||
payload = {
|
||||
"path": "pdf-toolkit",
|
||||
"skill_md_key": "pdf-toolkit/SKILL.md",
|
||||
"archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip",
|
||||
"name": "PDF Toolkit",
|
||||
"description": "Work with PDFs.",
|
||||
"size": 5,
|
||||
"mime_type": "text/markdown",
|
||||
"hash": None,
|
||||
"created_at": 1718000000,
|
||||
"source": "skill_md",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"name": "SKILL.md",
|
||||
"type": "file",
|
||||
"drive_key": "pdf-toolkit/SKILL.md",
|
||||
"available_in_drive": True,
|
||||
}
|
||||
],
|
||||
"file_tree": [],
|
||||
"skill_md": {
|
||||
"key": "pdf-toolkit/SKILL.md",
|
||||
"size": 5,
|
||||
"truncated": False,
|
||||
"binary": False,
|
||||
"text": "# PDF Toolkit\nUse it.\n",
|
||||
},
|
||||
"warnings": [],
|
||||
}
|
||||
with app.test_request_context("/"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.inspect_skill.return_value = payload
|
||||
response = raw(AgentDriveSkillInspectByAgentApi(), unbound_session, "tenant-1", "agent-1", "pdf-toolkit")
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["skill_md"]["text"] == "# PDF Toolkit\nUse it.\n"
|
||||
assert b"# PDF Toolkit\\nUse it.\\n" in response.get_data()
|
||||
assert drive.return_value.inspect_skill.call_args.kwargs["session"] is unbound_session
|
||||
|
||||
|
||||
def test_skill_inspect_resolves_workflow_node_binding_agent(unbound_session: Session):
|
||||
raw = _raw(AgentDriveSkillInspectApi.get)
|
||||
payload = {
|
||||
"path": "pdf-toolkit",
|
||||
"skill_md_key": "pdf-toolkit/SKILL.md",
|
||||
"archive_key": None,
|
||||
"name": "PDF Toolkit",
|
||||
"description": "",
|
||||
"size": 5,
|
||||
"mime_type": "text/markdown",
|
||||
"hash": None,
|
||||
"created_at": None,
|
||||
"source": "skill_md",
|
||||
"files": [],
|
||||
"file_tree": [],
|
||||
"skill_md": {"key": "pdf-toolkit/SKILL.md", "size": 5, "truncated": False, "binary": False, "text": "# hi"},
|
||||
"warnings": [],
|
||||
}
|
||||
with app.test_request_context("/?node_id=agent-node-1"):
|
||||
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive:
|
||||
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
|
||||
drive.return_value.inspect_skill.return_value = payload
|
||||
response = raw(AgentDriveSkillInspectApi(), unbound_session, _APP, "pdf-toolkit")
|
||||
assert response.get_json()["path"] == "pdf-toolkit"
|
||||
assert drive.return_value.inspect_skill.call_args.kwargs["agent_id"] == "wf-agent-9"
|
||||
|
||||
|
||||
def test_list_400_when_no_agent_bound(unbound_session: Session):
|
||||
raw = _raw(AgentDriveListApi.get)
|
||||
resolver = MagicMock(return_value=None)
|
||||
app_without_agent = SimpleNamespace(bound_agent_id_with_session=resolver)
|
||||
with app.test_request_context("/"):
|
||||
body, status = raw(AgentDriveListApi(), unbound_session, app_without_agent)
|
||||
assert status == 400
|
||||
assert body["code"] == "agent_not_bound"
|
||||
resolver.assert_called_once_with(session=unbound_session)
|
||||
|
||||
|
||||
def test_preview_passes_through_and_maps_errors(unbound_session: Session):
|
||||
raw = _raw(AgentDrivePreviewApi.get)
|
||||
with app.test_request_context("/?key=pdf-toolkit/SKILL.md"):
|
||||
with patch(f"{_MOD}.AgentDriveService") as drive:
|
||||
drive.return_value.preview.return_value = {
|
||||
"key": "pdf-toolkit/SKILL.md",
|
||||
"size": 5,
|
||||
"truncated": False,
|
||||
"binary": False,
|
||||
"text": "# hi",
|
||||
}
|
||||
body = raw(AgentDrivePreviewApi(), unbound_session, _APP)
|
||||
assert body["text"] == "# hi"
|
||||
with app.test_request_context("/?key=ghost/SKILL.md"):
|
||||
with patch(f"{_MOD}.AgentDriveService") as drive:
|
||||
drive.return_value.preview.side_effect = AgentDriveError(
|
||||
"drive_key_not_found", "no drive entry", status_code=404
|
||||
)
|
||||
body, status = raw(AgentDrivePreviewApi(), unbound_session, _APP)
|
||||
assert status == 404
|
||||
assert body["code"] == "drive_key_not_found"
|
||||
|
||||
|
||||
def test_preview_by_agent_passes_through_and_maps_errors(unbound_session: Session):
|
||||
raw = _raw(AgentDrivePreviewByAgentApi.get)
|
||||
with app.test_request_context("/?key=pdf-toolkit/SKILL.md"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.preview.return_value = {
|
||||
"key": "pdf-toolkit/SKILL.md",
|
||||
"size": 5,
|
||||
"truncated": False,
|
||||
"binary": False,
|
||||
"text": "# hi",
|
||||
}
|
||||
body = raw(AgentDrivePreviewByAgentApi(), unbound_session, "tenant-1", "agent-1")
|
||||
assert body["text"] == "# hi"
|
||||
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
|
||||
assert drive.return_value.preview.call_args.kwargs["session"] is unbound_session
|
||||
with app.test_request_context("/?key=ghost/SKILL.md"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.preview.side_effect = AgentDriveError(
|
||||
"drive_key_not_found", "no drive entry", status_code=404
|
||||
)
|
||||
body, status = raw(AgentDrivePreviewByAgentApi(), unbound_session, "tenant-1", "agent-1")
|
||||
assert status == 404
|
||||
assert body["code"] == "drive_key_not_found"
|
||||
|
||||
|
||||
def test_download_returns_signed_url_json(unbound_session: Session):
|
||||
raw = _raw(AgentDriveDownloadApi.get)
|
||||
with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"):
|
||||
with patch(f"{_MOD}.AgentDriveService") as drive:
|
||||
drive.return_value.download_url.return_value = "https://signed.example/zip"
|
||||
body = raw(AgentDriveDownloadApi(), unbound_session, _APP)
|
||||
assert body == {"url": "https://signed.example/zip"}
|
||||
|
||||
|
||||
def test_download_by_agent_returns_signed_url_json(unbound_session: Session):
|
||||
raw = _raw(AgentDriveDownloadByAgentApi.get)
|
||||
with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.download_url.return_value = "https://signed.example/zip"
|
||||
body = raw(AgentDriveDownloadByAgentApi(), unbound_session, "tenant-1", "agent-1")
|
||||
assert body == {"url": "https://signed.example/zip"}
|
||||
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
|
||||
assert drive.return_value.download_url.call_args.kwargs["session"] is unbound_session
|
||||
@ -1,423 +0,0 @@
|
||||
"""Unit tests for the console agent Skill endpoints (ENG-370 / ENG-594).
|
||||
|
||||
Handlers are unwrapped past the login/app-model decorators and invoked inside a
|
||||
bare Flask request context with the services mocked — covering request handling
|
||||
+ error mapping, not auth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from datetime import UTC, datetime
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.console.app import agent as agent_controller
|
||||
from controllers.console.app.agent import (
|
||||
AgentDriveFilesByAgentApi,
|
||||
AgentSkillByAgentApi,
|
||||
AgentSkillInferToolsByAgentApi,
|
||||
AgentSkillUploadApi,
|
||||
AgentSkillUploadByAgentApi,
|
||||
)
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import AppMode, UploadFile
|
||||
from services.agent.skill_package_service import SkillPackageError
|
||||
from services.agent_drive_service import AgentDriveError
|
||||
|
||||
_MOD = "controllers.console.app.agent"
|
||||
app = Flask(__name__)
|
||||
_TENANT_ID = "00000000-0000-0000-0000-000000000010"
|
||||
_UPLOAD_FILE_ID = "0fa6f9bc-3416-4476-8857-a13129704dd9"
|
||||
|
||||
|
||||
def _raw(method):
|
||||
return unwrap(method)
|
||||
|
||||
|
||||
def _file_ctx(*, files: dict[str, bytes] | None = None):
|
||||
data = {name: (io.BytesIO(content), name) for name, content in (files or {}).items()}
|
||||
return app.test_request_context("/", method="POST", data=data, content_type="multipart/form-data")
|
||||
|
||||
|
||||
_USER = SimpleNamespace(id="user-1")
|
||||
_APP = SimpleNamespace(
|
||||
id="app-1",
|
||||
tenant_id=_TENANT_ID,
|
||||
mode=AppMode.AGENT,
|
||||
bound_agent_id_with_session=lambda *, session: "agent-1",
|
||||
)
|
||||
_WORKFLOW_APP = SimpleNamespace(
|
||||
id="app-1",
|
||||
tenant_id=_TENANT_ID,
|
||||
mode=AppMode.WORKFLOW,
|
||||
bound_agent_id_with_session=lambda *, session: None,
|
||||
)
|
||||
|
||||
|
||||
def _persist_upload(session: Session, *, name: str = "sample.pdf") -> UploadFile:
|
||||
upload = UploadFile(
|
||||
tenant_id=_TENANT_ID,
|
||||
storage_type=StorageType.LOCAL,
|
||||
key=f"uploads/{name}",
|
||||
name=name,
|
||||
size=5,
|
||||
extension="pdf",
|
||||
mime_type="application/pdf",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=str(uuid4()),
|
||||
created_at=datetime.now(UTC),
|
||||
used=False,
|
||||
)
|
||||
upload.id = _UPLOAD_FILE_ID
|
||||
session.add(upload)
|
||||
session.commit()
|
||||
return upload
|
||||
|
||||
|
||||
def test_resolve_bound_agent_uses_injected_session(unbound_session: Session):
|
||||
resolver = MagicMock(return_value="agent-1")
|
||||
app_model = SimpleNamespace(bound_agent_id_with_session=resolver)
|
||||
result = agent_controller._resolve_agent_id(unbound_session, app_model, None)
|
||||
|
||||
assert result == "agent-1"
|
||||
resolver.assert_called_once_with(session=unbound_session)
|
||||
assert resolver.call_args.kwargs["session"] is unbound_session
|
||||
|
||||
|
||||
def test_upload_standardizes_into_drive_and_returns_skill_ref(unbound_session: Session):
|
||||
raw = _raw(AgentSkillUploadApi.post)
|
||||
with _file_ctx(files={"file": b"zip-bytes"}):
|
||||
with patch(f"{_MOD}.SkillStandardizeService") as svc:
|
||||
svc.return_value.standardize.return_value = {
|
||||
"skill": {"path": "skill-a", "skill_md_key": "skill-a/SKILL.md"},
|
||||
"manifest": {"name": "Skill A"},
|
||||
}
|
||||
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP)
|
||||
assert status == 201
|
||||
assert body["skill"] == {"path": "skill-a", "skill_md_key": "skill-a/SKILL.md"}
|
||||
assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1"
|
||||
|
||||
|
||||
def test_upload_by_agent_resolves_app_and_standardizes_into_drive(unbound_session: Session):
|
||||
raw = _raw(AgentSkillUploadByAgentApi.post)
|
||||
with _file_ctx(files={"file": b"zip-bytes"}):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.SkillStandardizeService") as svc,
|
||||
):
|
||||
svc.return_value.standardize.return_value = {"skill": {"path": "skill-a"}, "manifest": {}}
|
||||
body, status = raw(AgentSkillUploadByAgentApi(), unbound_session, "tenant-1", _USER, "agent-1")
|
||||
assert status == 201
|
||||
assert body["skill"] == {"path": "skill-a"}
|
||||
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
|
||||
assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1"
|
||||
|
||||
|
||||
def test_upload_no_file_is_400(unbound_session: Session):
|
||||
raw = _raw(AgentSkillUploadApi.post)
|
||||
with _file_ctx(files={}):
|
||||
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP)
|
||||
assert status == 400
|
||||
assert body["code"] == "no_file"
|
||||
|
||||
|
||||
def test_upload_maps_package_error(unbound_session: Session):
|
||||
raw = _raw(AgentSkillUploadApi.post)
|
||||
with _file_ctx(files={"file": b"bad"}):
|
||||
with patch(f"{_MOD}.SkillStandardizeService") as svc:
|
||||
svc.return_value.standardize.side_effect = SkillPackageError(
|
||||
"missing_skill_md", "no SKILL.md", status_code=400
|
||||
)
|
||||
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP)
|
||||
assert status == 400
|
||||
assert body["code"] == "missing_skill_md"
|
||||
|
||||
|
||||
def test_upload_no_bound_agent_is_400(unbound_session: Session):
|
||||
raw = _raw(AgentSkillUploadApi.post)
|
||||
resolver = MagicMock(return_value=None)
|
||||
app_without_agent = SimpleNamespace(bound_agent_id_with_session=resolver)
|
||||
with _file_ctx(files={"file": b"zip"}):
|
||||
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, app_without_agent)
|
||||
assert status == 400
|
||||
assert body["code"] == "agent_not_bound"
|
||||
resolver.assert_called_once_with(session=unbound_session)
|
||||
|
||||
|
||||
def test_upload_resolves_workflow_node_agent(unbound_session: Session):
|
||||
raw = _raw(AgentSkillUploadApi.post)
|
||||
with app.test_request_context(
|
||||
"/?node_id=agent-node-1", method="POST", data={"file": (io.BytesIO(b"zip"), "skill.zip")}
|
||||
):
|
||||
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.SkillStandardizeService") as svc:
|
||||
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
|
||||
svc.return_value.standardize.return_value = {"skill": {"path": "s"}, "manifest": {}}
|
||||
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _WORKFLOW_APP)
|
||||
assert status == 201
|
||||
assert body["skill"] == {"path": "s"}
|
||||
assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "wf-agent-1"
|
||||
|
||||
|
||||
def test_upload_maps_drive_error(unbound_session: Session):
|
||||
raw = _raw(AgentSkillUploadApi.post)
|
||||
with _file_ctx(files={"file": b"zip"}):
|
||||
with patch(f"{_MOD}.SkillStandardizeService") as svc:
|
||||
svc.return_value.standardize.side_effect = AgentDriveError("source_not_found", "nope", status_code=404)
|
||||
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP)
|
||||
assert status == 404
|
||||
assert body["code"] == "source_not_found"
|
||||
|
||||
|
||||
def _json_ctx(payload: dict | None = None, *, method: str = "POST", query_string: str = ""):
|
||||
return app.test_request_context(f"/?{query_string}", method=method, json=payload or {})
|
||||
|
||||
|
||||
def test_files_commit_validates_upload_and_returns_drive_ref(sqlite_session: Session):
|
||||
from controllers.console.app.agent import AgentDriveFilesApi
|
||||
|
||||
raw = _raw(AgentDriveFilesApi.post)
|
||||
upload = _persist_upload(sqlite_session, name="sample qna.pdf")
|
||||
with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}):
|
||||
with patch(f"{_MOD}.console_ns") as ns, patch(f"{_MOD}.AgentDriveService") as drive:
|
||||
ns.payload = {"upload_file_id": _UPLOAD_FILE_ID}
|
||||
drive.return_value.commit.return_value = [
|
||||
{"key": "files/sample qna.pdf", "size": 5, "mime_type": "application/pdf"}
|
||||
]
|
||||
body, status = raw(AgentDriveFilesApi(), sqlite_session, _USER, _APP)
|
||||
assert status == 201
|
||||
assert body["file"]["drive_key"] == "files/sample qna.pdf"
|
||||
assert body["file"]["file_id"] == upload.id
|
||||
item = drive.return_value.commit.call_args.kwargs["items"][0]
|
||||
assert item.value_owned_by_drive is True
|
||||
assert item.file_ref.kind == "upload_file"
|
||||
|
||||
|
||||
def test_files_by_agent_commit_uses_agent_route_and_ignores_node_id(sqlite_session: Session):
|
||||
raw = _raw(AgentDriveFilesByAgentApi.post)
|
||||
_persist_upload(sqlite_session)
|
||||
with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}, query_string="node_id=ignored"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.console_ns") as ns,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
ns.payload = {"upload_file_id": _UPLOAD_FILE_ID}
|
||||
drive.return_value.commit.return_value = [
|
||||
{"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"}
|
||||
]
|
||||
body, status = raw(AgentDriveFilesByAgentApi(), sqlite_session, "tenant-1", _USER, "agent-1")
|
||||
assert status == 201
|
||||
resolve_app.assert_called_once_with(session=sqlite_session, tenant_id="tenant-1", agent_id="agent-1")
|
||||
|
||||
|
||||
def test_files_commit_404_when_upload_not_in_tenant(sqlite_session: Session):
|
||||
from controllers.console.app.agent import AgentDriveFilesApi
|
||||
|
||||
raw = _raw(AgentDriveFilesApi.post)
|
||||
other_upload = _persist_upload(sqlite_session)
|
||||
other_upload.tenant_id = str(uuid4())
|
||||
sqlite_session.commit()
|
||||
with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}):
|
||||
with patch(f"{_MOD}.console_ns") as ns:
|
||||
ns.payload = {"upload_file_id": _UPLOAD_FILE_ID}
|
||||
body, status = raw(AgentDriveFilesApi(), sqlite_session, _USER, _APP)
|
||||
assert status == 404
|
||||
assert body["code"] == "upload_file_not_found"
|
||||
|
||||
|
||||
def test_files_commit_resolves_workflow_node_agent(sqlite_session: Session):
|
||||
from controllers.console.app.agent import AgentDriveFilesApi
|
||||
|
||||
raw = _raw(AgentDriveFilesApi.post)
|
||||
_persist_upload(sqlite_session)
|
||||
with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}, query_string="node_id=agent-node-1"):
|
||||
with (
|
||||
patch(f"{_MOD}.console_ns") as ns,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
patch(f"{_MOD}.AgentComposerService") as composer,
|
||||
):
|
||||
ns.payload = {"upload_file_id": _UPLOAD_FILE_ID}
|
||||
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
|
||||
drive.return_value.commit.return_value = [
|
||||
{"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"}
|
||||
]
|
||||
body, status = raw(AgentDriveFilesApi(), sqlite_session, _USER, _WORKFLOW_APP)
|
||||
assert status == 201
|
||||
assert drive.return_value.commit.call_args.kwargs["agent_id"] == "wf-agent-1"
|
||||
|
||||
|
||||
def test_files_delete_updates_soul_then_drive(unbound_session: Session):
|
||||
from controllers.console.app.agent import AgentDriveFilesApi
|
||||
|
||||
raw = _raw(AgentDriveFilesApi.delete)
|
||||
calls: list[str] = []
|
||||
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf"):
|
||||
with patch(f"{_MOD}.AgentDriveService") as drive:
|
||||
drive.return_value.commit.side_effect = lambda **kw: (
|
||||
calls.append("drive") or [{"key": "files/sample.pdf", "removed": True}]
|
||||
)
|
||||
body = raw(AgentDriveFilesApi(), unbound_session, _USER, _APP)
|
||||
assert calls == ["drive"]
|
||||
assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]}
|
||||
|
||||
|
||||
def test_files_by_agent_delete_uses_agent_route_and_ignores_node_id(unbound_session: Session):
|
||||
raw = _raw(AgentDriveFilesByAgentApi.delete)
|
||||
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=ignored"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}]
|
||||
body = raw(AgentDriveFilesByAgentApi(), unbound_session, "tenant-1", _USER, "agent-1")
|
||||
assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]}
|
||||
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
|
||||
|
||||
|
||||
def test_files_delete_resolves_workflow_node_agent(unbound_session: Session):
|
||||
from controllers.console.app.agent import AgentDriveFilesApi
|
||||
|
||||
raw = _raw(AgentDriveFilesApi.delete)
|
||||
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=agent-node-1"):
|
||||
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive:
|
||||
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
|
||||
drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}]
|
||||
body = raw(AgentDriveFilesApi(), unbound_session, _USER, _WORKFLOW_APP)
|
||||
assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]}
|
||||
assert drive.return_value.commit.call_args.kwargs["agent_id"] == "wf-agent-1"
|
||||
|
||||
|
||||
def test_files_delete_survives_drive_failure(unbound_session: Session):
|
||||
from controllers.console.app.agent import AgentDriveFilesApi
|
||||
|
||||
raw = _raw(AgentDriveFilesApi.delete)
|
||||
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf"):
|
||||
with patch(f"{_MOD}.AgentDriveService") as drive:
|
||||
drive.return_value.commit.side_effect = RuntimeError("storage down")
|
||||
with pytest.raises(RuntimeError, match="storage down"):
|
||||
raw(AgentDriveFilesApi(), unbound_session, _USER, _APP)
|
||||
|
||||
|
||||
def test_skill_delete_uses_slug_prefix_and_is_idempotent(unbound_session: Session):
|
||||
from controllers.console.app.agent import AgentSkillApi
|
||||
|
||||
raw = _raw(AgentSkillApi.delete)
|
||||
with _json_ctx(method="DELETE"):
|
||||
with patch(f"{_MOD}.AgentDriveService") as drive:
|
||||
drive.return_value.commit.return_value = [
|
||||
{"key": "tender-analyzer/SKILL.md", "removed": True},
|
||||
{"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "removed": True},
|
||||
]
|
||||
body = raw(AgentSkillApi(), unbound_session, _USER, _APP, "tender-analyzer")
|
||||
assert body == {
|
||||
"result": "success",
|
||||
"removed_keys": ["tender-analyzer/SKILL.md", "tender-analyzer/.DIFY-SKILL-FULL.zip"],
|
||||
}
|
||||
|
||||
|
||||
def test_skill_delete_by_agent_uses_agent_route(unbound_session: Session):
|
||||
raw = _raw(AgentSkillByAgentApi.delete)
|
||||
with _json_ctx(method="DELETE", query_string="node_id=ignored"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.commit.return_value = [{"key": "tender-analyzer/SKILL.md", "removed": True}]
|
||||
body = raw(AgentSkillByAgentApi(), unbound_session, "tenant-1", _USER, "agent-1", "tender-analyzer")
|
||||
assert body == {"result": "success", "removed_keys": ["tender-analyzer/SKILL.md"]}
|
||||
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
|
||||
|
||||
|
||||
def test_skill_delete_rejects_path_like_slug(unbound_session: Session):
|
||||
from controllers.console.app.agent import AgentSkillApi
|
||||
|
||||
raw = _raw(AgentSkillApi.delete)
|
||||
with _json_ctx(method="DELETE"):
|
||||
body, status = raw(AgentSkillApi(), unbound_session, _USER, _APP, "a/b")
|
||||
assert status == 400
|
||||
assert body["code"] == "drive_key_invalid"
|
||||
|
||||
|
||||
def test_infer_tools_returns_draft_suggestions(unbound_session: Session):
|
||||
from controllers.console.app.agent import AgentSkillInferToolsApi
|
||||
|
||||
raw = _raw(AgentSkillInferToolsApi.post)
|
||||
with _json_ctx():
|
||||
with patch(f"{_MOD}.SkillToolInferenceService") as svc:
|
||||
svc.return_value.infer.return_value = {
|
||||
"inferable": True,
|
||||
"cli_tools": [{"name": "ffmpeg", "inferred_from": "audio-transcribe"}],
|
||||
"reason": None,
|
||||
}
|
||||
body = raw(AgentSkillInferToolsApi(), unbound_session, _APP, "audio-transcribe")
|
||||
assert body["inferable"] is True
|
||||
assert svc.return_value.infer.call_args.kwargs["slug"] == "audio-transcribe"
|
||||
|
||||
|
||||
def test_infer_tools_by_agent_uses_agent_route(unbound_session: Session):
|
||||
raw = _raw(AgentSkillInferToolsByAgentApi.post)
|
||||
with _json_ctx(query_string="node_id=ignored"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.SkillToolInferenceService") as svc,
|
||||
):
|
||||
svc.return_value.infer.return_value = {"inferable": True, "cli_tools": [], "reason": None}
|
||||
body = raw(
|
||||
AgentSkillInferToolsByAgentApi(),
|
||||
unbound_session,
|
||||
"tenant-1",
|
||||
"agent-1",
|
||||
"audio-transcribe",
|
||||
)
|
||||
assert body["inferable"] is True
|
||||
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
|
||||
assert svc.return_value.infer.call_args.kwargs["agent_id"] == "agent-1"
|
||||
|
||||
|
||||
def test_infer_tools_resolves_workflow_node_agent(unbound_session: Session):
|
||||
from controllers.console.app.agent import AgentSkillInferToolsApi
|
||||
|
||||
raw = _raw(AgentSkillInferToolsApi.post)
|
||||
with _json_ctx(query_string="node_id=agent-node-1"):
|
||||
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.SkillToolInferenceService") as svc:
|
||||
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
|
||||
svc.return_value.infer.return_value = {"inferable": False, "cli_tools": [], "reason": "none"}
|
||||
body = raw(AgentSkillInferToolsApi(), unbound_session, _WORKFLOW_APP, "audio-transcribe")
|
||||
assert body["inferable"] is False
|
||||
assert svc.return_value.infer.call_args.kwargs["agent_id"] == "wf-agent-1"
|
||||
|
||||
|
||||
def test_infer_tools_maps_inference_errors(unbound_session: Session):
|
||||
from controllers.console.app.agent import AgentSkillInferToolsApi
|
||||
from services.agent.skill_tool_inference_service import SkillToolInferenceError
|
||||
|
||||
raw = _raw(AgentSkillInferToolsApi.post)
|
||||
with _json_ctx():
|
||||
with patch(f"{_MOD}.SkillToolInferenceService") as svc:
|
||||
svc.return_value.infer.side_effect = SkillToolInferenceError(
|
||||
"default_model_not_configured", "no model", status_code=400
|
||||
)
|
||||
body, status = raw(AgentSkillInferToolsApi(), unbound_session, _APP, "audio-transcribe")
|
||||
assert status == 400
|
||||
assert body["code"] == "default_model_not_configured"
|
||||
|
||||
|
||||
def test_infer_tools_rejects_path_like_slug_and_unbound_app(unbound_session: Session):
|
||||
from controllers.console.app.agent import AgentSkillInferToolsApi
|
||||
|
||||
raw = _raw(AgentSkillInferToolsApi.post)
|
||||
with _json_ctx():
|
||||
body, status = raw(AgentSkillInferToolsApi(), unbound_session, _APP, "a/b")
|
||||
assert (status, body["code"]) == (400, "drive_key_invalid")
|
||||
app_without_agent = SimpleNamespace(bound_agent_id_with_session=MagicMock(return_value=None))
|
||||
with _json_ctx():
|
||||
body, status = raw(AgentSkillInferToolsApi(), unbound_session, app_without_agent, "x")
|
||||
assert (status, body["code"]) == (400, "agent_not_bound")
|
||||
@ -17,6 +17,7 @@ from pydantic import ValidationError
|
||||
|
||||
from controllers.console.auth.error import (
|
||||
EmailCodeError,
|
||||
EmailCodeLoginServiceUnavailableError,
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
TurnstileServiceUnavailableError,
|
||||
@ -37,9 +38,16 @@ from controllers.console.error import (
|
||||
WorkspacesLimitExceeded,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
from services.email_code_login_challenge import (
|
||||
EmailCodeLoginChallengeResult,
|
||||
EmailCodeLoginChallengeStatus,
|
||||
EmailCodeLoginChallengeUnavailableError,
|
||||
)
|
||||
from services.errors.account import AccountRegisterError
|
||||
from services.turnstile_service import TurnstileChallengeRejectedError, TurnstileUpstreamError
|
||||
|
||||
TEST_TOKEN = "00000000-0000-4000-8000-000000000001"
|
||||
|
||||
|
||||
def encode_code(code: str) -> str:
|
||||
"""Helper to encode verification code as Base64 for testing."""
|
||||
@ -52,7 +60,7 @@ def test_email_code_login_payload_rejects_invalid_timezone():
|
||||
{
|
||||
"email": "newuser@example.com",
|
||||
"code": "123456",
|
||||
"token": "token-123",
|
||||
"token": TEST_TOKEN,
|
||||
"timezone": "",
|
||||
}
|
||||
)
|
||||
@ -61,6 +69,18 @@ def test_email_code_login_payload_rejects_invalid_timezone():
|
||||
def test_turnstile_token_is_scoped_to_email_code_send_payload():
|
||||
assert "turnstile_token" in EmailCodeSendPayload.model_fields
|
||||
assert "turnstile_token" not in EmailPayload.model_fields
|
||||
assert "turnstile_token" in EmailCodeLoginPayload.model_fields
|
||||
|
||||
|
||||
def test_email_code_login_code_schema_does_not_describe_plaintext_format():
|
||||
code_schema = EmailCodeLoginPayload.model_json_schema()["properties"]["code"]
|
||||
|
||||
assert "pattern" not in code_schema
|
||||
|
||||
|
||||
def test_email_code_login_payload_rejects_non_uuid_token():
|
||||
with pytest.raises(ValidationError):
|
||||
EmailCodeLoginPayload.model_validate({"email": "user@example.com", "code": "123456", "token": "not-a-uuid"})
|
||||
|
||||
|
||||
class TestEmailCodeLoginSendEmailApi:
|
||||
@ -379,9 +399,146 @@ class TestEmailCodeLoginApi:
|
||||
token_pair.csrf_token = "csrf_token"
|
||||
return token_pair
|
||||
|
||||
@pytest.mark.parametrize("code", ["12345", "1234567", "abcdef", "١٢٣٤٥٦"])
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
|
||||
@patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
|
||||
@patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
|
||||
def test_rejects_malformed_code_after_wire_decode(
|
||||
self,
|
||||
mock_verify_challenge,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
code: str,
|
||||
):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": encode_code(code), "token": TEST_TOKEN},
|
||||
),
|
||||
pytest.raises(EmailCodeError),
|
||||
):
|
||||
EmailCodeLoginApi().post()
|
||||
|
||||
mock_verify_challenge.assert_not_called()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
|
||||
@patch("controllers.console.auth.login.TurnstileService.verify")
|
||||
def test_cloud_verify_uses_separate_turnstile_action_when_required(
|
||||
self,
|
||||
mock_turnstile_verify,
|
||||
mock_verify_challenge,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
):
|
||||
mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
|
||||
status=EmailCodeLoginChallengeStatus.INVALID_TOKEN
|
||||
)
|
||||
|
||||
with (
|
||||
patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
|
||||
patch("controllers.console.auth.login.dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED", True),
|
||||
app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={
|
||||
"email": "test@example.com",
|
||||
"code": encode_code("123456"),
|
||||
"token": TEST_TOKEN,
|
||||
"turnstile_token": "verify-challenge-token",
|
||||
},
|
||||
headers={"CF-Connecting-IP": "203.0.113.8"},
|
||||
),
|
||||
pytest.raises(InvalidTokenError),
|
||||
):
|
||||
EmailCodeLoginApi().post()
|
||||
|
||||
mock_turnstile_verify.assert_called_once_with(
|
||||
token="verify-challenge-token",
|
||||
remote_ip="203.0.113.8",
|
||||
expected_action="signin_code_verify",
|
||||
)
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
|
||||
@patch(
|
||||
"controllers.console.auth.login.TurnstileService.verify",
|
||||
side_effect=TurnstileChallengeRejectedError,
|
||||
)
|
||||
def test_cloud_verify_rejects_missing_turnstile_before_consuming_code(
|
||||
self,
|
||||
mock_turnstile_verify,
|
||||
mock_verify_challenge,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
):
|
||||
with (
|
||||
patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
|
||||
patch("controllers.console.auth.login.dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED", True),
|
||||
app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
|
||||
),
|
||||
pytest.raises(TurnstileVerificationFailedError),
|
||||
):
|
||||
EmailCodeLoginApi().post()
|
||||
|
||||
mock_turnstile_verify.assert_called_once()
|
||||
mock_verify_challenge.assert_not_called()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
|
||||
@patch("controllers.console.auth.login.TurnstileService.verify")
|
||||
def test_cloud_verify_flag_off_allows_legacy_client_without_turnstile(
|
||||
self,
|
||||
mock_turnstile_verify,
|
||||
mock_verify_challenge,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
):
|
||||
mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
|
||||
status=EmailCodeLoginChallengeStatus.INVALID_TOKEN
|
||||
)
|
||||
|
||||
with (
|
||||
patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
|
||||
patch("controllers.console.auth.login.dify_config.TURNSTILE_EMAIL_CODE_VERIFY_REQUIRED", False),
|
||||
app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
|
||||
),
|
||||
pytest.raises(InvalidTokenError),
|
||||
):
|
||||
EmailCodeLoginApi().post()
|
||||
|
||||
mock_turnstile_verify.assert_not_called()
|
||||
mock_verify_challenge.assert_called_once()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch(
|
||||
"controllers.console.auth.login.AccountService.verify_email_code_login_challenge",
|
||||
side_effect=EmailCodeLoginChallengeUnavailableError,
|
||||
)
|
||||
def test_verify_maps_redis_failure_to_service_unavailable(
|
||||
self,
|
||||
mock_verify_challenge,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
|
||||
),
|
||||
pytest.raises(EmailCodeLoginServiceUnavailableError),
|
||||
):
|
||||
EmailCodeLoginApi().post()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
|
||||
@patch("controllers.console.auth.login.AccountService.get_user_through_email")
|
||||
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
|
||||
@patch("controllers.console.auth.login.AccountService.login")
|
||||
@ -392,8 +549,7 @@ class TestEmailCodeLoginApi:
|
||||
mock_login,
|
||||
mock_get_tenants,
|
||||
mock_get_user,
|
||||
mock_revoke_token,
|
||||
mock_get_data,
|
||||
mock_verify_challenge,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
mock_account,
|
||||
@ -408,7 +564,9 @@ class TestEmailCodeLoginApi:
|
||||
- User is logged in with token pair
|
||||
"""
|
||||
# Arrange
|
||||
mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
|
||||
mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
|
||||
status=EmailCodeLoginChallengeStatus.VERIFIED
|
||||
)
|
||||
mock_get_user.return_value = mock_account
|
||||
mock_get_tenants.return_value = [MagicMock()]
|
||||
mock_login.return_value = mock_token_pair
|
||||
@ -417,19 +575,18 @@ class TestEmailCodeLoginApi:
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": encode_code("123456"), "token": "valid_token"},
|
||||
json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
|
||||
):
|
||||
api = EmailCodeLoginApi()
|
||||
response = api.post()
|
||||
|
||||
# Assert
|
||||
assert response.json["result"] == "success"
|
||||
mock_revoke_token.assert_called_once_with("valid_token")
|
||||
mock_verify_challenge.assert_called_once_with(email="test@example.com", code="123456", token=TEST_TOKEN)
|
||||
mock_login.assert_called_once()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
|
||||
@patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
|
||||
@patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
|
||||
@patch("controllers.console.auth.login.AccountService.get_user_through_email")
|
||||
@patch("controllers.console.auth.login.AccountService.create_account_and_tenant")
|
||||
@patch("controllers.console.auth.login.AccountService.login")
|
||||
@ -440,8 +597,7 @@ class TestEmailCodeLoginApi:
|
||||
mock_login,
|
||||
mock_create_account,
|
||||
mock_get_user,
|
||||
mock_revoke_token,
|
||||
mock_get_data,
|
||||
mock_verify_challenge,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
mock_account,
|
||||
@ -456,7 +612,9 @@ class TestEmailCodeLoginApi:
|
||||
- User is logged in after account creation
|
||||
"""
|
||||
# Arrange
|
||||
mock_get_data.return_value = {"email": "newuser@example.com", "code": "123456"}
|
||||
mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
|
||||
status=EmailCodeLoginChallengeStatus.VERIFIED
|
||||
)
|
||||
mock_get_user.return_value = None
|
||||
mock_create_account.return_value = mock_account
|
||||
mock_login.return_value = mock_token_pair
|
||||
@ -470,7 +628,7 @@ class TestEmailCodeLoginApi:
|
||||
json={
|
||||
"email": "newuser@example.com",
|
||||
"code": encode_code("123456"),
|
||||
"token": "valid_token",
|
||||
"token": TEST_TOKEN,
|
||||
"language": "en-US",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
@ -491,8 +649,8 @@ class TestEmailCodeLoginApi:
|
||||
)
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
|
||||
def test_email_code_login_invalid_token(self, mock_get_data, mock_db, app: Flask):
|
||||
@patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
|
||||
def test_email_code_login_invalid_token(self, mock_verify_challenge, mock_db, app: Flask):
|
||||
"""
|
||||
Test email code login with invalid token.
|
||||
|
||||
@ -500,21 +658,23 @@ class TestEmailCodeLoginApi:
|
||||
- InvalidTokenError is raised for invalid/expired tokens
|
||||
"""
|
||||
# Arrange
|
||||
mock_get_data.return_value = None
|
||||
mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
|
||||
status=EmailCodeLoginChallengeStatus.INVALID_TOKEN
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": encode_code("123456"), "token": "invalid_token"},
|
||||
json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
|
||||
):
|
||||
api = EmailCodeLoginApi()
|
||||
with pytest.raises(InvalidTokenError):
|
||||
api.post()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
|
||||
def test_email_code_login_email_mismatch(self, mock_get_data, mock_db, app: Flask):
|
||||
@patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
|
||||
def test_email_code_login_email_mismatch(self, mock_verify_challenge, mock_db, app: Flask):
|
||||
"""
|
||||
Test email code login with mismatched email.
|
||||
|
||||
@ -522,21 +682,23 @@ class TestEmailCodeLoginApi:
|
||||
- InvalidEmailError is raised when email doesn't match token
|
||||
"""
|
||||
# Arrange
|
||||
mock_get_data.return_value = {"email": "original@example.com", "code": "123456"}
|
||||
mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
|
||||
status=EmailCodeLoginChallengeStatus.EMAIL_MISMATCH
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "different@example.com", "code": encode_code("123456"), "token": "token"},
|
||||
json={"email": "different@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
|
||||
):
|
||||
api = EmailCodeLoginApi()
|
||||
with pytest.raises(InvalidEmailError):
|
||||
api.post()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
|
||||
def test_email_code_login_wrong_code(self, mock_get_data, mock_db, app: Flask):
|
||||
@patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
|
||||
def test_email_code_login_wrong_code(self, mock_verify_challenge, mock_db, app: Flask):
|
||||
"""
|
||||
Test email code login with incorrect code.
|
||||
|
||||
@ -544,21 +706,23 @@ class TestEmailCodeLoginApi:
|
||||
- EmailCodeError is raised for wrong verification code
|
||||
"""
|
||||
# Arrange
|
||||
mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
|
||||
mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
|
||||
status=EmailCodeLoginChallengeStatus.INVALID_CODE,
|
||||
remaining_attempts=4,
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": encode_code("wrong_code"), "token": "token"},
|
||||
json={"email": "test@example.com", "code": encode_code("654321"), "token": TEST_TOKEN},
|
||||
):
|
||||
api = EmailCodeLoginApi()
|
||||
with pytest.raises(EmailCodeError):
|
||||
api.post()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
|
||||
@patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
|
||||
@patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
|
||||
@patch("controllers.console.auth.login.AccountService.get_user_through_email")
|
||||
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
|
||||
@patch("controllers.console.auth.login.FeatureService.is_workspace_creation_allowed")
|
||||
@ -567,8 +731,7 @@ class TestEmailCodeLoginApi:
|
||||
mock_is_workspace_creation_allowed,
|
||||
mock_get_tenants,
|
||||
mock_get_user,
|
||||
mock_revoke_token,
|
||||
mock_get_data,
|
||||
mock_verify_challenge,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
mock_account,
|
||||
@ -581,7 +744,9 @@ class TestEmailCodeLoginApi:
|
||||
- User is added as owner of new workspace
|
||||
"""
|
||||
# Arrange
|
||||
mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
|
||||
mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
|
||||
status=EmailCodeLoginChallengeStatus.VERIFIED
|
||||
)
|
||||
mock_get_user.return_value = mock_account
|
||||
mock_get_tenants.return_value = []
|
||||
mock_is_workspace_creation_allowed.return_value = True
|
||||
@ -590,15 +755,14 @@ class TestEmailCodeLoginApi:
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": "123456", "token": "token"},
|
||||
json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
|
||||
):
|
||||
api = EmailCodeLoginApi()
|
||||
# This would complete the flow, but we're testing workspace creation logic
|
||||
# In real implementation, TenantService.create_tenant would be called
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
|
||||
@patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
|
||||
@patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
|
||||
@patch("controllers.console.auth.login.AccountService.get_user_through_email")
|
||||
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
|
||||
@patch("controllers.console.auth.login.FeatureService.get_license")
|
||||
@ -609,8 +773,7 @@ class TestEmailCodeLoginApi:
|
||||
mock_get_license,
|
||||
mock_get_tenants,
|
||||
mock_get_user,
|
||||
mock_revoke_token,
|
||||
mock_get_data,
|
||||
mock_verify_challenge,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
mock_account,
|
||||
@ -622,7 +785,9 @@ class TestEmailCodeLoginApi:
|
||||
- WorkspacesLimitExceeded is raised when limit reached
|
||||
"""
|
||||
# Arrange
|
||||
mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
|
||||
mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
|
||||
status=EmailCodeLoginChallengeStatus.VERIFIED
|
||||
)
|
||||
mock_get_user.return_value = mock_account
|
||||
mock_get_tenants.return_value = []
|
||||
mock_get_license.return_value.workspaces.is_available.return_value = False
|
||||
@ -632,15 +797,14 @@ class TestEmailCodeLoginApi:
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": encode_code("123456"), "token": "token"},
|
||||
json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
|
||||
):
|
||||
api = EmailCodeLoginApi()
|
||||
with pytest.raises(WorkspacesLimitExceeded):
|
||||
api.post()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
|
||||
@patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
|
||||
@patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
|
||||
@patch("controllers.console.auth.login.AccountService.get_user_through_email")
|
||||
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
|
||||
@patch("controllers.console.auth.login.FeatureService.is_workspace_creation_allowed")
|
||||
@ -649,8 +813,7 @@ class TestEmailCodeLoginApi:
|
||||
mock_is_workspace_creation_allowed,
|
||||
mock_get_tenants,
|
||||
mock_get_user,
|
||||
mock_revoke_token,
|
||||
mock_get_data,
|
||||
mock_verify_challenge,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
mock_account,
|
||||
@ -662,7 +825,9 @@ class TestEmailCodeLoginApi:
|
||||
- NotAllowedCreateWorkspace is raised when creation disabled
|
||||
"""
|
||||
# Arrange
|
||||
mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
|
||||
mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
|
||||
status=EmailCodeLoginChallengeStatus.VERIFIED
|
||||
)
|
||||
mock_get_user.return_value = mock_account
|
||||
mock_get_tenants.return_value = []
|
||||
mock_is_workspace_creation_allowed.return_value = False
|
||||
@ -671,7 +836,7 @@ class TestEmailCodeLoginApi:
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "test@example.com", "code": encode_code("123456"), "token": "token"},
|
||||
json={"email": "test@example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
|
||||
):
|
||||
api = EmailCodeLoginApi()
|
||||
with pytest.raises(NotAllowedCreateWorkspace):
|
||||
|
||||
@ -30,9 +30,12 @@ from controllers.console.error import (
|
||||
WorkspacesLimitExceeded,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
from services.email_code_login_challenge import EmailCodeLoginChallengeResult, EmailCodeLoginChallengeStatus
|
||||
from services.entities.auth_entities import LoginFailureReason
|
||||
from services.errors.account import AccountLoginError, AccountPasswordError, SeatsLimitExceededError
|
||||
|
||||
TEST_TOKEN = "00000000-0000-4000-8000-000000000001"
|
||||
|
||||
|
||||
def encode_password(password: str) -> str:
|
||||
"""Helper to encode password as Base64 for testing."""
|
||||
@ -458,30 +461,29 @@ class TestLoginApi:
|
||||
mock_reset_rate_limit.assert_called_once_with("upper@example.com")
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
|
||||
@patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
|
||||
@patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
|
||||
@patch("controllers.console.auth.login._get_account_with_case_fallback")
|
||||
def test_email_code_login_logs_banned_account(
|
||||
self,
|
||||
mock_get_account: MagicMock,
|
||||
mock_revoke_token: MagicMock,
|
||||
mock_get_token_data: MagicMock,
|
||||
mock_verify_challenge: MagicMock,
|
||||
mock_db: MagicMock,
|
||||
app: Flask,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"}
|
||||
mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
|
||||
status=EmailCodeLoginChallengeStatus.VERIFIED
|
||||
)
|
||||
mock_get_account.side_effect = Unauthorized("Account is banned.")
|
||||
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "User@Example.com", "code": encode_code("123456"), "token": "token-123"},
|
||||
json={"email": "User@Example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
|
||||
):
|
||||
with pytest.raises(AccountBannedError):
|
||||
EmailCodeLoginApi().post()
|
||||
|
||||
mock_revoke_token.assert_called_once_with("token-123")
|
||||
warn_records = [
|
||||
r for r in caplog.records if r.name == "controllers.console.auth.login" and r.levelno == logging.WARNING
|
||||
]
|
||||
@ -492,14 +494,12 @@ class TestLoginApi:
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.auth.login.db")
|
||||
@patch("controllers.console.auth.login.AccountService.create_account_and_tenant")
|
||||
@patch("controllers.console.auth.login.AccountService.get_email_code_login_data")
|
||||
@patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
|
||||
@patch("controllers.console.auth.login.AccountService.verify_email_code_login_challenge")
|
||||
@patch("controllers.console.auth.login._get_account_with_case_fallback")
|
||||
def test_email_code_login_fails_when_seats_limit_exceeded(
|
||||
self,
|
||||
mock_get_account: MagicMock,
|
||||
mock_revoke_token: MagicMock,
|
||||
mock_get_token_data: MagicMock,
|
||||
mock_verify_challenge: MagicMock,
|
||||
mock_create_account: MagicMock,
|
||||
mock_login_db: MagicMock,
|
||||
mock_db: MagicMock,
|
||||
@ -513,7 +513,9 @@ class TestLoginApi:
|
||||
- the service-layer SeatsLimitExceededError is translated to the SeatsLimitExceeded HTTP error
|
||||
"""
|
||||
# Arrange: valid token, no existing account -> account-creation path
|
||||
mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"}
|
||||
mock_verify_challenge.return_value = EmailCodeLoginChallengeResult(
|
||||
status=EmailCodeLoginChallengeStatus.VERIFIED
|
||||
)
|
||||
mock_get_account.return_value = None
|
||||
mock_create_account.side_effect = SeatsLimitExceededError("licensed seats limit exceeded")
|
||||
|
||||
@ -521,7 +523,7 @@ class TestLoginApi:
|
||||
with app.test_request_context(
|
||||
"/email-code-login/validity",
|
||||
method="POST",
|
||||
json={"email": "User@Example.com", "code": encode_code("123456"), "token": "token-123"},
|
||||
json={"email": "User@Example.com", "code": encode_code("123456"), "token": TEST_TOKEN},
|
||||
):
|
||||
with pytest.raises(SeatsLimitExceeded):
|
||||
EmailCodeLoginApi().post()
|
||||
|
||||
@ -85,7 +85,7 @@ def _use_sqlite_banner_service(
|
||||
) -> None:
|
||||
service = ExploreBannerQueryService(
|
||||
banners=ExploreBannerQueryRepository(sqlite_session_factory),
|
||||
is_enabled=lambda: True,
|
||||
enabled=True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
banner_module,
|
||||
@ -97,7 +97,7 @@ def _use_sqlite_banner_service(
|
||||
class TestExploreBannerQueryService:
|
||||
def test_returns_empty_without_querying_when_disabled(self) -> None:
|
||||
banners = FakeExploreBannerQuery()
|
||||
service = ExploreBannerQueryService(banners=banners, is_enabled=lambda: False)
|
||||
service = ExploreBannerQueryService(banners=banners, enabled=False)
|
||||
|
||||
assert service.list_for_language("fr-FR") == ()
|
||||
assert banners.requested_languages == []
|
||||
@ -105,7 +105,7 @@ class TestExploreBannerQueryService:
|
||||
def test_returns_requested_language(self) -> None:
|
||||
record = _record()
|
||||
banners = FakeExploreBannerQuery({"fr-FR": (record,)})
|
||||
service = ExploreBannerQueryService(banners=banners, is_enabled=lambda: True)
|
||||
service = ExploreBannerQueryService(banners=banners, enabled=True)
|
||||
|
||||
assert service.list_for_language("fr-FR") == (record,)
|
||||
assert banners.requested_languages == ["fr-FR"]
|
||||
@ -113,14 +113,14 @@ class TestExploreBannerQueryService:
|
||||
def test_falls_back_to_en_us(self) -> None:
|
||||
record = _record(title="fallback")
|
||||
banners = FakeExploreBannerQuery({"en-US": (record,)})
|
||||
service = ExploreBannerQueryService(banners=banners, is_enabled=lambda: True)
|
||||
service = ExploreBannerQueryService(banners=banners, enabled=True)
|
||||
|
||||
assert service.list_for_language("es-ES") == (record,)
|
||||
assert banners.requested_languages == ["es-ES", "en-US"]
|
||||
|
||||
def test_does_not_repeat_default_language_query(self) -> None:
|
||||
banners = FakeExploreBannerQuery()
|
||||
service = ExploreBannerQueryService(banners=banners, is_enabled=lambda: True)
|
||||
service = ExploreBannerQueryService(banners=banners, enabled=True)
|
||||
|
||||
assert service.list_for_language("en-US") == ()
|
||||
assert banners.requested_languages == ["en-US"]
|
||||
|
||||
@ -5,6 +5,8 @@ import pytest
|
||||
|
||||
from controllers.common.wraps import RBACPermission, RBACResourceScope
|
||||
from controllers.console.datasets.data_source import DataSourceApi
|
||||
from controllers.console.workspace.model_providers import ModelProviderCredentialApi
|
||||
from controllers.console.workspace.models import ModelProviderModelCredentialApi
|
||||
from controllers.console.workspace.tool_providers import ToolBuiltinProviderAddApi
|
||||
|
||||
|
||||
@ -26,3 +28,25 @@ def test_workspace_credential_mutations_require_management_permission(
|
||||
assert rbac_config["resource_type"] == RBACResourceScope.WORKSPACE
|
||||
assert rbac_config["scene"] == permission
|
||||
assert rbac_config["resource_required"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method",
|
||||
[
|
||||
ModelProviderCredentialApi.get,
|
||||
ModelProviderModelCredentialApi.get,
|
||||
],
|
||||
)
|
||||
def test_model_provider_credential_get_requires_admin_and_rbac(
|
||||
method: FunctionType,
|
||||
) -> None:
|
||||
"""GET endpoints that return provider credential details must enforce
|
||||
the same admin + RBAC gates as their sibling POST/PUT/DELETE methods."""
|
||||
legacy_wrapper = unwrap(method, stop=lambda wrapper: "is_admin_or_owner_required" in wrapper.__code__.co_qualname)
|
||||
assert "is_admin_or_owner_required" in legacy_wrapper.__code__.co_qualname
|
||||
|
||||
rbac_wrapper = unwrap(method, stop=lambda wrapper: "rbac_permission_required" in wrapper.__code__.co_qualname)
|
||||
rbac_config = getclosurevars(rbac_wrapper).nonlocals
|
||||
assert rbac_config["resource_type"] == RBACResourceScope.WORKSPACE
|
||||
assert rbac_config["scene"] == RBACPermission.CREDENTIAL_MANAGE
|
||||
assert rbac_config["resource_required"] is False
|
||||
|
||||
@ -1,172 +0,0 @@
|
||||
"""Unit tests for the agent drive inner-API controller (ENG-591).
|
||||
|
||||
Handlers are unwrapped past the auth/setup decorators and invoked inside a bare
|
||||
Flask request context, with AgentDriveService mocked — so this covers the
|
||||
controller's request parsing + error mapping, not auth (tested separately).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.inner_api.plugin.agent_drive import AgentDriveCommitApi, AgentDriveManifestApi, AgentDriveSkillsApi
|
||||
from models.enums import EndUserType
|
||||
from models.model import EndUser
|
||||
from services.agent_drive_service import AgentDriveError
|
||||
|
||||
_MOD = "controllers.inner_api.plugin.agent_drive"
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def _raw(method):
|
||||
return inspect.unwrap(method)
|
||||
|
||||
|
||||
def _end_user(user_id: str) -> EndUser:
|
||||
return EndUser(
|
||||
id=user_id,
|
||||
tenant_id="tenant-1",
|
||||
type=EndUserType.SERVICE_API,
|
||||
session_id="session-1",
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_parses_query_and_returns_items():
|
||||
raw = _raw(AgentDriveManifestApi.get)
|
||||
with app.test_request_context("/?tenant_id=tenant-1&prefix=docs/&include_download_url=true"):
|
||||
with patch(f"{_MOD}.AgentDriveService") as svc:
|
||||
svc.return_value.manifest.return_value = [{"key": "docs/a.txt"}]
|
||||
result = raw(AgentDriveManifestApi(), "agent-agent-1")
|
||||
assert result == {"items": [{"key": "docs/a.txt"}]}
|
||||
svc.return_value.manifest.assert_called_once_with(
|
||||
tenant_id="tenant-1", agent_id="agent-1", prefix="docs/", include_download_url=True, session=ANY
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_missing_tenant_id_is_400():
|
||||
raw = _raw(AgentDriveManifestApi.get)
|
||||
with app.test_request_context("/"):
|
||||
body, status = raw(AgentDriveManifestApi(), "agent-agent-1")
|
||||
assert status == 400
|
||||
assert body["code"] == "missing_tenant_id"
|
||||
|
||||
|
||||
def test_manifest_bad_drive_ref_is_400():
|
||||
raw = _raw(AgentDriveManifestApi.get)
|
||||
with app.test_request_context("/?tenant_id=tenant-1"):
|
||||
body, status = raw(AgentDriveManifestApi(), "not-an-agent-ref")
|
||||
assert status == 400
|
||||
assert body["code"] == "invalid_drive_ref"
|
||||
|
||||
|
||||
def test_skills_requires_tenant_id_and_returns_items():
|
||||
raw = _raw(AgentDriveSkillsApi.get)
|
||||
|
||||
with app.test_request_context("/"):
|
||||
body, status = raw(AgentDriveSkillsApi(), "agent-agent-1")
|
||||
assert status == 400
|
||||
assert body["code"] == "missing_tenant_id"
|
||||
|
||||
with app.test_request_context("/?tenant_id=tenant-1"):
|
||||
with patch(f"{_MOD}.AgentDriveService") as svc:
|
||||
svc.return_value.list_skills.return_value = [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": None,
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
}
|
||||
]
|
||||
result = raw(AgentDriveSkillsApi(), "agent-agent-1")
|
||||
|
||||
assert result == {
|
||||
"items": [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": None,
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
}
|
||||
]
|
||||
}
|
||||
assert svc.return_value.list_skills.call_args.kwargs == {
|
||||
"tenant_id": "tenant-1",
|
||||
"agent_id": "agent-1",
|
||||
"session": ANY,
|
||||
}
|
||||
|
||||
|
||||
def test_commit_parses_body_and_returns_items():
|
||||
raw = _raw(AgentDriveCommitApi.post)
|
||||
payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}],
|
||||
}
|
||||
with app.test_request_context("/", method="POST", json=payload):
|
||||
with (
|
||||
patch(f"{_MOD}.get_user", return_value=_end_user("user-1")) as get_user,
|
||||
patch(f"{_MOD}.AgentDriveService") as svc,
|
||||
):
|
||||
svc.return_value.commit.return_value = [{"key": "a.txt"}]
|
||||
result = raw(AgentDriveCommitApi(), "agent-agent-1")
|
||||
assert result == {"items": [{"key": "a.txt"}]}
|
||||
assert get_user.call_args.args == ("tenant-1", "user-1")
|
||||
assert svc.return_value.commit.call_args.kwargs["agent_id"] == "agent-1"
|
||||
assert svc.return_value.commit.call_args.kwargs["user_id"] == "user-1"
|
||||
|
||||
|
||||
def test_commit_canonicalizes_user_before_service_call():
|
||||
raw = _raw(AgentDriveCommitApi.post)
|
||||
payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "session-1",
|
||||
"items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}],
|
||||
}
|
||||
with app.test_request_context("/", method="POST", json=payload):
|
||||
with (
|
||||
patch(f"{_MOD}.get_user", return_value=_end_user("end-user-1")),
|
||||
patch(f"{_MOD}.AgentDriveService") as svc,
|
||||
):
|
||||
svc.return_value.commit.return_value = [{"key": "a.txt"}]
|
||||
result = raw(AgentDriveCommitApi(), "agent-agent-1")
|
||||
|
||||
assert result == {"items": [{"key": "a.txt"}]}
|
||||
assert svc.return_value.commit.call_args.kwargs["user_id"] == "end-user-1"
|
||||
|
||||
|
||||
def test_commit_invalid_body_is_400():
|
||||
raw = _raw(AgentDriveCommitApi.post)
|
||||
with app.test_request_context("/", method="POST", json={"tenant_id": "t"}): # missing user_id/items
|
||||
body, status = raw(AgentDriveCommitApi(), "agent-agent-1")
|
||||
assert status == 400
|
||||
assert body["code"] == "invalid_request"
|
||||
|
||||
|
||||
def test_commit_maps_service_error():
|
||||
raw = _raw(AgentDriveCommitApi.post)
|
||||
payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"items": [{"key": "a.txt", "file_ref": {"kind": "tool_file", "id": "tf-1"}}],
|
||||
}
|
||||
with app.test_request_context("/", method="POST", json=payload):
|
||||
with (
|
||||
patch(f"{_MOD}.get_user", return_value=_end_user("user-1")),
|
||||
patch(f"{_MOD}.AgentDriveService") as svc,
|
||||
):
|
||||
svc.return_value.commit.side_effect = AgentDriveError("source_not_found", "nope", status_code=404)
|
||||
body, status = raw(AgentDriveCommitApi(), "agent-agent-1")
|
||||
assert status == 404
|
||||
assert body["code"] == "source_not_found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("api_cls", [AgentDriveManifestApi, AgentDriveSkillsApi, AgentDriveCommitApi])
|
||||
def test_endpoints_have_handlers(api_cls):
|
||||
assert callable(getattr(api_cls(), "get", None) or getattr(api_cls(), "post", None))
|
||||
@ -17,8 +17,10 @@ from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
from werkzeug.exceptions import Forbidden, Unauthorized
|
||||
|
||||
from controllers.service_api.app import app as app_controller
|
||||
from controllers.service_api.app import site as site_controller
|
||||
from controllers.service_api.app.app import AppInfoApi, AppMetaApi, AppParameterApi
|
||||
from controllers.service_api.app.error import AgentNotPublishedError, AppUnavailableError
|
||||
from controllers.service_api.app.site import AppSiteApi
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus
|
||||
from models.base import TypeBase
|
||||
@ -27,6 +29,7 @@ from services.app_definition_query_service import (
|
||||
AppDefinitionNotPublishedError,
|
||||
AppDefinitionSummary,
|
||||
AppDefinitionUnavailableError,
|
||||
AppSiteConfiguration,
|
||||
)
|
||||
|
||||
|
||||
@ -290,6 +293,61 @@ def test_get_info_maps_unavailable_app(
|
||||
AppInfoApi().get()
|
||||
|
||||
|
||||
def test_get_site_configuration_queries_authenticated_app(
|
||||
flask_app: Flask,
|
||||
authenticated_controller: AppDatabase,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app_definitions = Mock()
|
||||
app_definitions.get_site_configuration.return_value = AppSiteConfiguration(
|
||||
title="Test Site",
|
||||
chat_color_theme="light",
|
||||
chat_color_theme_inverted=False,
|
||||
icon_type="emoji",
|
||||
icon="robot",
|
||||
icon_background="#ffffff",
|
||||
description="A test site",
|
||||
copyright=None,
|
||||
privacy_policy=None,
|
||||
input_placeholder="Ask anything",
|
||||
custom_disclaimer=None,
|
||||
default_language="en-US",
|
||||
show_workflow_steps=True,
|
||||
use_icon_as_answer_icon=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
site_controller,
|
||||
"application_services",
|
||||
Mock(return_value=SimpleNamespace(app_definitions=app_definitions)),
|
||||
)
|
||||
|
||||
with flask_app.test_request_context("/site", headers={"Authorization": "Bearer token"}):
|
||||
response = AppSiteApi().get()
|
||||
|
||||
app_definitions.get_site_configuration.assert_called_once_with(authenticated_controller.app_id)
|
||||
assert response["title"] == "Test Site"
|
||||
assert response["icon"] == "robot"
|
||||
assert response["icon_url"] is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("authenticated_controller")
|
||||
def test_get_site_configuration_maps_missing_site_to_forbidden(
|
||||
flask_app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app_definitions = Mock()
|
||||
app_definitions.get_site_configuration.side_effect = AppDefinitionUnavailableError("Site not found")
|
||||
monkeypatch.setattr(
|
||||
site_controller,
|
||||
"application_services",
|
||||
Mock(return_value=SimpleNamespace(app_definitions=app_definitions)),
|
||||
)
|
||||
|
||||
with flask_app.test_request_context("/site", headers={"Authorization": "Bearer token"}):
|
||||
with pytest.raises(Forbidden):
|
||||
AppSiteApi().get()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", ["missing", "disabled", "archived", "ownerless"])
|
||||
def test_authentication_rejects_empty_or_invisible_database_state(
|
||||
flask_app: Flask,
|
||||
|
||||
@ -3,17 +3,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.common.errors import InvalidArgumentError
|
||||
from controllers.web.app import AppAccessMode, AppMeta, AppParameterApi, AppWebAuthPermission
|
||||
from controllers.web.error import AgentNotPublishedError, AppUnavailableError
|
||||
from controllers.web.error import (
|
||||
AgentNotPublishedError,
|
||||
AppUnavailableError,
|
||||
WebAppAccessServiceUnavailableError,
|
||||
WebAppNotFoundError,
|
||||
)
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from enums import WebAppAccessMode
|
||||
from models.enums import EndUserType
|
||||
from models.model import App, AppMode, EndUser
|
||||
from services.app_definition_query_service import AppDefinitionNotPublishedError, AppDefinitionUnavailableError
|
||||
from services.webapp_access_query_service import (
|
||||
WebAppAccessAppNotFoundError,
|
||||
WebAppAccessReferenceRequiredError,
|
||||
WebAppAccessUnavailableError,
|
||||
)
|
||||
|
||||
|
||||
def _make_app() -> App:
|
||||
@ -119,53 +131,64 @@ class TestAppMeta:
|
||||
# AppAccessMode
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAppAccessMode:
|
||||
@patch("controllers.web.app.FeatureService.get_system_features")
|
||||
def test_returns_public_when_webapp_auth_disabled(self, mock_features: MagicMock, app: Flask) -> None:
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
|
||||
@patch("controllers.web.app.application_services")
|
||||
def test_delegates_validated_app_references(self, application_services: MagicMock, app: Flask) -> None:
|
||||
webapp_access = MagicMock()
|
||||
webapp_access.get_access_mode.return_value = WebAppAccessMode.SSO_VERIFIED
|
||||
application_services.return_value = SimpleNamespace(webapp_access=webapp_access)
|
||||
|
||||
with app.test_request_context("/webapp/access-mode?appId=app-1"):
|
||||
with app.test_request_context("/webapp/access-mode?appId=app-1&appCode=code-1"):
|
||||
result = AppAccessMode().get()
|
||||
|
||||
assert result == {"accessMode": "public"}
|
||||
assert result == {"accessMode": "sso_verified"}
|
||||
webapp_access.get_access_mode.assert_called_once_with(app_id="app-1", app_code="code-1")
|
||||
|
||||
@patch("controllers.web.app.EnterpriseService.WebAppAuth.get_app_access_mode_by_id")
|
||||
@patch("controllers.web.app.FeatureService.get_system_features")
|
||||
def test_returns_access_mode_with_app_id(
|
||||
self, mock_features: MagicMock, mock_access: MagicMock, app: Flask
|
||||
@pytest.mark.parametrize(
|
||||
("service_error", "http_error", "expected_data"),
|
||||
[
|
||||
pytest.param(
|
||||
WebAppAccessReferenceRequiredError("appId or appCode must be provided"),
|
||||
InvalidArgumentError,
|
||||
{"code": "invalid_param", "message": "appId or appCode must be provided", "status": 400},
|
||||
id="missing-reference",
|
||||
),
|
||||
pytest.param(
|
||||
WebAppAccessAppNotFoundError(),
|
||||
WebAppNotFoundError,
|
||||
{"code": "app_not_found", "message": "App not found.", "status": 404},
|
||||
id="app-not-found",
|
||||
),
|
||||
pytest.param(
|
||||
WebAppAccessUnavailableError(),
|
||||
WebAppAccessServiceUnavailableError,
|
||||
{
|
||||
"code": "web_app_access_unavailable",
|
||||
"message": "Web app access service is unavailable.",
|
||||
"status": 503,
|
||||
},
|
||||
id="access-unavailable",
|
||||
),
|
||||
],
|
||||
)
|
||||
@patch("controllers.web.app.application_services")
|
||||
def test_maps_query_errors(
|
||||
self,
|
||||
application_services: MagicMock,
|
||||
service_error: Exception,
|
||||
http_error: type[Exception],
|
||||
expected_data: dict[str, object],
|
||||
app: Flask,
|
||||
) -> None:
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True))
|
||||
mock_access.return_value = SimpleNamespace(access_mode="internal")
|
||||
webapp_access = MagicMock()
|
||||
webapp_access.get_access_mode.side_effect = service_error
|
||||
application_services.return_value = SimpleNamespace(webapp_access=webapp_access)
|
||||
|
||||
with app.test_request_context("/webapp/access-mode?appId=app-1"):
|
||||
result = AppAccessMode().get()
|
||||
|
||||
assert result == {"accessMode": "internal"}
|
||||
mock_access.assert_called_once_with("app-1")
|
||||
|
||||
@patch("controllers.web.app.AppService.get_app_id_by_code", return_value="resolved-id")
|
||||
@patch("controllers.web.app.EnterpriseService.WebAppAuth.get_app_access_mode_by_id")
|
||||
@patch("controllers.web.app.FeatureService.get_system_features")
|
||||
def test_resolves_app_code_to_id(
|
||||
self, mock_features: MagicMock, mock_access: MagicMock, mock_resolve: MagicMock, app: Flask
|
||||
) -> None:
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True))
|
||||
mock_access.return_value = SimpleNamespace(access_mode="external")
|
||||
|
||||
with app.test_request_context("/webapp/access-mode?appCode=code1"):
|
||||
result = AppAccessMode().get()
|
||||
|
||||
mock_resolve.assert_called_once_with("code1", session=ANY)
|
||||
mock_access.assert_called_once_with("resolved-id")
|
||||
assert result == {"accessMode": "external"}
|
||||
|
||||
@patch("controllers.web.app.FeatureService.get_system_features")
|
||||
def test_raises_when_no_app_id_or_code(self, mock_features: MagicMock, app: Flask) -> None:
|
||||
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True))
|
||||
|
||||
with app.test_request_context("/webapp/access-mode"):
|
||||
with pytest.raises(ValueError, match="appId or appCode"):
|
||||
with app.test_request_context("/webapp/access-mode?appCode=code-1"):
|
||||
with pytest.raises(http_error) as raised:
|
||||
AppAccessMode().get()
|
||||
|
||||
assert raised.value.data == expected_data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AppWebAuthPermission
|
||||
|
||||
@ -24,8 +24,10 @@ from controllers.web.error import (
|
||||
ProviderQuotaExceededError,
|
||||
SpeechToTextDisabledError,
|
||||
UnsupportedAudioTypeError,
|
||||
WebAppAccessServiceUnavailableError,
|
||||
WebAppAuthAccessDeniedError,
|
||||
WebAppAuthRequiredError,
|
||||
WebAppNotFoundError,
|
||||
WebFormRateLimitExceededError,
|
||||
)
|
||||
|
||||
@ -49,6 +51,8 @@ _ERROR_SPECS: list[tuple[type, str, int]] = [
|
||||
(SpeechToTextDisabledError, "speech_to_text_disabled", 400),
|
||||
(WebAppAuthRequiredError, "web_sso_auth_required", 401),
|
||||
(WebAppAuthAccessDeniedError, "web_app_access_denied", 401),
|
||||
(WebAppNotFoundError, "app_not_found", 404),
|
||||
(WebAppAccessServiceUnavailableError, "web_app_access_unavailable", 503),
|
||||
(InvokeRateLimitError, "rate_limit_error", 429),
|
||||
(WebFormRateLimitExceededError, "web_form_rate_limit_exceeded", 429),
|
||||
(NotFoundError, "not_found", 404),
|
||||
|
||||
@ -497,7 +497,6 @@ class TestAgentAppConfigLayer:
|
||||
"execution_context": "execution_context",
|
||||
"runtime": "runtime",
|
||||
}
|
||||
assert layers[DIFY_SHELL_LAYER_ID].config.agent_stub_drive_ref is None
|
||||
|
||||
def test_config_layer_for_build_draft_marks_config_writable(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
|
||||
@ -1478,7 +1478,6 @@ def test_workflow_run_request_has_config_layer_with_empty_agent_soul(monkeypatch
|
||||
"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
"runtime": "runtime",
|
||||
}
|
||||
assert layers[DIFY_SHELL_LAYER_ID]["config"]["agent_stub_drive_ref"] is None
|
||||
|
||||
|
||||
def test_workflow_run_request_contains_config_layer():
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
"""Tests for application-service dependency wiring."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from enums import DeploymentEdition
|
||||
from enums import DeploymentEdition, WebAppAccessMode
|
||||
from extensions import ext_application_services
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from models.model import DifySetup
|
||||
@ -18,7 +22,10 @@ from services.account_activation_adapters import (
|
||||
RegisterServiceInvitationTokenStore,
|
||||
)
|
||||
from services.auth.data_source_api_key_auth_service import DataSourceApiKeyAuthService
|
||||
from services.enterprise.enterprise_service import WebAppSettings
|
||||
from services.errors.enterprise import EnterpriseAPIError, EnterpriseAPINotFoundError
|
||||
from services.init_validation_service import InvalidInitializationPasswordError
|
||||
from services.webapp_access_query_service import WebAppAccessUnavailableError
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -186,3 +193,110 @@ def test_build_application_services_wires_data_source_api_key_auth(
|
||||
)
|
||||
|
||||
assert isinstance(services.data_source_api_key_auth, DataSourceApiKeyAuthService)
|
||||
|
||||
|
||||
def test_build_application_services_adapts_enterprise_webapp_access_mode(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with (
|
||||
patch("extensions.ext_application_services.FeatureService.is_webapp_auth_enabled", return_value=True),
|
||||
patch(
|
||||
"extensions.ext_application_services.EnterpriseService.WebAppAuth.get_app_access_mode_by_id",
|
||||
return_value=SimpleNamespace(access_mode="private_all"),
|
||||
) as get_access_mode,
|
||||
):
|
||||
services = ext_application_services.build_application_services(
|
||||
database_client=sqlite_session_factory,
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
initialization_password="",
|
||||
redis=MagicMock(spec=RedisClientWrapper),
|
||||
)
|
||||
result = services.webapp_access.get_access_mode(app_id="app-1", app_code=None)
|
||||
|
||||
assert result is WebAppAccessMode.PRIVATE_ALL
|
||||
get_access_mode.assert_called_once_with("app-1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"enterprise_error",
|
||||
[
|
||||
pytest.param(EnterpriseAPINotFoundError(), id="not-found"),
|
||||
pytest.param(EnterpriseAPIError(), id="api-error"),
|
||||
pytest.param(httpx.ConnectError("connection failed"), id="transport"),
|
||||
pytest.param(json.JSONDecodeError("invalid", "", 0), id="invalid-json"),
|
||||
pytest.param(UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid"), id="invalid-encoding"),
|
||||
pytest.param(
|
||||
ValidationError.from_exception_data(WebAppSettings.__name__, []),
|
||||
id="invalid-response",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_build_application_services_maps_known_enterprise_errors(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
enterprise_error: Exception,
|
||||
) -> None:
|
||||
with (
|
||||
patch("extensions.ext_application_services.FeatureService.is_webapp_auth_enabled", return_value=True),
|
||||
patch(
|
||||
"extensions.ext_application_services.EnterpriseService.WebAppAuth.get_app_access_mode_by_id",
|
||||
side_effect=enterprise_error,
|
||||
),
|
||||
):
|
||||
services = ext_application_services.build_application_services(
|
||||
database_client=sqlite_session_factory,
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
initialization_password="",
|
||||
redis=MagicMock(spec=RedisClientWrapper),
|
||||
)
|
||||
|
||||
with pytest.raises(WebAppAccessUnavailableError) as raised:
|
||||
services.webapp_access.get_access_mode(app_id="app-1", app_code=None)
|
||||
|
||||
assert raised.value.__cause__ is enterprise_error
|
||||
|
||||
|
||||
def test_build_application_services_maps_invalid_access_mode_to_unavailable(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with (
|
||||
patch("extensions.ext_application_services.FeatureService.is_webapp_auth_enabled", return_value=True),
|
||||
patch(
|
||||
"extensions.ext_application_services.EnterpriseService.WebAppAuth.get_app_access_mode_by_id",
|
||||
return_value=SimpleNamespace(access_mode="invalid"),
|
||||
),
|
||||
):
|
||||
services = ext_application_services.build_application_services(
|
||||
database_client=sqlite_session_factory,
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
initialization_password="",
|
||||
redis=MagicMock(spec=RedisClientWrapper),
|
||||
)
|
||||
|
||||
with pytest.raises(WebAppAccessUnavailableError) as raised:
|
||||
services.webapp_access.get_access_mode(app_id="app-1", app_code=None)
|
||||
|
||||
assert isinstance(raised.value.__cause__, ValueError)
|
||||
|
||||
|
||||
def test_build_application_services_does_not_hide_unknown_enterprise_errors(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
failure = TypeError("adapter bug")
|
||||
with (
|
||||
patch("extensions.ext_application_services.FeatureService.is_webapp_auth_enabled", return_value=True),
|
||||
patch(
|
||||
"extensions.ext_application_services.EnterpriseService.WebAppAuth.get_app_access_mode_by_id",
|
||||
side_effect=failure,
|
||||
),
|
||||
):
|
||||
services = ext_application_services.build_application_services(
|
||||
database_client=sqlite_session_factory,
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
initialization_password="",
|
||||
redis=MagicMock(spec=RedisClientWrapper),
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError) as raised:
|
||||
services.webapp_access.get_access_mode(app_id="app-1", app_code=None)
|
||||
|
||||
assert raised.value is failure
|
||||
|
||||
@ -1,122 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
_MIGRATION_PATH = (
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_migration_module():
|
||||
spec = importlib.util.spec_from_file_location("agent_drive_skill_metadata_refactor", _MIGRATION_PATH)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("failed to load migration module")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _create_pre_upgrade_schema(engine: sa.Engine) -> None:
|
||||
metadata = sa.MetaData()
|
||||
sa.Table(
|
||||
"agent_drive_files",
|
||||
metadata,
|
||||
sa.Column("tenant_id", sa.String(36), nullable=False),
|
||||
sa.Column("agent_id", sa.String(36), nullable=False),
|
||||
sa.Column("key", sa.String(512), nullable=False),
|
||||
sa.Column("file_kind", sa.String(32), nullable=False),
|
||||
sa.Column("file_id", sa.String(36), nullable=False),
|
||||
sa.Column("value_owned_by_drive", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("size", sa.BigInteger(), nullable=True),
|
||||
sa.Column("hash", sa.String(255), nullable=True),
|
||||
sa.Column("mime_type", sa.String(255), nullable=True),
|
||||
sa.Column("created_by", sa.String(36), nullable=True),
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.UniqueConstraint("tenant_id", "agent_id", "key", name="agent_drive_file_scope_key_unique"),
|
||||
)
|
||||
sa.Table(
|
||||
"agent_config_snapshots",
|
||||
metadata,
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("config_snapshot", sa.Text(), nullable=False),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
|
||||
def _run_migration_step(module: object, engine: sa.Engine, step_name: str) -> None:
|
||||
with engine.begin() as connection:
|
||||
context = MigrationContext.configure(connection)
|
||||
operations = Operations(context)
|
||||
original_op = module.op
|
||||
module.op = operations
|
||||
try:
|
||||
getattr(module, step_name)()
|
||||
finally:
|
||||
module.op = original_op
|
||||
|
||||
|
||||
def test_upgrade_adds_skill_columns_and_index_and_preserves_snapshot_data() -> None:
|
||||
engine = sa.create_engine("sqlite:///:memory:")
|
||||
_create_pre_upgrade_schema(engine)
|
||||
snapshot = {
|
||||
"prompt": {"system_prompt": "Use [§skill:legacy:Legacy§]"},
|
||||
"skills_files": {"skills": [{"name": "Legacy"}], "files": [{"name": "u.pdf"}]},
|
||||
}
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
sa.text("INSERT INTO agent_config_snapshots (id, config_snapshot) VALUES (:id, :config_snapshot)"),
|
||||
{"id": "snap-1", "config_snapshot": json.dumps(snapshot)},
|
||||
)
|
||||
|
||||
module = _load_migration_module()
|
||||
_run_migration_step(module, engine, "upgrade")
|
||||
|
||||
inspector = sa.inspect(engine)
|
||||
columns = {column["name"] for column in inspector.get_columns("agent_drive_files")}
|
||||
assert {"is_skill", "skill_metadata"}.issubset(columns)
|
||||
indexes = {index["name"] for index in inspector.get_indexes("agent_drive_files")}
|
||||
assert "agent_drive_files_tenant_agent_is_skill_key_idx" in indexes
|
||||
|
||||
with engine.begin() as connection:
|
||||
stored_snapshot = connection.execute(
|
||||
sa.text("SELECT config_snapshot FROM agent_config_snapshots WHERE id = :id"),
|
||||
{"id": "snap-1"},
|
||||
).scalar_one()
|
||||
assert json.loads(stored_snapshot) == snapshot
|
||||
|
||||
|
||||
def test_downgrade_drops_skill_columns_and_index_without_reconstructing_legacy_data() -> None:
|
||||
engine = sa.create_engine("sqlite:///:memory:")
|
||||
_create_pre_upgrade_schema(engine)
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
sa.text("INSERT INTO agent_config_snapshots (id, config_snapshot) VALUES (:id, :config_snapshot)"),
|
||||
{"id": "snap-1", "config_snapshot": json.dumps({"prompt": {"system_prompt": "hello"}})},
|
||||
)
|
||||
|
||||
module = _load_migration_module()
|
||||
_run_migration_step(module, engine, "upgrade")
|
||||
_run_migration_step(module, engine, "downgrade")
|
||||
|
||||
inspector = sa.inspect(engine)
|
||||
columns = {column["name"] for column in inspector.get_columns("agent_drive_files")}
|
||||
assert "is_skill" not in columns
|
||||
assert "skill_metadata" not in columns
|
||||
indexes = {index["name"] for index in inspector.get_indexes("agent_drive_files")}
|
||||
assert "agent_drive_files_tenant_agent_is_skill_key_idx" not in indexes
|
||||
|
||||
with engine.begin() as connection:
|
||||
stored_snapshot = connection.execute(
|
||||
sa.text("SELECT config_snapshot FROM agent_config_snapshots WHERE id = :id"),
|
||||
{"id": "snap-1"},
|
||||
).scalar_one()
|
||||
assert "skills_files" not in json.loads(stored_snapshot)
|
||||
184
api/tests/unit_tests/migrations/test_remove_agent_drive.py
Normal file
184
api/tests/unit_tests/migrations/test_remove_agent_drive.py
Normal file
@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
_MIGRATION_PATH = (
|
||||
Path(__file__).resolve().parents[3] / "migrations/versions/2026_08_17_1740-89919253ca7a_remove_agent_drive.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_migration_module() -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location("remove_agent_drive", _MIGRATION_PATH)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("failed to load migration module")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _create_pre_upgrade_schema(engine: sa.Engine) -> None:
|
||||
metadata = sa.MetaData()
|
||||
sa.Table("agent_drive_files", metadata, sa.Column("id", sa.String(36), primary_key=True))
|
||||
for table_name in ("agent_config_snapshots", "agent_config_drafts"):
|
||||
sa.Table(
|
||||
table_name,
|
||||
metadata,
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("config_snapshot", sa.Text(), nullable=False),
|
||||
)
|
||||
sa.Table(
|
||||
"workflow_agent_node_bindings",
|
||||
metadata,
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("node_job_config", sa.Text(), nullable=False),
|
||||
)
|
||||
metadata.create_all(engine)
|
||||
|
||||
|
||||
def _run_migration_step(module: ModuleType, engine: sa.Engine, step_name: str) -> None:
|
||||
migration_step = module.__dict__[step_name]
|
||||
if not callable(migration_step):
|
||||
raise TypeError(f"migration step {step_name!r} is not callable")
|
||||
|
||||
with engine.begin() as connection:
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
original_op = module.__dict__["op"]
|
||||
module.__dict__["op"] = operations
|
||||
try:
|
||||
migration_step()
|
||||
finally:
|
||||
module.__dict__["op"] = original_op
|
||||
|
||||
|
||||
def test_upgrade_removes_agent_drive_schema_and_legacy_json_fields() -> None:
|
||||
engine = sa.create_engine("sqlite:///:memory:")
|
||||
_create_pre_upgrade_schema(engine)
|
||||
soul = {
|
||||
"files": {"skills": [{"name": "legacy"}]},
|
||||
"config_skills": [{"name": "current", "file_id": "tool-1"}],
|
||||
"prompt": {"system_prompt": "hello"},
|
||||
}
|
||||
node_job = {
|
||||
"metadata": {
|
||||
"file_refs": [
|
||||
{"id": "upload-1", "drive_key": "files/input.pdf"},
|
||||
{"id": "upload-2"},
|
||||
]
|
||||
},
|
||||
"declared_outputs": [
|
||||
{
|
||||
"name": "report",
|
||||
"type": "file",
|
||||
"check": {"benchmark_file_ref": {"id": "upload-3", "drive_key": "files/reference.pdf"}},
|
||||
}
|
||||
],
|
||||
}
|
||||
with engine.begin() as connection:
|
||||
for table_name in ("agent_config_snapshots", "agent_config_drafts"):
|
||||
connection.execute(
|
||||
sa.text(f"INSERT INTO {table_name} (id, config_snapshot) VALUES (:id, :value)"),
|
||||
{"id": table_name, "value": json.dumps(soul)},
|
||||
)
|
||||
connection.execute(
|
||||
sa.text("INSERT INTO workflow_agent_node_bindings (id, node_job_config) VALUES (:id, :value)"),
|
||||
{"id": "binding-1", "value": json.dumps(node_job)},
|
||||
)
|
||||
|
||||
module = _load_migration_module()
|
||||
_run_migration_step(module, engine, "upgrade")
|
||||
|
||||
assert "agent_drive_files" not in sa.inspect(engine).get_table_names()
|
||||
with engine.begin() as connection:
|
||||
for table_name in ("agent_config_snapshots", "agent_config_drafts"):
|
||||
stored = connection.execute(sa.text(f"SELECT config_snapshot FROM {table_name}")).scalar_one()
|
||||
value = json.loads(stored)
|
||||
assert "files" not in value
|
||||
assert value["config_skills"] == soul["config_skills"]
|
||||
assert value["prompt"] == soul["prompt"]
|
||||
stored_node_job = connection.execute(
|
||||
sa.text("SELECT node_job_config FROM workflow_agent_node_bindings")
|
||||
).scalar_one()
|
||||
|
||||
migrated_node_job = json.loads(stored_node_job)
|
||||
assert migrated_node_job["metadata"]["file_refs"] == [{"id": "upload-1"}, {"id": "upload-2"}]
|
||||
assert migrated_node_job["declared_outputs"][0]["check"]["benchmark_file_ref"] == {"id": "upload-3"}
|
||||
|
||||
_run_migration_step(module, engine, "downgrade")
|
||||
inspector = sa.inspect(engine)
|
||||
assert "agent_drive_files" in inspector.get_table_names()
|
||||
assert {
|
||||
"tenant_id",
|
||||
"agent_id",
|
||||
"key",
|
||||
"file_kind",
|
||||
"file_id",
|
||||
"value_owned_by_drive",
|
||||
"is_skill",
|
||||
"skill_metadata",
|
||||
}.issubset({column["name"] for column in inspector.get_columns("agent_drive_files")})
|
||||
assert "agent_drive_file_scope_key_unique" in {
|
||||
constraint["name"] for constraint in inspector.get_unique_constraints("agent_drive_files")
|
||||
}
|
||||
assert "agent_drive_files_tenant_agent_is_skill_key_idx" in {
|
||||
index["name"] for index in inspector.get_indexes("agent_drive_files")
|
||||
}
|
||||
|
||||
|
||||
def test_upgrade_supports_offline_sql_generation() -> None:
|
||||
module = _load_migration_module()
|
||||
output = StringIO()
|
||||
migration_context = MigrationContext.configure(
|
||||
dialect_name="postgresql",
|
||||
opts={"as_sql": True, "output_buffer": output},
|
||||
)
|
||||
operations = Operations(migration_context)
|
||||
migration_step = module.__dict__["upgrade"]
|
||||
if not callable(migration_step):
|
||||
raise TypeError("migration upgrade is not callable")
|
||||
|
||||
original_op = module.__dict__["op"]
|
||||
module.__dict__["op"] = operations
|
||||
try:
|
||||
migration_step()
|
||||
finally:
|
||||
module.__dict__["op"] = original_op
|
||||
|
||||
generated_sql = output.getvalue()
|
||||
assert "DROP TABLE agent_drive_files" in generated_sql
|
||||
assert "SELECT id" not in generated_sql
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("table_name", "column_name"),
|
||||
[
|
||||
pytest.param("agent_config_snapshots", "config_snapshot", id="config-snapshot"),
|
||||
pytest.param("workflow_agent_node_bindings", "node_job_config", id="node-job-config"),
|
||||
],
|
||||
)
|
||||
def test_upgrade_rejects_invalid_json_without_rewriting(table_name: str, column_name: str) -> None:
|
||||
engine = sa.create_engine("sqlite:///:memory:")
|
||||
_create_pre_upgrade_schema(engine)
|
||||
invalid_json = "not-json"
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
sa.text(f"INSERT INTO {table_name} (id, {column_name}) VALUES (:id, :value)"),
|
||||
{"id": "invalid-row", "value": invalid_json},
|
||||
)
|
||||
|
||||
module = _load_migration_module()
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
_run_migration_step(module, engine, "upgrade")
|
||||
|
||||
with engine.begin() as connection:
|
||||
stored = connection.execute(sa.text(f"SELECT {column_name} FROM {table_name}")).scalar_one()
|
||||
assert stored == invalid_json
|
||||
assert "agent_drive_files" in sa.inspect(engine).get_table_names()
|
||||
@ -39,9 +39,7 @@ project-excludes = [
|
||||
"controllers/console/agent/test_agent_controllers.py",
|
||||
"controllers/console/app/test_agent_app_sandbox.py",
|
||||
"controllers/console/app/test_agent_config_inspector.py",
|
||||
"controllers/console/app/test_agent_drive_inspector.py",
|
||||
"controllers/console/app/test_agent_manage_guard.py",
|
||||
"controllers/console/app/test_agent_skills.py",
|
||||
"controllers/console/app/test_annotation_api.py",
|
||||
"controllers/console/app/test_annotation_security.py",
|
||||
"controllers/console/app/test_app_apis.py",
|
||||
@ -146,7 +144,6 @@ project-excludes = [
|
||||
"controllers/files/test_upload.py",
|
||||
"controllers/inner_api/app/test_dsl.py",
|
||||
"controllers/inner_api/plugin/test_agent_config.py",
|
||||
"controllers/inner_api/plugin/test_agent_drive.py",
|
||||
"controllers/inner_api/plugin/test_plugin.py",
|
||||
"controllers/inner_api/plugin/test_plugin_wraps.py",
|
||||
"controllers/inner_api/test_auth_wraps.py",
|
||||
@ -723,7 +720,6 @@ project-excludes = [
|
||||
"libs/test_workspace_member_helper.py",
|
||||
"libs/test_workspace_permission.py",
|
||||
"libs/test_yarl.py",
|
||||
"migrations/test_agent_drive_skill_metadata_refactor.py",
|
||||
"migrations/test_uuidv7_pg18_migration.py",
|
||||
"models/test_account_models.py",
|
||||
"models/test_agent.py",
|
||||
@ -819,7 +815,6 @@ project-excludes = [
|
||||
"services/test_agent_app_feature_service.py",
|
||||
"services/test_agent_app_sandbox_service.py",
|
||||
"services/test_agent_config_service.py",
|
||||
"services/test_agent_drive_service.py",
|
||||
"services/test_annotation_service.py",
|
||||
"services/test_api_token_service.py",
|
||||
"services/test_app_generate_service.py",
|
||||
|
||||
@ -5,12 +5,17 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType
|
||||
from models.account import Account
|
||||
from models.enums import TagType
|
||||
from models.model import App, AppMode, AppModelConfig, Tag, TagBinding
|
||||
from models.enums import CustomizeTokenStrategy, TagType
|
||||
from models.model import App, AppMode, AppModelConfig, IconType, Site, Tag, TagBinding
|
||||
from models.tools import ApiToolProvider
|
||||
from models.workflow import Workflow, WorkflowKind, WorkflowType
|
||||
from repositories.app_definition_query_repository import AppDefinitionQueryRepository
|
||||
from services.app_definition_query_service import AppDefinitionSummary, AppParameterConfig, AppToolIconSource
|
||||
from services.app_definition_query_service import (
|
||||
AppDefinitionSummary,
|
||||
AppParameterConfig,
|
||||
AppSiteConfiguration,
|
||||
AppToolIconSource,
|
||||
)
|
||||
|
||||
_APP_ID = "11111111-1111-1111-1111-111111111111"
|
||||
_TENANT_ID = "22222222-2222-2222-2222-222222222222"
|
||||
@ -290,6 +295,57 @@ def test_get_summary_returns_only_tenant_scoped_app_tags(
|
||||
assert result.author_name is None
|
||||
|
||||
|
||||
def test_get_site_configuration_returns_none_for_missing_site(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repository = AppDefinitionQueryRepository(session_factory=sqlite_session_factory)
|
||||
|
||||
assert repository.get_site_configuration(_APP_ID) is None
|
||||
|
||||
|
||||
def test_get_site_configuration_maps_site_fields(sqlite_session_factory: sessionmaker[Session]) -> None:
|
||||
with sqlite_session_factory.begin() as session:
|
||||
site = Site(
|
||||
app_id=_APP_ID,
|
||||
title="Test Site",
|
||||
icon_type=IconType.IMAGE,
|
||||
icon="11111111-1111-4111-8111-111111111111",
|
||||
icon_background="#ffffff",
|
||||
description="A test site",
|
||||
default_language="en-US",
|
||||
chat_color_theme="light",
|
||||
chat_color_theme_inverted=True,
|
||||
copyright="Copyright",
|
||||
privacy_policy="Privacy",
|
||||
input_placeholder="Ask anything",
|
||||
show_workflow_steps=False,
|
||||
use_icon_as_answer_icon=True,
|
||||
customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW,
|
||||
prompt_public=True,
|
||||
)
|
||||
site.custom_disclaimer = "Disclaimer"
|
||||
session.add(site)
|
||||
|
||||
result = AppDefinitionQueryRepository(session_factory=sqlite_session_factory).get_site_configuration(_APP_ID)
|
||||
|
||||
assert result == AppSiteConfiguration(
|
||||
title="Test Site",
|
||||
chat_color_theme="light",
|
||||
chat_color_theme_inverted=True,
|
||||
icon_type=IconType.IMAGE.value,
|
||||
icon="11111111-1111-4111-8111-111111111111",
|
||||
icon_background="#ffffff",
|
||||
description="A test site",
|
||||
copyright="Copyright",
|
||||
privacy_policy="Privacy",
|
||||
input_placeholder="Ask anything",
|
||||
custom_disclaimer="Disclaimer",
|
||||
default_language="en-US",
|
||||
show_workflow_steps=False,
|
||||
use_icon_as_answer_icon=True,
|
||||
)
|
||||
|
||||
|
||||
def _tool(provider_type: str, provider_id: str, tool_name: str) -> dict[str, object]:
|
||||
return {
|
||||
"provider_type": provider_type,
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.model import Site
|
||||
from repositories.webapp_access_query_repository import WebAppAccessQueryRepository
|
||||
from services.webapp_access_query_service import WebAppAccessUnavailableError
|
||||
|
||||
_APP_ID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
|
||||
def test_find_app_id_by_code_returns_matching_site_app(sqlite_session_factory: sessionmaker[Session]) -> None:
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.add(
|
||||
Site(
|
||||
app_id=_APP_ID,
|
||||
code="site-code",
|
||||
title="Test Site",
|
||||
default_language="en-US",
|
||||
customize_token_strategy="uuid",
|
||||
)
|
||||
)
|
||||
|
||||
repository = WebAppAccessQueryRepository(session_factory=sqlite_session_factory)
|
||||
|
||||
assert repository.find_app_id_by_code("site-code") == _APP_ID
|
||||
|
||||
|
||||
def test_find_app_id_by_code_returns_none_for_missing_code(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
repository = WebAppAccessQueryRepository(session_factory=sqlite_session_factory)
|
||||
|
||||
assert repository.find_app_id_by_code("missing-code") is None
|
||||
|
||||
|
||||
def test_find_app_id_by_code_maps_database_failures_to_unavailable() -> None:
|
||||
database_error = OperationalError("select", {}, RuntimeError("connection failed"))
|
||||
session = MagicMock()
|
||||
session.__enter__.return_value.scalar.side_effect = database_error
|
||||
repository = WebAppAccessQueryRepository(session_factory=MagicMock(return_value=session))
|
||||
|
||||
with pytest.raises(WebAppAccessUnavailableError) as raised:
|
||||
repository.find_app_id_by_code("site-code")
|
||||
|
||||
assert raised.value.__cause__ is database_error
|
||||
|
||||
|
||||
def test_find_app_id_by_code_does_not_hide_unknown_errors() -> None:
|
||||
failure = TypeError("repository bug")
|
||||
session = MagicMock()
|
||||
session.__enter__.return_value.scalar.side_effect = failure
|
||||
repository = WebAppAccessQueryRepository(session_factory=MagicMock(return_value=session))
|
||||
|
||||
with pytest.raises(TypeError) as raised:
|
||||
repository.find_app_id_by_code("site-code")
|
||||
|
||||
assert raised.value is failure
|
||||
@ -44,24 +44,6 @@ def test_workflow_variant_rejects_agent_app_only_fields():
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_variant_accepts_agent_soul_files_section():
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": ComposerVariant.WORKFLOW,
|
||||
"save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY,
|
||||
"agent_soul": {
|
||||
"schema_version": 1,
|
||||
"prompt": {"system_prompt": "jjjj"},
|
||||
"files": {"skills": [], "files": []},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert payload.agent_soul is not None
|
||||
assert payload.agent_soul.files.skills == []
|
||||
assert payload.agent_soul.files.files == []
|
||||
|
||||
|
||||
def test_agent_app_variant_rejects_workflow_node_job():
|
||||
with pytest.raises(ValueError):
|
||||
ComposerSavePayload.model_validate(
|
||||
|
||||
@ -18,7 +18,7 @@ from models.agent import (
|
||||
WorkflowAgentBindingType,
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
|
||||
from models.agent_config_entities import AgentConfigFileRefConfig, AgentConfigSkillRefConfig, AgentSoulConfig
|
||||
from services.agent.dsl_entities import (
|
||||
AGENT_NODE_JOB_DSL_KEY,
|
||||
AGENT_PACKAGE_REF_KEY,
|
||||
@ -465,44 +465,41 @@ def test_import_workflow_packages_rejects_invalid_package_binding(binding: dict,
|
||||
)
|
||||
|
||||
|
||||
def test_clone_inline_binding_copies_soul_and_drive_rows(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_clone_inline_binding_copies_soul() -> None:
|
||||
session = Mock()
|
||||
service = AgentDslService(session)
|
||||
target_agent = SimpleNamespace(id="target-agent")
|
||||
target_snapshot = SimpleNamespace(id="target-snapshot")
|
||||
service._create_workflow_only_agent = Mock(return_value=(target_agent, target_snapshot))
|
||||
copy_rows = Mock()
|
||||
monkeypatch.setattr("services.agent.composer_service.AgentComposerService._copy_agent_drive_rows", copy_rows)
|
||||
source_agent = _agent()
|
||||
source_snapshot = SimpleNamespace(
|
||||
config_snapshot_dict=AgentSoulConfig(config_note="source").model_dump(mode="json")
|
||||
source_soul = AgentSoulConfig(
|
||||
config_note="source",
|
||||
config_skills=[AgentConfigSkillRefConfig(name="summarizer", file_id="skill-file-1")],
|
||||
config_files=[AgentConfigFileRefConfig(name="brief.pdf", file_kind="upload_file", file_id="config-file-1")],
|
||||
)
|
||||
source_snapshot = SimpleNamespace(config_snapshot_dict=source_soul.model_dump(mode="json"))
|
||||
workflow = SimpleNamespace(tenant_id="tenant-1", app_id="app-1", id="workflow-1")
|
||||
node_job = WorkflowNodeJobConfig(workflow_prompt="work")
|
||||
|
||||
result = service.clone_inline_binding_for_node(
|
||||
workflow=workflow,
|
||||
node_id="target-node",
|
||||
source_agent=source_agent,
|
||||
source_snapshot=source_snapshot,
|
||||
node_job=node_job,
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
assert result == (target_agent, target_snapshot)
|
||||
create_kwargs = service._create_workflow_only_agent.call_args.kwargs
|
||||
assert create_kwargs["metadata"].name == source_agent.name
|
||||
assert create_kwargs["soul"].config_note == "source"
|
||||
cloned_soul = create_kwargs["soul"]
|
||||
assert cloned_soul.config_note == "source"
|
||||
assert [(item.name, item.file_kind, item.file_id) for item in cloned_soul.config_skills] == [
|
||||
("summarizer", "tool_file", "skill-file-1")
|
||||
]
|
||||
assert [(item.name, item.file_kind, item.file_id) for item in cloned_soul.config_files] == [
|
||||
("brief.pdf", "upload_file", "config-file-1")
|
||||
]
|
||||
assert create_kwargs["source"] == AgentSource.WORKFLOW
|
||||
copy_rows.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
source_agent_id="agent-1",
|
||||
target_agent_id="target-agent",
|
||||
account_id="account-1",
|
||||
agent_soul=create_kwargs["soul"],
|
||||
node_job=node_job,
|
||||
session=session,
|
||||
)
|
||||
|
||||
|
||||
def test_extract_package_dependencies_covers_model_tools_and_knowledge(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
@ -0,0 +1,147 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.agent import (
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigVersionKind,
|
||||
AgentDebugConversation,
|
||||
AgentWorkspaceBinding,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import App, Conversation
|
||||
from services.agent.runtime_config_service import AgentRuntimeConfigService
|
||||
|
||||
|
||||
def _app() -> MagicMock:
|
||||
app = MagicMock(spec=App)
|
||||
app.id = "app-1"
|
||||
app.tenant_id = "tenant-1"
|
||||
return app
|
||||
|
||||
|
||||
def _conversation(*, binding_id: str | None = None) -> MagicMock:
|
||||
conversation = MagicMock(spec=Conversation)
|
||||
conversation.id = "conversation-1"
|
||||
conversation.agent_workspace_binding_id = binding_id
|
||||
return conversation
|
||||
|
||||
|
||||
def _soul(prompt: str) -> AgentSoulConfig:
|
||||
return AgentSoulConfig.model_validate(
|
||||
{
|
||||
"prompt": {"system_prompt": prompt},
|
||||
"app_features": {"suggested_questions_after_answer": {"enabled": True}},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _patch_published_soul(monkeypatch: pytest.MonkeyPatch, soul: AgentSoulConfig) -> MagicMock:
|
||||
roster_service = MagicMock()
|
||||
roster_service.return_value.get_published_agent_soul_for_app.return_value = soul
|
||||
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
|
||||
return roster_service
|
||||
|
||||
|
||||
def test_debug_without_mapping_falls_back_to_published_soul(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session = MagicMock(spec=Session)
|
||||
session.scalar.return_value = None
|
||||
published = _soul("published")
|
||||
roster_service = _patch_published_soul(monkeypatch, published)
|
||||
|
||||
result = AgentRuntimeConfigService(session).resolve_conversation_soul(
|
||||
app_model=_app(),
|
||||
conversation=_conversation(),
|
||||
account_id="account-1",
|
||||
use_debug_draft=True,
|
||||
)
|
||||
|
||||
assert result == published
|
||||
roster_service.return_value.get_published_agent_soul_for_app.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
)
|
||||
|
||||
|
||||
def test_debug_without_draft_falls_back_to_published_soul(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session = MagicMock(spec=Session)
|
||||
debug_conversation = MagicMock(spec=AgentDebugConversation)
|
||||
debug_conversation.agent_id = "agent-1"
|
||||
debug_conversation.draft_type = AgentConfigDraftType.DRAFT
|
||||
session.scalar.side_effect = [debug_conversation, None]
|
||||
published = _soul("published")
|
||||
_patch_published_soul(monkeypatch, published)
|
||||
|
||||
result = AgentRuntimeConfigService(session).resolve_conversation_soul(
|
||||
app_model=_app(),
|
||||
conversation=_conversation(),
|
||||
account_id="account-1",
|
||||
use_debug_draft=True,
|
||||
)
|
||||
|
||||
assert result == published
|
||||
|
||||
|
||||
def test_missing_binding_falls_back_to_published_soul(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session = MagicMock(spec=Session)
|
||||
session.scalar.return_value = None
|
||||
published = _soul("published")
|
||||
_patch_published_soul(monkeypatch, published)
|
||||
|
||||
result = AgentRuntimeConfigService(session).resolve_conversation_soul(
|
||||
app_model=_app(),
|
||||
conversation=_conversation(binding_id="binding-1"),
|
||||
account_id=None,
|
||||
use_debug_draft=False,
|
||||
)
|
||||
|
||||
assert result == published
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version_kind", [AgentConfigVersionKind.SNAPSHOT, AgentConfigVersionKind.DRAFT])
|
||||
def test_missing_bound_version_falls_back_to_published_soul(
|
||||
version_kind: AgentConfigVersionKind,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session = MagicMock(spec=Session)
|
||||
binding = MagicMock(spec=AgentWorkspaceBinding)
|
||||
binding.agent_id = "agent-1"
|
||||
binding.agent_config_version_id = "version-1"
|
||||
binding.agent_config_version_kind = version_kind
|
||||
session.scalar.side_effect = [binding, None]
|
||||
published = _soul("published")
|
||||
_patch_published_soul(monkeypatch, published)
|
||||
|
||||
result = AgentRuntimeConfigService(session).resolve_conversation_soul(
|
||||
app_model=_app(),
|
||||
conversation=_conversation(binding_id="binding-1"),
|
||||
account_id=None,
|
||||
use_debug_draft=False,
|
||||
)
|
||||
|
||||
assert result == published
|
||||
|
||||
|
||||
def test_bound_draft_returns_its_soul(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session = MagicMock(spec=Session)
|
||||
binding = MagicMock(spec=AgentWorkspaceBinding)
|
||||
binding.agent_id = "agent-1"
|
||||
binding.agent_config_version_id = "draft-1"
|
||||
binding.agent_config_version_kind = AgentConfigVersionKind.DRAFT
|
||||
draft = MagicMock(spec=AgentConfigDraft)
|
||||
bound = _soul("bound draft")
|
||||
draft.config_snapshot_dict = bound.model_dump(mode="json")
|
||||
session.scalar.side_effect = [binding, draft]
|
||||
roster_service = _patch_published_soul(monkeypatch, _soul("published"))
|
||||
|
||||
result = AgentRuntimeConfigService(session).resolve_conversation_soul(
|
||||
app_model=_app(),
|
||||
conversation=_conversation(binding_id="binding-1"),
|
||||
account_id=None,
|
||||
use_debug_draft=False,
|
||||
)
|
||||
|
||||
assert result == bound
|
||||
roster_service.assert_not_called()
|
||||
@ -20,8 +20,6 @@ from models.agent import (
|
||||
AgentConfigSnapshot,
|
||||
AgentConfigVersionKind,
|
||||
AgentDebugConversation,
|
||||
AgentDriveFile,
|
||||
AgentDriveFileKind,
|
||||
AgentHomeSnapshot,
|
||||
AgentKind,
|
||||
AgentScope,
|
||||
@ -2379,7 +2377,6 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk
|
||||
scope=AgentScope.WORKFLOW_ONLY,
|
||||
)
|
||||
create_roster_calls = []
|
||||
copy_drive_calls = []
|
||||
monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", lambda **kwargs: workflow_agent)
|
||||
|
||||
def fake_create_roster_agent_for_composer(**kwargs):
|
||||
@ -2391,11 +2388,6 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk
|
||||
"_create_roster_agent_for_composer",
|
||||
fake_create_roster_agent_for_composer,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_copy_agent_drive_rows",
|
||||
lambda **kwargs: copy_drive_calls.append(kwargs),
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
@ -2496,17 +2488,6 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk
|
||||
assert create_roster_calls[1]["role"] == "Copied role"
|
||||
assert create_roster_calls[1]["icon"] == "copied"
|
||||
assert create_roster_calls[1]["icon_background"] == "#E0F2FE"
|
||||
copy_drive_calls[0].pop("session", None)
|
||||
assert copy_drive_calls == [
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"source_agent_id": "roster-agent-1",
|
||||
"target_agent_id": "roster-agent-1",
|
||||
"account_id": "account-1",
|
||||
"agent_soul": payload.agent_soul,
|
||||
"node_job": payload.node_job,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
|
||||
@ -2914,11 +2895,7 @@ def test_copy_workflow_composer_from_roster_creates_inline_agent_and_preserves_n
|
||||
captured["create"] = kwargs
|
||||
return inline_agent
|
||||
|
||||
def fake_copy_drive_rows(**kwargs):
|
||||
captured["drive"] = kwargs
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", fake_create_workflow_only_agent)
|
||||
monkeypatch.setattr(AgentComposerService, "_copy_agent_drive_rows", fake_copy_drive_rows)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_serialize_workflow_state",
|
||||
@ -2950,9 +2927,6 @@ def test_copy_workflow_composer_from_roster_creates_inline_agent_and_preserves_n
|
||||
assert create_kwargs["agent_soul"].prompt.system_prompt == "copy me"
|
||||
assert create_kwargs["name"] == "Nadia"
|
||||
assert create_kwargs["role"] == "Clarifies tenders"
|
||||
drive_kwargs = captured["drive"]
|
||||
assert drive_kwargs["source_agent_id"] == "roster-agent-1"
|
||||
assert drive_kwargs["target_agent_id"] == "inline-agent-1"
|
||||
|
||||
|
||||
def test_copy_workflow_composer_from_roster_rejects_stale_source_snapshot(
|
||||
@ -3196,191 +3170,6 @@ def test_copy_workflow_composer_from_roster_rejects_invalid_source_binding(
|
||||
)
|
||||
|
||||
|
||||
def test_copy_agent_drive_rows_copies_skill_prefix_and_files(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
|
||||
session = sqlite_session
|
||||
skill_row = AgentDriveFile(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="roster-agent-1",
|
||||
key="tender-analyzer/SKILL.md",
|
||||
file_kind="tool_file",
|
||||
file_id="tool-file-1",
|
||||
value_owned_by_drive=True,
|
||||
is_skill=True,
|
||||
skill_metadata='{"name":"Tender Analyzer"}',
|
||||
size=10,
|
||||
mime_type="text/markdown",
|
||||
)
|
||||
script_row = AgentDriveFile(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="roster-agent-1",
|
||||
key="tender-analyzer/scripts/run.sh",
|
||||
file_kind="tool_file",
|
||||
file_id="tool-file-2",
|
||||
value_owned_by_drive=True,
|
||||
size=20,
|
||||
mime_type="text/x-shellscript",
|
||||
)
|
||||
file_row = AgentDriveFile(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="roster-agent-1",
|
||||
key="files/qna.pdf",
|
||||
file_kind="upload_file",
|
||||
file_id="upload-file-1",
|
||||
value_owned_by_drive=False,
|
||||
size=30,
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
session.add_all([skill_row, script_row, file_row])
|
||||
session.commit()
|
||||
agent_soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"prompt": {
|
||||
"system_prompt": "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]",
|
||||
},
|
||||
}
|
||||
)
|
||||
node_job = WorkflowNodeJobConfig.model_validate(
|
||||
{"metadata": {"file_refs": [{"name": "qna.pdf", "drive_key": "files/qna.pdf"}]}}
|
||||
)
|
||||
|
||||
AgentComposerService._copy_agent_drive_rows(
|
||||
session=session,
|
||||
tenant_id="tenant-1",
|
||||
source_agent_id="roster-agent-1",
|
||||
target_agent_id="inline-agent-1",
|
||||
account_id="account-1",
|
||||
agent_soul=agent_soul,
|
||||
node_job=node_job,
|
||||
)
|
||||
|
||||
session.flush()
|
||||
copied = list(
|
||||
session.scalars(
|
||||
select(AgentDriveFile).where(
|
||||
AgentDriveFile.tenant_id == "tenant-1",
|
||||
AgentDriveFile.agent_id == "inline-agent-1",
|
||||
)
|
||||
)
|
||||
)
|
||||
assert {row.key for row in copied} == {
|
||||
"tender-analyzer/SKILL.md",
|
||||
"tender-analyzer/scripts/run.sh",
|
||||
"files/qna.pdf",
|
||||
}
|
||||
assert {row.agent_id for row in copied} == {"inline-agent-1"}
|
||||
copied_by_key = {row.key: row for row in copied}
|
||||
assert copied_by_key["tender-analyzer/SKILL.md"].file_id == "tool-file-1"
|
||||
assert copied_by_key["tender-analyzer/SKILL.md"].is_skill is True
|
||||
assert copied_by_key["files/qna.pdf"].value_owned_by_drive is False
|
||||
|
||||
|
||||
def test_copy_agent_drive_rows_skips_when_no_referenced_drive_keys(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
):
|
||||
session = sqlite_session
|
||||
agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "No drive mentions."}})
|
||||
|
||||
AgentComposerService._copy_agent_drive_rows(
|
||||
session=session,
|
||||
tenant_id="tenant-1",
|
||||
source_agent_id="roster-agent-1",
|
||||
target_agent_id="inline-agent-1",
|
||||
account_id="account-1",
|
||||
agent_soul=agent_soul,
|
||||
)
|
||||
|
||||
assert not session.new
|
||||
|
||||
|
||||
def test_copy_agent_drive_rows_skips_existing_target_keys(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
|
||||
session = sqlite_session
|
||||
source_row = AgentDriveFile(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="roster-agent-1",
|
||||
key="files/qna.pdf",
|
||||
file_kind="upload_file",
|
||||
file_id="upload-file-1",
|
||||
value_owned_by_drive=False,
|
||||
size=30,
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
target_row = AgentDriveFile(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="inline-agent-1",
|
||||
key=source_row.key,
|
||||
file_kind=source_row.file_kind,
|
||||
file_id=source_row.file_id,
|
||||
value_owned_by_drive=source_row.value_owned_by_drive,
|
||||
size=source_row.size,
|
||||
mime_type=source_row.mime_type,
|
||||
)
|
||||
session.add_all([source_row, target_row])
|
||||
session.commit()
|
||||
agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "[§file:files/qna.pdf:qna.pdf§]"}})
|
||||
|
||||
AgentComposerService._copy_agent_drive_rows(
|
||||
session=session,
|
||||
tenant_id="tenant-1",
|
||||
source_agent_id="roster-agent-1",
|
||||
target_agent_id="inline-agent-1",
|
||||
account_id="account-1",
|
||||
agent_soul=agent_soul,
|
||||
)
|
||||
|
||||
session.flush()
|
||||
target_rows = list(
|
||||
session.scalars(
|
||||
select(AgentDriveFile).where(
|
||||
AgentDriveFile.tenant_id == "tenant-1",
|
||||
AgentDriveFile.agent_id == "inline-agent-1",
|
||||
)
|
||||
)
|
||||
)
|
||||
assert [row.key for row in target_rows] == ["files/qna.pdf"]
|
||||
|
||||
|
||||
def test_drive_copy_scopes_include_declared_output_benchmark_files():
|
||||
agent_soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"prompt": {
|
||||
"system_prompt": (
|
||||
"[§file:files/source.pdf:source.pdf§] "
|
||||
"[§knowledge:dataset-1:Docs§] "
|
||||
"[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]"
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
node_job = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"declared_outputs": [
|
||||
{
|
||||
"name": "qna_report",
|
||||
"type": "file",
|
||||
"check": {
|
||||
"enabled": True,
|
||||
"prompt": "Compare the generated file with the benchmark.",
|
||||
"benchmark_file_ref": {"name": "expected.pdf", "drive_key": "files/expected.pdf"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "string",
|
||||
"check": {"enabled": False, "benchmark_file_ref": {"drive_key": "files/ignored.pdf"}},
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
exact_keys, prefixes = AgentComposerService._drive_copy_scopes_from_agent_configs(
|
||||
agent_soul=agent_soul,
|
||||
node_job=node_job,
|
||||
)
|
||||
|
||||
assert exact_keys == {"files/source.pdf", "files/expected.pdf"}
|
||||
assert prefixes == {"tender-analyzer/"}
|
||||
|
||||
|
||||
def test_composer_create_agents_syncs_active_config_has_model(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
@ -5835,7 +5624,7 @@ class TestWorkflowAgentDraftBindingSync:
|
||||
draft_workflow=self._agent_workflow(),
|
||||
)
|
||||
|
||||
def test_publish_validation_rejects_dangling_agent_soul_drive_refs(self, sqlite_session: Session):
|
||||
def test_publish_validation_rejects_dangling_agent_soul_config_refs(self, sqlite_session: Session):
|
||||
session = sqlite_session
|
||||
binding = self._agent_binding()
|
||||
agent_soul = AgentSoulConfig.model_validate(
|
||||
@ -5845,7 +5634,7 @@ class TestWorkflowAgentDraftBindingSync:
|
||||
"model_provider": "openai",
|
||||
"model": "gpt-4o",
|
||||
},
|
||||
"prompt": {"system_prompt": "Use [§skill:research%2FSKILL.md:Research§]."},
|
||||
"prompt": {"system_prompt": "Use [§skill:research:Research§]."},
|
||||
}
|
||||
)
|
||||
agent = self._publish_agent()
|
||||
@ -7045,135 +6834,6 @@ def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatc
|
||||
assert {entry["granularity"] for entry in entries[1:]} == {"tool"}
|
||||
|
||||
|
||||
# ── ENG-623 §4.4: drive-backed prompt mention validation ─────────────────────
|
||||
|
||||
|
||||
def _drive_soul(**overrides):
|
||||
from services.entities.agent_entities import AgentSoulConfig
|
||||
|
||||
base = {
|
||||
"prompt": {
|
||||
"system_prompt": (
|
||||
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]."
|
||||
)
|
||||
},
|
||||
}
|
||||
base.update(overrides)
|
||||
return AgentSoulConfig.model_validate(base)
|
||||
|
||||
|
||||
def _session_with_drive_keys(sqlite_session: Session, existing_keys: list[str]) -> Session:
|
||||
session = sqlite_session
|
||||
session.add_all(
|
||||
[
|
||||
AgentDriveFile(
|
||||
id=f"drive-file-{index}",
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
key=key,
|
||||
file_kind=AgentDriveFileKind.UPLOAD_FILE,
|
||||
file_id=f"upload-{index}",
|
||||
)
|
||||
for index, key in enumerate(existing_keys, start=1)
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
return session
|
||||
|
||||
|
||||
def test_drive_mention_findings_reports_missing_keys(sqlite_session: Session):
|
||||
session = _session_with_drive_keys(sqlite_session, ["tender-analyzer/SKILL.md"])
|
||||
|
||||
findings = AgentComposerService._drive_mention_findings(
|
||||
session=session,
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
prompt=_drive_soul().prompt.system_prompt,
|
||||
)
|
||||
|
||||
assert [(f["code"], f["id"]) for f in findings] == [("mention_target_missing", "files/sample.pdf")]
|
||||
assert findings[0]["kind"] == "file"
|
||||
assert str(findings[0]["message"]).startswith("file 'sample.pdf' has no drive entry")
|
||||
|
||||
|
||||
def test_drive_mention_findings_clean_when_all_keys_exist(sqlite_session: Session):
|
||||
session = _session_with_drive_keys(
|
||||
sqlite_session,
|
||||
["tender-analyzer/SKILL.md", "files/sample.pdf"],
|
||||
)
|
||||
|
||||
assert (
|
||||
AgentComposerService._drive_mention_findings(
|
||||
session=session,
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
prompt=_drive_soul().prompt.system_prompt,
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_drive_mention_findings_skips_prompt_without_drive_mentions(sqlite_session: Session):
|
||||
session = sqlite_session
|
||||
# No drive-backed mention at all -> no DB roundtrip, no findings.
|
||||
soul = _drive_soul(prompt={"system_prompt": "Use [§knowledge:kb-1:Docs§]."})
|
||||
findings = AgentComposerService._drive_mention_findings(
|
||||
session=session,
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
prompt=soul.prompt.system_prompt,
|
||||
)
|
||||
assert findings == []
|
||||
|
||||
|
||||
def test_collect_validation_findings_appends_drive_mention_findings_with_agent_context(
|
||||
sqlite_session: Session,
|
||||
):
|
||||
from services.entities.agent_entities import ComposerSavePayload
|
||||
|
||||
session = _session_with_drive_keys(sqlite_session, [])
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "agent_app",
|
||||
"save_strategy": "save_to_current_version",
|
||||
"agent_soul": _drive_soul().model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
session=session, tenant_id="tenant-1", payload=payload, agent_id="agent-1"
|
||||
)
|
||||
|
||||
codes = {w["code"] for w in findings["warnings"]}
|
||||
assert codes >= {"mention_target_missing"}
|
||||
assert {w["id"] for w in findings["warnings"] if w["code"] == "mention_target_missing"} == {
|
||||
"tender-analyzer/SKILL.md",
|
||||
"files/sample.pdf",
|
||||
}
|
||||
# without agent context the drive check is skipped entirely
|
||||
findings_no_agent = AgentComposerService.collect_validation_findings(
|
||||
session=session, tenant_id="tenant-1", payload=payload
|
||||
)
|
||||
assert all(w["code"] != "mention_target_missing" for w in findings_no_agent["warnings"])
|
||||
|
||||
|
||||
# ── ENG-623/625: resolver helpers + save-path drive guard ────────────────────
|
||||
|
||||
|
||||
def test_resolve_bound_agent_id_queries_active_roster_agent(sqlite_session: Session):
|
||||
session = sqlite_session
|
||||
session.add(
|
||||
_agent(
|
||||
agent_id="agent-9",
|
||||
tenant_id="t-1",
|
||||
source=AgentSource.ROSTER,
|
||||
app_id="app-1",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
assert AgentComposerService.resolve_bound_agent_id(session=session, tenant_id="t-1", app_id="app-1") == "agent-9"
|
||||
|
||||
|
||||
def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
):
|
||||
@ -7207,129 +6867,3 @@ def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(
|
||||
AgentComposerService.resolve_workflow_node_agent_id(session=session, tenant_id="t", app_id="a", node_id="n")
|
||||
== "agent-7"
|
||||
)
|
||||
|
||||
|
||||
def test_save_workflow_composer_reports_drive_mentions_for_inline_node_job_only(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
):
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "workflow",
|
||||
"save_strategy": "node_job_only",
|
||||
"agent_soul": _drive_soul().model_dump(mode="json"),
|
||||
"soul_lock": {"locked": False},
|
||||
}
|
||||
)
|
||||
binding = WorkflowAgentNodeBinding(
|
||||
tenant_id="t-1",
|
||||
app_id="app-1",
|
||||
workflow_id="wf-1",
|
||||
workflow_version="draft",
|
||||
node_id="n-1",
|
||||
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
|
||||
agent_id="agent-1",
|
||||
current_snapshot_id="version-1",
|
||||
)
|
||||
session = sqlite_session
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding))
|
||||
monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding))
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_get_agent_if_present",
|
||||
classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_get_version_if_present",
|
||||
classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"})
|
||||
)
|
||||
guarded: dict[str, str] = {}
|
||||
|
||||
def fake_collect(cls, *, session, tenant_id, payload, agent_id=None):
|
||||
guarded["tenant_id"] = tenant_id
|
||||
guarded["agent_id"] = agent_id
|
||||
return {"warnings": [{"code": "mention_target_missing", "id": "files/sample.pdf"}]}
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "collect_validation_findings", classmethod(fake_collect))
|
||||
|
||||
result = AgentComposerService.save_workflow_composer(
|
||||
session=session,
|
||||
tenant_id="t-1",
|
||||
app_id="app-1",
|
||||
node_id="n-1",
|
||||
account_id="acc-1",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"state": "ok",
|
||||
"validation": {"warnings": [{"code": "mention_target_missing", "id": "files/sample.pdf"}]},
|
||||
}
|
||||
assert guarded == {"tenant_id": "t-1", "agent_id": "agent-1"}
|
||||
|
||||
|
||||
def test_save_workflow_composer_reports_drive_mentions_for_roster_node_job_only(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
):
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "workflow",
|
||||
"save_strategy": "node_job_only",
|
||||
"agent_soul": _drive_soul().model_dump(mode="json"),
|
||||
"soul_lock": {"locked": False},
|
||||
}
|
||||
)
|
||||
binding = WorkflowAgentNodeBinding(
|
||||
tenant_id="t-1",
|
||||
app_id="app-1",
|
||||
workflow_id="wf-1",
|
||||
workflow_version="draft",
|
||||
node_id="n-1",
|
||||
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
|
||||
agent_id="agent-1",
|
||||
current_snapshot_id="version-1",
|
||||
)
|
||||
session = sqlite_session
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding))
|
||||
monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding))
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_get_agent_if_present",
|
||||
classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_get_version_if_present",
|
||||
classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"})
|
||||
)
|
||||
captured: dict[str, str | None] = {}
|
||||
|
||||
def fake_collect(cls, *, session, tenant_id, payload, agent_id=None):
|
||||
captured["agent_id"] = agent_id
|
||||
return {"warnings": []}
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "collect_validation_findings", classmethod(fake_collect))
|
||||
|
||||
result = AgentComposerService.save_workflow_composer(
|
||||
session=session,
|
||||
tenant_id="t-1",
|
||||
app_id="app-1",
|
||||
node_id="n-1",
|
||||
account_id="acc-1",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
assert result == {"state": "ok", "validation": {"warnings": []}}
|
||||
assert captured["agent_id"] == "agent-1"
|
||||
|
||||
@ -7,8 +7,6 @@ guarantees no mention-shaped marker survives to the model.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
|
||||
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig, WorkflowPreviousNodeOutputRef
|
||||
@ -65,12 +63,6 @@ def test_parse_skips_oversized_id_or_label():
|
||||
assert parse_prompt_mentions(f"[§skill:{long_id}§]") == []
|
||||
|
||||
|
||||
def test_parse_accepts_long_unicode_encoded_drive_key_within_drive_limit():
|
||||
encoded_drive_key = quote("你" * 512)
|
||||
mentions = parse_prompt_mentions(f"[§skill:{encoded_drive_key}:Long Skill§]")
|
||||
assert [(mention.kind, mention.ref_id) for mention in mentions] == [(MentionKind.SKILL, encoded_drive_key)]
|
||||
|
||||
|
||||
# ── expand + scrub ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@ -1,140 +0,0 @@
|
||||
"""Unit tests for Skill standardization into the agent drive (ENG-594)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.agent import Agent, AgentDriveFile, AgentDriveFileKind, AgentScope, AgentSource
|
||||
from models.tools import ToolFile
|
||||
from services.agent.skill_standardize_service import SkillStandardizeService, slugify_skill_name
|
||||
from services.agent_drive_service import DriveSkillMetadata
|
||||
|
||||
_TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
_AGENT_ID = "22222222-2222-2222-2222-222222222222"
|
||||
_USER_ID = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
_SKILL_MD = b"""---
|
||||
name: PDF Toolkit
|
||||
description: Work with PDFs.
|
||||
---
|
||||
|
||||
# PDF Toolkit
|
||||
"""
|
||||
|
||||
|
||||
def _zip(members: dict[str, bytes]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
for name, data in members.items():
|
||||
archive.writestr(name, data)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def test_slugify_skill_name():
|
||||
assert slugify_skill_name("PDF Toolkit") == "pdf-toolkit"
|
||||
assert slugify_skill_name(" Weird/Name!! ") == "weird-name"
|
||||
assert slugify_skill_name("") == "skill"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Agent, ToolFile, AgentDriveFile)], indirect=True)
|
||||
def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_manifest(sqlite_session: Session):
|
||||
content = _zip({"pdf-toolkit/SKILL.md": _SKILL_MD, "pdf-toolkit/scripts/run.py": b"print('x')\n"})
|
||||
|
||||
agent = Agent(
|
||||
id=_AGENT_ID,
|
||||
tenant_id=_TENANT_ID,
|
||||
name="Drive Agent",
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
)
|
||||
md_tool_file = ToolFile(
|
||||
user_id=_USER_ID,
|
||||
tenant_id=_TENANT_ID,
|
||||
conversation_id=None,
|
||||
file_key="tools/skill-md",
|
||||
mimetype="text/markdown",
|
||||
name="SKILL.md",
|
||||
size=len(_SKILL_MD),
|
||||
)
|
||||
archive_tool_file = ToolFile(
|
||||
user_id=_USER_ID,
|
||||
tenant_id=_TENANT_ID,
|
||||
conversation_id=None,
|
||||
file_key="tools/skill-archive",
|
||||
mimetype="application/zip",
|
||||
name=".DIFY-SKILL-FULL.zip",
|
||||
size=len(content),
|
||||
)
|
||||
sqlite_session.add_all([agent, md_tool_file, archive_tool_file])
|
||||
sqlite_session.commit()
|
||||
|
||||
tool_files = MagicMock()
|
||||
tool_files.create_file_by_raw.side_effect = [md_tool_file, archive_tool_file]
|
||||
|
||||
service = SkillStandardizeService(tool_file_manager=tool_files)
|
||||
result = service.standardize(
|
||||
content=content,
|
||||
filename="skill.zip",
|
||||
tenant_id=_TENANT_ID,
|
||||
user_id=_USER_ID,
|
||||
agent_id=_AGENT_ID,
|
||||
session=sqlite_session,
|
||||
)
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
# ToolFiles: SKILL.md and the full archive. Archive members stay lazy.
|
||||
assert tool_files.create_file_by_raw.call_count == 2
|
||||
md_call, zip_call = tool_files.create_file_by_raw.call_args_list
|
||||
assert md_call.kwargs["mimetype"] == "text/markdown"
|
||||
assert md_call.kwargs["file_binary"] == _SKILL_MD
|
||||
assert zip_call.kwargs["mimetype"] == "application/zip"
|
||||
assert zip_call.kwargs["file_binary"] != content
|
||||
with zipfile.ZipFile(io.BytesIO(zip_call.kwargs["file_binary"])) as archive:
|
||||
assert sorted(info.filename for info in archive.infolist() if not info.is_dir()) == [
|
||||
"SKILL.md",
|
||||
"scripts/run.py",
|
||||
]
|
||||
|
||||
# Committed as drive-owned with the standardized keys. Member paths are
|
||||
# carried in metadata for inspect/preview/runtime lazy resolution.
|
||||
rows = {
|
||||
row.key: row
|
||||
for row in sqlite_session.scalars(
|
||||
select(AgentDriveFile).where(
|
||||
AgentDriveFile.tenant_id == _TENANT_ID,
|
||||
AgentDriveFile.agent_id == _AGENT_ID,
|
||||
)
|
||||
)
|
||||
}
|
||||
assert set(rows) == {"pdf-toolkit/SKILL.md", "pdf-toolkit/.DIFY-SKILL-FULL.zip"}
|
||||
skill_row = rows["pdf-toolkit/SKILL.md"]
|
||||
archive_row = rows["pdf-toolkit/.DIFY-SKILL-FULL.zip"]
|
||||
assert skill_row.file_kind == AgentDriveFileKind.TOOL_FILE
|
||||
assert skill_row.file_id == md_tool_file.id
|
||||
assert skill_row.value_owned_by_drive is True
|
||||
assert skill_row.is_skill is True
|
||||
assert skill_row.skill_metadata is not None
|
||||
skill_metadata = DriveSkillMetadata.model_validate_json(skill_row.skill_metadata)
|
||||
assert skill_metadata.name == "PDF Toolkit"
|
||||
assert skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"]
|
||||
assert archive_row.file_kind == AgentDriveFileKind.TOOL_FILE
|
||||
assert archive_row.file_id == archive_tool_file.id
|
||||
assert archive_row.value_owned_by_drive is True
|
||||
assert archive_row.is_skill is False
|
||||
assert len(service.last_committed_items) == 2
|
||||
|
||||
# The returned upload response carries only the drive-derived fields the UI needs.
|
||||
skill = result["skill"]
|
||||
assert skill["path"] == "pdf-toolkit"
|
||||
assert skill["name"] == "PDF Toolkit"
|
||||
assert skill["archive_key"] == "pdf-toolkit/.DIFY-SKILL-FULL.zip"
|
||||
assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md"
|
||||
assert result["manifest"]["entry_path"] == "SKILL.md"
|
||||
assert result["manifest"]["files"] == ["SKILL.md", "scripts/run.py"]
|
||||
assert "_committed_items" not in result
|
||||
@ -1,188 +0,0 @@
|
||||
"""Unit tests for skill → CLI tool inference (ENG-371)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from services.agent.skill_tool_inference_service import (
|
||||
SkillToolInferenceError,
|
||||
SkillToolInferenceService,
|
||||
)
|
||||
from services.agent_drive_service import AgentDriveError
|
||||
|
||||
_MOD = "services.agent.skill_tool_inference_service"
|
||||
|
||||
_SKILL_MD_PREVIEW = {
|
||||
"key": "audio-transcribe/SKILL.md",
|
||||
"size": 100,
|
||||
"truncated": False,
|
||||
"binary": False,
|
||||
"text": "# Audio Transcribe\nStep 2 runs ffmpeg, step 3 calls the whisper API.",
|
||||
}
|
||||
|
||||
|
||||
def _service(preview=_SKILL_MD_PREVIEW):
|
||||
drive = MagicMock()
|
||||
drive.preview.return_value = preview
|
||||
return SkillToolInferenceService(drive_service=drive), drive
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_infer_returns_suggestions_with_inferred_from(monkeypatch, sqlite_session: Session):
|
||||
service, drive = _service()
|
||||
raw = (
|
||||
'{"inferable": true, "reason": null, "cli_tools": [{"name": "ffmpeg",'
|
||||
' "description": "transcoding for step 2", "command": "ffmpeg",'
|
||||
' "install_commands": ["apt-get install -y ffmpeg"],'
|
||||
' "env_suggestions": [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": true}]}]}'
|
||||
)
|
||||
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
|
||||
result = service.infer(
|
||||
tenant_id="t-1",
|
||||
agent_id="a-1",
|
||||
slug="audio-transcribe",
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result["inferable"] is True
|
||||
tool = result["cli_tools"][0]
|
||||
assert tool["name"] == "ffmpeg"
|
||||
assert tool["inferred_from"] == "audio-transcribe"
|
||||
assert tool["env_suggestions"] == [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": True}]
|
||||
drive.preview.assert_called_once_with(
|
||||
tenant_id="t-1", agent_id="a-1", key="audio-transcribe/SKILL.md", session=sqlite_session
|
||||
)
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_infer_threads_skill_md_into_the_prompt(monkeypatch, sqlite_session: Session):
|
||||
service, _ = _service()
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def fake_invoke(*, tenant_id, user_prompt):
|
||||
captured["prompt"] = user_prompt
|
||||
return '{"inferable": false, "cli_tools": [], "reason": "none"}'
|
||||
|
||||
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(fake_invoke)):
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
|
||||
|
||||
assert "Files inside the skill package" not in captured["prompt"]
|
||||
assert "ffmpeg" in captured["prompt"] # SKILL.md body present
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_infer_not_inferable_passes_reason_through(monkeypatch, sqlite_session: Session):
|
||||
service, _ = _service()
|
||||
raw = '{"inferable": false, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}'
|
||||
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
|
||||
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
|
||||
assert result == {"inferable": False, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_infer_retries_once_then_422(monkeypatch, sqlite_session: Session):
|
||||
service, _ = _service()
|
||||
calls: list[int] = []
|
||||
|
||||
def bad_invoke(**kwargs):
|
||||
calls.append(1)
|
||||
return "not json at all ]["
|
||||
|
||||
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(bad_invoke)):
|
||||
with pytest.raises(SkillToolInferenceError) as exc_info:
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
|
||||
|
||||
assert len(calls) == 2 # one retry
|
||||
assert exc_info.value.code == "inference_failed"
|
||||
assert exc_info.value.status_code == 422
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_infer_repairs_slightly_malformed_json(monkeypatch, sqlite_session: Session):
|
||||
service, _ = _service()
|
||||
raw = 'Here you go: {"inferable": true, "cli_tools": [], "reason": null,}'
|
||||
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
|
||||
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
|
||||
assert result["inferable"] is True
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_missing_skill_maps_to_404(sqlite_session: Session):
|
||||
drive = MagicMock()
|
||||
drive.preview.side_effect = AgentDriveError("drive_key_not_found", "nope", status_code=404)
|
||||
service = SkillToolInferenceService(drive_service=drive)
|
||||
|
||||
with pytest.raises(SkillToolInferenceError) as exc_info:
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="ghost", session=sqlite_session)
|
||||
assert exc_info.value.code == "skill_not_found"
|
||||
assert exc_info.value.status_code == 404
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_binary_skill_md_maps_to_404(sqlite_session: Session):
|
||||
service, _ = _service(preview={"key": "x/SKILL.md", "size": 1, "truncated": False, "binary": True, "text": None})
|
||||
with pytest.raises(SkillToolInferenceError) as exc_info:
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="x", session=sqlite_session)
|
||||
assert exc_info.value.code == "skill_not_found"
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
# ── real-path coverage: _invoke / passthrough ────────────────────────────────
|
||||
|
||||
|
||||
def test_invoke_maps_missing_default_model_to_400(monkeypatch: pytest.MonkeyPatch):
|
||||
import services.agent.skill_tool_inference_service as module
|
||||
from core.errors.error import ProviderTokenNotInitError
|
||||
|
||||
fake_manager = MagicMock()
|
||||
fake_manager.get_default_model_instance.side_effect = ProviderTokenNotInitError("no default")
|
||||
monkeypatch.setattr(module.ModelManager, "for_tenant", classmethod(lambda cls, tenant_id: fake_manager))
|
||||
|
||||
with pytest.raises(SkillToolInferenceError) as exc_info:
|
||||
SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x")
|
||||
assert exc_info.value.code == "default_model_not_configured"
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def test_invoke_maps_model_failure_to_422_and_success_returns_text(monkeypatch: pytest.MonkeyPatch):
|
||||
import services.agent.skill_tool_inference_service as module
|
||||
|
||||
fake_manager = MagicMock()
|
||||
fake_instance = MagicMock()
|
||||
fake_manager.get_default_model_instance.return_value = fake_instance
|
||||
monkeypatch.setattr(module.ModelManager, "for_tenant", classmethod(lambda cls, tenant_id: fake_manager))
|
||||
|
||||
fake_instance.invoke_llm.side_effect = RuntimeError("provider down")
|
||||
with pytest.raises(SkillToolInferenceError) as exc_info:
|
||||
SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x")
|
||||
assert exc_info.value.code == "inference_failed"
|
||||
assert exc_info.value.status_code == 422
|
||||
|
||||
fake_instance.invoke_llm.side_effect = None
|
||||
fake_instance.invoke_llm.return_value.message.get_text_content.return_value = '{"inferable": false}'
|
||||
raw = SkillToolInferenceService._invoke(tenant_id="t-1", user_prompt="x")
|
||||
assert raw == '{"inferable": false}'
|
||||
call = fake_instance.invoke_llm.call_args.kwargs
|
||||
assert call["model_parameters"] == {"temperature": 0.1}
|
||||
assert call["stream"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_load_skill_md_passes_through_non_missing_drive_errors(sqlite_session: Session):
|
||||
drive = MagicMock()
|
||||
drive.preview.side_effect = AgentDriveError("agent_not_found", "tenant mismatch", status_code=404)
|
||||
service = SkillToolInferenceService(drive_service=drive)
|
||||
|
||||
with pytest.raises(SkillToolInferenceError) as exc_info:
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="x", session=sqlite_session)
|
||||
assert exc_info.value.code == "agent_not_found"
|
||||
assert not sqlite_session.in_transaction()
|
||||
@ -6,7 +6,6 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.agent import Agent, WorkflowAgentBindingType, WorkflowAgentNodeBinding
|
||||
from models.agent_config_entities import WorkflowNodeJobConfig
|
||||
from models.enums import AppStatus
|
||||
from models.model import App, AppMode
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
@ -326,7 +325,6 @@ def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.M
|
||||
target_snapshot = SimpleNamespace(id="target-snapshot")
|
||||
clone = Mock(return_value=(target_agent, target_snapshot))
|
||||
monkeypatch.setattr(AgentDslService, "clone_inline_binding_for_node", clone)
|
||||
node_job = WorkflowNodeJobConfig(workflow_prompt="work")
|
||||
|
||||
result = WorkflowAgentPublishService._clone_inline_graph_binding_for_node(
|
||||
session=session,
|
||||
@ -334,7 +332,6 @@ def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.M
|
||||
node_id="target-node",
|
||||
source_agent_id="source-agent",
|
||||
source_snapshot_id="source-snapshot",
|
||||
node_job=node_job,
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
@ -344,7 +341,6 @@ def test_clone_inline_graph_binding_for_node_clones_source(monkeypatch: pytest.M
|
||||
node_id="target-node",
|
||||
source_agent=source_agent,
|
||||
source_snapshot=source_snapshot,
|
||||
node_job=node_job,
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
@ -361,7 +357,6 @@ def test_clone_inline_graph_binding_for_node_rejects_missing_source(scalar_resul
|
||||
node_id="target-node",
|
||||
source_agent_id="source-agent",
|
||||
source_snapshot_id="source-snapshot",
|
||||
node_job=WorkflowNodeJobConfig(),
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
|
||||
@ -23,7 +23,12 @@ from services.enterprise.enterprise_service import (
|
||||
try_join_default_workspace,
|
||||
)
|
||||
from services.entities.feature_entities import LicenseStatus
|
||||
from services.errors.enterprise import EnterpriseAPIError, EnterpriseAPIForbiddenError, EnterpriseAPIUnauthorizedError
|
||||
from services.errors.enterprise import (
|
||||
EnterpriseAPIError,
|
||||
EnterpriseAPIForbiddenError,
|
||||
EnterpriseAPIUnauthorizedError,
|
||||
EnterpriseServiceError,
|
||||
)
|
||||
|
||||
MODULE = "services.enterprise.enterprise_service"
|
||||
|
||||
@ -147,6 +152,12 @@ class TestWebAppAuth:
|
||||
assert isinstance(result, WebAppSettings)
|
||||
assert result.access_mode == "public"
|
||||
|
||||
def test_get_app_access_mode_raises_service_error_on_empty_response(self):
|
||||
with patch(f"{MODULE}.EnterpriseRequest") as req:
|
||||
req.send_request.return_value = None
|
||||
with pytest.raises(EnterpriseServiceError, match="No data found"):
|
||||
EnterpriseService.WebAppAuth.get_app_access_mode_by_id("a1")
|
||||
|
||||
def test_batch_get_returns_empty_for_no_apps(self):
|
||||
assert EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id([]) == {}
|
||||
|
||||
|
||||
@ -1,952 +0,0 @@
|
||||
"""Unit tests for the agent drive service (ENG-591).
|
||||
|
||||
Pure helpers (key safety / drive-ref parsing) plus the commit/manifest lifecycle
|
||||
exercised against the project's in-memory SQLite engine with seeded ToolFiles.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import io
|
||||
import zipfile
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, event, select
|
||||
from sqlalchemy.exc import DataError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from models.agent import Agent, AgentDriveFile, AgentDriveFileKind, AgentScope, AgentSource
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import UploadFile
|
||||
from models.tools import ToolFile
|
||||
from services.agent_drive_service import (
|
||||
AgentDriveError,
|
||||
AgentDriveService,
|
||||
DriveCommitItem,
|
||||
DriveSkillMetadata,
|
||||
normalize_drive_key,
|
||||
parse_agent_drive_ref,
|
||||
)
|
||||
|
||||
TENANT = "11111111-1111-1111-1111-111111111111"
|
||||
AGENT = "22222222-2222-2222-2222-222222222222"
|
||||
USER = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
|
||||
# ── pure helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_agent_drive_ref():
|
||||
assert parse_agent_drive_ref("agent-abc") == "abc"
|
||||
for bad in ["abc", "agent-", ""]:
|
||||
with pytest.raises(AgentDriveError):
|
||||
parse_agent_drive_ref(bad)
|
||||
|
||||
|
||||
def test_normalize_drive_key_ok_and_collapses_slashes():
|
||||
assert normalize_drive_key("a/b/c.txt") == "a/b/c.txt"
|
||||
assert normalize_drive_key("/a//b.txt") == "a/b.txt"
|
||||
assert normalize_drive_key("skill-name/SKILL.md") == "skill-name/SKILL.md"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", " ", "a/../b", "../etc", "a/\x00b", "a" * 1100])
|
||||
def test_normalize_drive_key_rejects_unsafe(bad: str):
|
||||
with pytest.raises(AgentDriveError):
|
||||
normalize_drive_key(bad)
|
||||
|
||||
|
||||
# ── service lifecycle (in-memory ORM) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tables() -> Generator[None, None, None]:
|
||||
engine = session_factory.get_session_maker().kw["bind"]
|
||||
for model in (Agent, ToolFile, UploadFile, AgentDriveFile):
|
||||
model.__table__.create(bind=engine, checkfirst=True)
|
||||
_seed_agent()
|
||||
yield
|
||||
with session_factory.create_session() as session:
|
||||
session.execute(delete(AgentDriveFile))
|
||||
session.execute(delete(UploadFile))
|
||||
session.execute(delete(ToolFile))
|
||||
session.execute(delete(Agent))
|
||||
session.commit()
|
||||
AgentDriveFile.__table__.drop(bind=engine, checkfirst=True)
|
||||
|
||||
|
||||
def _seed_agent(*, tenant_id: str = TENANT, agent_id: str = AGENT) -> None:
|
||||
agent = Agent(
|
||||
id=agent_id,
|
||||
tenant_id=tenant_id,
|
||||
name="Drive Agent",
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
)
|
||||
with session_factory.create_session() as session:
|
||||
session.add(agent)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _seed_tool_file(*, user_id: str = USER, name: str = "f.txt", conversation_id: str | None = None) -> str:
|
||||
tool_file = ToolFile(
|
||||
user_id=user_id,
|
||||
tenant_id=TENANT,
|
||||
conversation_id=conversation_id,
|
||||
file_key=f"tools/{TENANT}/{name}",
|
||||
mimetype="text/plain",
|
||||
name=name,
|
||||
size=5,
|
||||
)
|
||||
with session_factory.create_session() as session:
|
||||
session.add(tool_file)
|
||||
session.commit()
|
||||
return tool_file.id
|
||||
|
||||
|
||||
def _zip_bytes(members: dict[str, bytes]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
for name, data in members.items():
|
||||
archive.writestr(name, data)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _commit(key: str, tool_file_id: str, *, owned: bool = True):
|
||||
return AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key=key,
|
||||
file_ref={"kind": "tool_file", "id": tool_file_id},
|
||||
value_owned_by_drive=owned,
|
||||
)
|
||||
],
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
|
||||
|
||||
def test_commit_then_manifest_lists_the_entry():
|
||||
tf = _seed_tool_file()
|
||||
_commit("data/report.txt", tf)
|
||||
|
||||
items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session())
|
||||
assert [i["key"] for i in items] == ["data/report.txt"]
|
||||
assert items[0]["file_kind"] == "tool_file"
|
||||
assert items[0]["file_id"] == tf
|
||||
assert items[0]["mime_type"] == "text/plain"
|
||||
|
||||
# prefix filter
|
||||
assert (
|
||||
AgentDriveService().manifest(
|
||||
tenant_id=TENANT, agent_id=AGENT, prefix="data/", session=session_factory.create_session()
|
||||
)
|
||||
!= []
|
||||
)
|
||||
assert (
|
||||
AgentDriveService().manifest(
|
||||
tenant_id=TENANT, agent_id=AGENT, prefix="other/", session=session_factory.create_session()
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_commit_owned_tool_file_detaches_conversation_ownership():
|
||||
conversation_id = "44444444-4444-4444-4444-444444444444"
|
||||
tool_file_id = _seed_tool_file(conversation_id=conversation_id)
|
||||
|
||||
_commit("data/report.txt", tool_file_id, owned=True)
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
tool_file = session.get(ToolFile, tool_file_id)
|
||||
assert tool_file is not None
|
||||
assert tool_file.conversation_id is None
|
||||
|
||||
|
||||
def test_commit_shared_tool_file_keeps_conversation_ownership():
|
||||
conversation_id = "44444444-4444-4444-4444-444444444444"
|
||||
tool_file_id = _seed_tool_file(conversation_id=conversation_id)
|
||||
|
||||
_commit("data/report.txt", tool_file_id, owned=False)
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
tool_file = session.get(ToolFile, tool_file_id)
|
||||
assert tool_file is not None
|
||||
assert tool_file.conversation_id == conversation_id
|
||||
|
||||
|
||||
def test_commit_skill_row_persists_metadata_and_lists_catalog() -> None:
|
||||
tf = _seed_tool_file(name="SKILL.md")
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="tender-analyzer/SKILL.md",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
is_skill=True,
|
||||
skill_metadata=DriveSkillMetadata(name="Tender Analyzer", description="Parses RFPs."),
|
||||
)
|
||||
],
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(select(AgentDriveFile).where(AgentDriveFile.key == "tender-analyzer/SKILL.md"))
|
||||
assert row is not None
|
||||
assert row.is_skill is True
|
||||
assert row.skill_metadata == '{"description":"Parses RFPs.","name":"Tender Analyzer"}'
|
||||
|
||||
skills = AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session())
|
||||
assert len(skills) == 1
|
||||
assert skills[0]["path"] == "tender-analyzer"
|
||||
assert skills[0]["skill_md_key"] == "tender-analyzer/SKILL.md"
|
||||
assert skills[0]["archive_key"] is None
|
||||
assert skills[0]["name"] == "Tender Analyzer"
|
||||
assert skills[0]["description"] == "Parses RFPs."
|
||||
assert skills[0]["size"] == 5
|
||||
assert skills[0]["mime_type"] == "text/plain"
|
||||
|
||||
|
||||
def test_commit_rejects_skill_row_without_skill_metadata() -> None:
|
||||
tf = _seed_tool_file(name="SKILL.md")
|
||||
|
||||
with pytest.raises(AgentDriveError) as exc_info:
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="tender-analyzer/SKILL.md",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
is_skill=True,
|
||||
)
|
||||
],
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "invalid_skill_metadata"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw_metadata", [None, '{"description":"oops"}'])
|
||||
def test_list_skills_raises_controlled_error_for_invalid_stored_metadata(raw_metadata: str | None) -> None:
|
||||
tf = _seed_tool_file(name="SKILL.md")
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
session.add(
|
||||
AgentDriveFile(
|
||||
id="44444444-4444-4444-4444-444444444444",
|
||||
tenant_id=TENANT,
|
||||
agent_id=AGENT,
|
||||
key="broken-skill/SKILL.md",
|
||||
file_kind=AgentDriveFileKind.TOOL_FILE,
|
||||
file_id=tf,
|
||||
value_owned_by_drive=True,
|
||||
is_skill=True,
|
||||
skill_metadata=raw_metadata,
|
||||
size=5,
|
||||
mime_type="text/plain",
|
||||
created_by=USER,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
with pytest.raises(AgentDriveError) as exc_info:
|
||||
AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session())
|
||||
|
||||
assert exc_info.value.code == "invalid_skill_metadata"
|
||||
|
||||
|
||||
def test_commit_rejects_non_skill_row_with_skill_metadata() -> None:
|
||||
tf = _seed_tool_file()
|
||||
with pytest.raises(AgentDriveError, match="skill metadata"):
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="files/report.txt",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
skill_metadata=DriveSkillMetadata(name="Bad", description=""),
|
||||
)
|
||||
],
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
|
||||
|
||||
def test_commit_rejects_non_canonical_skill_key() -> None:
|
||||
tf = _seed_tool_file(name="README.md")
|
||||
with pytest.raises(AgentDriveError, match="canonical"):
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="tender-analyzer/README.md",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
is_skill=True,
|
||||
skill_metadata=DriveSkillMetadata(name="Tender Analyzer", description=""),
|
||||
)
|
||||
],
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
|
||||
|
||||
def test_commit_rejects_tool_file_not_owned_by_user():
|
||||
other = _seed_tool_file(user_id="99999999-9999-9999-9999-999999999999")
|
||||
with pytest.raises(AgentDriveError) as exc_info:
|
||||
_commit("x.txt", other)
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.code == "source_not_found"
|
||||
|
||||
|
||||
def test_commit_rejects_agent_from_another_tenant():
|
||||
tf = _seed_tool_file()
|
||||
with pytest.raises(AgentDriveError) as exc_info:
|
||||
AgentDriveService().commit(
|
||||
tenant_id="99999999-9999-9999-9999-999999999999",
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="x.txt",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
value_owned_by_drive=True,
|
||||
)
|
||||
],
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.code == "agent_not_found"
|
||||
|
||||
|
||||
def test_overwrite_cleans_old_drive_owned_value():
|
||||
tf1 = _seed_tool_file(name="v1.txt")
|
||||
tf2 = _seed_tool_file(name="v2.txt")
|
||||
_commit("doc.txt", tf1, owned=True)
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
_commit("doc.txt", tf2, owned=True)
|
||||
storage_mock.delete.assert_called_once()
|
||||
|
||||
# old ToolFile physically removed; key now points at tf2
|
||||
with session_factory.create_session() as session:
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == tf1)) is None
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == tf2)) is not None
|
||||
rows = list(session.scalars(select(AgentDriveFile).where(AgentDriveFile.key == "doc.txt")))
|
||||
assert len(rows) == 1
|
||||
assert rows[0].file_id == tf2
|
||||
|
||||
|
||||
def test_batch_failure_does_not_delete_old_storage_before_commit():
|
||||
tf1 = _seed_tool_file(name="v1.txt")
|
||||
tf2 = _seed_tool_file(name="v2.txt")
|
||||
_commit("doc.txt", tf1, owned=True)
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
with session_factory.create_session() as session:
|
||||
with pytest.raises(AgentDriveError):
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="doc.txt",
|
||||
file_ref={"kind": "tool_file", "id": tf2},
|
||||
value_owned_by_drive=True,
|
||||
),
|
||||
DriveCommitItem(
|
||||
key="bad.txt",
|
||||
file_ref={"kind": "tool_file", "id": "44444444-4444-4444-4444-444444444444"},
|
||||
value_owned_by_drive=True,
|
||||
),
|
||||
],
|
||||
session=session,
|
||||
)
|
||||
session.rollback()
|
||||
storage_mock.delete.assert_not_called()
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(select(AgentDriveFile).where(AgentDriveFile.key == "doc.txt"))
|
||||
assert row is not None
|
||||
assert row.file_id == tf1
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == tf1)) is not None
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == tf2)) is not None
|
||||
|
||||
|
||||
def test_validate_source_db_error_maps_to_404():
|
||||
"""A database UUID failure maps to 404 and rolls back the real transaction."""
|
||||
|
||||
rollback_events: list[Session] = []
|
||||
|
||||
def raise_data_error(_orm_execute_state: object) -> None:
|
||||
raise DataError("bad uuid", {}, Exception("invalid input syntax for uuid"))
|
||||
|
||||
def record_rollback(session: Session) -> None:
|
||||
rollback_events.append(session)
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
session.begin()
|
||||
event.listen(session, "do_orm_execute", raise_data_error)
|
||||
event.listen(session, "after_rollback", record_rollback)
|
||||
try:
|
||||
with pytest.raises(AgentDriveError) as exc_info:
|
||||
AgentDriveService()._validate_source(
|
||||
session,
|
||||
tenant_id=TENANT,
|
||||
user_id="not-a-uuid",
|
||||
file_kind=AgentDriveFileKind.TOOL_FILE,
|
||||
file_id="also-bad",
|
||||
)
|
||||
finally:
|
||||
event.remove(session, "do_orm_execute", raise_data_error)
|
||||
event.remove(session, "after_rollback", record_rollback)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.code == "source_not_found"
|
||||
assert rollback_events == [session]
|
||||
assert not session.in_transaction()
|
||||
|
||||
|
||||
def test_recommit_same_value_is_idempotent_and_keeps_value():
|
||||
tf = _seed_tool_file()
|
||||
_commit("a.txt", tf)
|
||||
_commit("a.txt", tf) # no error, no cleanup
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None
|
||||
rows = list(session.scalars(select(AgentDriveFile).where(AgentDriveFile.key == "a.txt")))
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
def test_recommit_same_skill_value_updates_metadata_without_cleaning_backing_file() -> None:
|
||||
tf = _seed_tool_file(name="SKILL.md")
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="tender-analyzer/SKILL.md",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
value_owned_by_drive=True,
|
||||
is_skill=True,
|
||||
skill_metadata=DriveSkillMetadata(name="Tender Analyzer", description="v1"),
|
||||
)
|
||||
],
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="tender-analyzer/SKILL.md",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
value_owned_by_drive=False,
|
||||
is_skill=True,
|
||||
skill_metadata=DriveSkillMetadata(name="Tender Analyzer v2", description="v2"),
|
||||
)
|
||||
],
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
storage_mock.delete.assert_not_called()
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(select(AgentDriveFile).where(AgentDriveFile.key == "tender-analyzer/SKILL.md"))
|
||||
assert row is not None
|
||||
assert row.file_id == tf
|
||||
assert row.value_owned_by_drive is False
|
||||
assert row.skill_metadata == '{"description":"v2","name":"Tender Analyzer v2"}'
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None
|
||||
|
||||
|
||||
def _seed_upload_file(*, name: str = "u.txt") -> str:
|
||||
upload = UploadFile(
|
||||
tenant_id=TENANT,
|
||||
storage_type=StorageType.LOCAL,
|
||||
key=f"upload_files/{TENANT}/{name}",
|
||||
name=name,
|
||||
size=7,
|
||||
extension="txt",
|
||||
mime_type="text/plain",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=USER,
|
||||
created_at=datetime.datetime.now(tz=datetime.UTC),
|
||||
used=False,
|
||||
)
|
||||
with session_factory.create_session() as session:
|
||||
session.add(upload)
|
||||
session.commit()
|
||||
return upload.id
|
||||
|
||||
|
||||
def _commit_upload(key: str, upload_file_id: str, *, owned: bool = True):
|
||||
return AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key=key,
|
||||
file_ref={"kind": "upload_file", "id": upload_file_id},
|
||||
value_owned_by_drive=owned,
|
||||
)
|
||||
],
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
|
||||
|
||||
def test_commit_upload_file_source_and_manifest():
|
||||
uf = _seed_upload_file()
|
||||
_commit_upload("docs/u.txt", uf)
|
||||
|
||||
items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session())
|
||||
assert items[0]["file_kind"] == "upload_file"
|
||||
assert items[0]["file_id"] == uf
|
||||
assert items[0]["mime_type"] == "text/plain"
|
||||
|
||||
|
||||
def test_commit_rejects_missing_upload_file():
|
||||
with pytest.raises(AgentDriveError) as exc_info:
|
||||
_commit_upload("x.txt", "44444444-4444-4444-4444-444444444444")
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.code == "source_not_found"
|
||||
|
||||
|
||||
def test_overwrite_cleans_old_upload_file_value():
|
||||
u1 = _seed_upload_file(name="v1.txt")
|
||||
u2 = _seed_upload_file(name="v2.txt")
|
||||
_commit_upload("doc.txt", u1, owned=True)
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
_commit_upload("doc.txt", u2, owned=True)
|
||||
storage_mock.delete.assert_called_once()
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
assert session.scalar(select(UploadFile).where(UploadFile.id == u1)) is None
|
||||
assert session.scalar(select(UploadFile).where(UploadFile.id == u2)) is not None
|
||||
|
||||
|
||||
def test_manifest_includes_internal_download_url():
|
||||
tf = _seed_tool_file()
|
||||
_commit("data/r.txt", tf)
|
||||
|
||||
with (
|
||||
patch("services.agent_drive_service.file_factory.build_from_mapping", return_value=object()),
|
||||
patch("core.app.workflow.file_runtime.DifyWorkflowFileRuntime") as runtime_cls,
|
||||
):
|
||||
runtime_cls.return_value.resolve_file_url.return_value = "http://internal/files/x?sign=1"
|
||||
items = AgentDriveService().manifest(
|
||||
tenant_id=TENANT, agent_id=AGENT, include_download_url=True, session=session_factory.create_session()
|
||||
)
|
||||
|
||||
assert items[0]["download_url"] == "http://internal/files/x?sign=1"
|
||||
# drive-owned resolution: internal URL (for_external=False)
|
||||
assert runtime_cls.return_value.resolve_file_url.call_args.kwargs["for_external"] is False
|
||||
|
||||
|
||||
def test_manifest_download_url_none_when_unresolvable():
|
||||
tf = _seed_tool_file()
|
||||
_commit("data/r.txt", tf)
|
||||
|
||||
with patch(
|
||||
"services.agent_drive_service.file_factory.build_from_mapping",
|
||||
side_effect=ValueError("not found"),
|
||||
):
|
||||
items = AgentDriveService().manifest(
|
||||
tenant_id=TENANT, agent_id=AGENT, include_download_url=True, session=session_factory.create_session()
|
||||
)
|
||||
assert items[0]["download_url"] is None
|
||||
|
||||
|
||||
# ── ENG-625 D5: delete ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_delete_by_key_cleans_drive_owned_value():
|
||||
tf = _seed_tool_file(name="doomed.txt")
|
||||
_commit("files/doomed.txt", tf, owned=True)
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
removed = AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[DriveCommitItem(key="files/doomed.txt", file_ref=None)],
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
storage_mock.delete.assert_called_once()
|
||||
|
||||
assert removed == [
|
||||
{
|
||||
"key": "files/doomed.txt",
|
||||
"file_kind": "tool_file",
|
||||
"file_id": tf,
|
||||
"value_owned_by_drive": True,
|
||||
"is_skill": False,
|
||||
"skill_metadata": None,
|
||||
"removed": True,
|
||||
}
|
||||
]
|
||||
with session_factory.create_session() as session:
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is None
|
||||
assert list(session.scalars(select(AgentDriveFile))) == []
|
||||
|
||||
|
||||
def test_commit_null_batch_removes_multiple_skill_keys():
|
||||
md = _seed_tool_file(name="SKILL.md")
|
||||
zf = _seed_tool_file(name="full.zip")
|
||||
_commit("tender-analyzer/SKILL.md", md, owned=True)
|
||||
_commit("tender-analyzer/.DIFY-SKILL-FULL.zip", zf, owned=True)
|
||||
other = _seed_tool_file(name="other.txt")
|
||||
_commit("files/other.txt", other, owned=True)
|
||||
|
||||
with patch("services.agent_drive_service.storage"):
|
||||
removed = AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(key="tender-analyzer/SKILL.md", file_ref=None),
|
||||
DriveCommitItem(key="tender-analyzer/.DIFY-SKILL-FULL.zip", file_ref=None),
|
||||
],
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
|
||||
assert sorted(item["key"] for item in removed) == [
|
||||
"tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
"tender-analyzer/SKILL.md",
|
||||
]
|
||||
with session_factory.create_session() as session:
|
||||
# both skill ToolFiles physically removed, the unrelated file untouched
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == md)) is None
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == zf)) is None
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == other)) is not None
|
||||
keys = [row.key for row in session.scalars(select(AgentDriveFile))]
|
||||
assert keys == ["files/other.txt"]
|
||||
|
||||
|
||||
def test_commit_null_is_idempotent_for_missing_keys():
|
||||
removed = AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[DriveCommitItem(key="files/never-there.txt", file_ref=None)],
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
assert removed == [{"key": "files/never-there.txt", "removed": True, "noop": True}]
|
||||
|
||||
|
||||
def test_commit_null_keeps_shared_value_records():
|
||||
tf = _seed_tool_file(name="shared.txt")
|
||||
_commit("files/shared.txt", tf, owned=False)
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
removed = AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[DriveCommitItem(key="files/shared.txt", file_ref=None)],
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
storage_mock.delete.assert_not_called()
|
||||
|
||||
assert removed[0]["key"] == "files/shared.txt"
|
||||
with session_factory.create_session() as session:
|
||||
# only the KV row dropped; the shared ToolFile survives
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == tf)) is not None
|
||||
|
||||
|
||||
def test_restandardize_same_slug_overwrites_both_keys_and_cleans_old_toolfiles():
|
||||
"""ENG-625 §5.3 replacement semantics: re-standardizing a same-name skill
|
||||
overwrites <slug>/SKILL.md and <slug>/.DIFY-SKILL-FULL.zip, physically
|
||||
cleaning both old drive-owned ToolFiles."""
|
||||
old_md = _seed_tool_file(name="SKILL.md")
|
||||
old_zip = _seed_tool_file(name="full-v1.zip")
|
||||
_commit("pdf-toolkit/SKILL.md", old_md, owned=True)
|
||||
_commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", old_zip, owned=True)
|
||||
|
||||
new_md = _seed_tool_file(name="SKILL-v2.md")
|
||||
new_zip = _seed_tool_file(name="full-v2.zip")
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
_commit("pdf-toolkit/SKILL.md", new_md, owned=True)
|
||||
_commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", new_zip, owned=True)
|
||||
assert storage_mock.delete.call_count == 2
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == old_md)) is None
|
||||
assert session.scalar(select(ToolFile).where(ToolFile.id == old_zip)) is None
|
||||
rows = {row.key: row.file_id for row in session.scalars(select(AgentDriveFile))}
|
||||
assert rows == {
|
||||
"pdf-toolkit/SKILL.md": new_md,
|
||||
"pdf-toolkit/.DIFY-SKILL-FULL.zip": new_zip,
|
||||
}
|
||||
|
||||
|
||||
# ── ENG-624: console drive inspector (service layer) ─────────────────────────
|
||||
|
||||
|
||||
def test_preview_returns_text_with_truncation_flags():
|
||||
tf = _seed_tool_file(name="SKILL.md")
|
||||
_commit("pdf-toolkit/SKILL.md", tf)
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\nUse responsibly.\n"])
|
||||
result = AgentDriveService().preview(
|
||||
tenant_id=TENANT, agent_id=AGENT, key="pdf-toolkit/SKILL.md", session=session_factory.create_session()
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"key": "pdf-toolkit/SKILL.md",
|
||||
"size": 5,
|
||||
"truncated": False,
|
||||
"binary": False,
|
||||
"text": "# PDF Toolkit\nUse responsibly.\n",
|
||||
}
|
||||
|
||||
|
||||
def test_preview_marks_binary_and_oversized_content():
|
||||
tf = _seed_tool_file(name="blob.bin")
|
||||
_commit("files/blob.bin", tf)
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
storage_mock.load_stream.return_value = iter([b"\x00\x01\x02"])
|
||||
binary = AgentDriveService().preview(
|
||||
tenant_id=TENANT, agent_id=AGENT, key="files/blob.bin", session=session_factory.create_session()
|
||||
)
|
||||
assert binary["binary"] is True
|
||||
assert binary["text"] is None
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
storage_mock.load_stream.return_value = iter([b"x" * (AgentDriveService.PREVIEW_MAX_BYTES + 10)])
|
||||
oversized = AgentDriveService().preview(
|
||||
tenant_id=TENANT, agent_id=AGENT, key="files/blob.bin", session=session_factory.create_session()
|
||||
)
|
||||
assert oversized["truncated"] is True
|
||||
assert oversized["binary"] is False
|
||||
assert len(oversized["text"]) == AgentDriveService.PREVIEW_MAX_BYTES
|
||||
|
||||
|
||||
def test_preview_unknown_key_is_404():
|
||||
with pytest.raises(AgentDriveError) as exc_info:
|
||||
AgentDriveService().preview(
|
||||
tenant_id=TENANT, agent_id=AGENT, key="ghost/SKILL.md", session=session_factory.create_session()
|
||||
)
|
||||
assert exc_info.value.code == "drive_key_not_found"
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
def test_preview_rejects_cross_tenant_agent():
|
||||
with pytest.raises(AgentDriveError) as exc_info:
|
||||
AgentDriveService().preview(
|
||||
tenant_id="99999999-9999-9999-9999-999999999999",
|
||||
agent_id=AGENT,
|
||||
key="pdf-toolkit/SKILL.md",
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
assert exc_info.value.code == "agent_not_found"
|
||||
|
||||
|
||||
def test_download_url_signs_external_audience():
|
||||
tf = _seed_tool_file(name="full.zip")
|
||||
_commit("pdf-toolkit/.DIFY-SKILL-FULL.zip", tf)
|
||||
|
||||
with patch.object(AgentDriveService, "_resolve_download_url", return_value="https://signed.example/x") as resolver:
|
||||
url = AgentDriveService().download_url(
|
||||
tenant_id=TENANT,
|
||||
agent_id=AGENT,
|
||||
key="pdf-toolkit/.DIFY-SKILL-FULL.zip",
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
|
||||
assert url == "https://signed.example/x"
|
||||
# console downloads are for browsers: external signing, never the internal URL
|
||||
assert resolver.call_args.kwargs["for_external"] is True
|
||||
assert resolver.call_args.kwargs["as_attachment"] is True
|
||||
|
||||
|
||||
def test_upload_file_download_url_uses_attachment_filename():
|
||||
upload_file_id = _seed_upload_file(name="report.pdf")
|
||||
_commit_upload("files/report.pdf", upload_file_id)
|
||||
|
||||
with patch("core.app.workflow.file_runtime.DifyWorkflowFileRuntime") as runtime_cls:
|
||||
runtime_cls.return_value.resolve_upload_file_url.return_value = "https://files.example/report.pdf"
|
||||
url = AgentDriveService().download_url(
|
||||
tenant_id=TENANT, agent_id=AGENT, key="files/report.pdf", session=session_factory.create_session()
|
||||
)
|
||||
|
||||
assert url == "https://files.example/report.pdf"
|
||||
assert runtime_cls.return_value.resolve_upload_file_url.call_args.kwargs["for_external"] is True
|
||||
assert runtime_cls.return_value.resolve_upload_file_url.call_args.kwargs["as_attachment"] is True
|
||||
|
||||
|
||||
def test_manifest_items_carry_created_at_for_inspector():
|
||||
tf = _seed_tool_file()
|
||||
_commit("files/x.txt", tf)
|
||||
items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session())
|
||||
assert items[0]["created_at"] is None or isinstance(items[0]["created_at"], int)
|
||||
|
||||
|
||||
# ── DIFY-2517: skill catalog / inspect ───────────────────────────────────────
|
||||
|
||||
|
||||
def _commit_skill(*, manifest_files: list[str] | None = None) -> None:
|
||||
md = _seed_tool_file(name="SKILL.md")
|
||||
zf = _seed_tool_file(name="full.zip")
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="pdf-toolkit/SKILL.md",
|
||||
file_ref={"kind": "tool_file", "id": md},
|
||||
value_owned_by_drive=True,
|
||||
is_skill=True,
|
||||
skill_metadata=DriveSkillMetadata(
|
||||
name="PDF Toolkit",
|
||||
description="Work with PDFs.",
|
||||
manifest_files=manifest_files,
|
||||
),
|
||||
),
|
||||
DriveCommitItem(
|
||||
key="pdf-toolkit/.DIFY-SKILL-FULL.zip",
|
||||
file_ref={"kind": "tool_file", "id": zf},
|
||||
value_owned_by_drive=True,
|
||||
),
|
||||
],
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
|
||||
|
||||
def test_list_skills_uses_canonical_skill_rows():
|
||||
_commit_skill(manifest_files=["SKILL.md", "scripts/run.py"])
|
||||
|
||||
skills = AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT, session=session_factory.create_session())
|
||||
|
||||
created_at = skills[0].pop("created_at")
|
||||
assert skills == [
|
||||
{
|
||||
"path": "pdf-toolkit",
|
||||
"skill_md_key": "pdf-toolkit/SKILL.md",
|
||||
"archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip",
|
||||
"name": "PDF Toolkit",
|
||||
"description": "Work with PDFs.",
|
||||
"size": 5,
|
||||
"mime_type": "text/plain",
|
||||
"hash": None,
|
||||
}
|
||||
]
|
||||
assert created_at is None or isinstance(created_at, int)
|
||||
|
||||
|
||||
def test_inspect_skill_returns_manifest_files_and_file_tree():
|
||||
_commit_skill(manifest_files=["SKILL.md", "references/guide.md", "scripts/run.py"])
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\n"])
|
||||
result = AgentDriveService().inspect_skill(
|
||||
tenant_id=TENANT, agent_id=AGENT, skill_path="pdf-toolkit", session=session_factory.create_session()
|
||||
)
|
||||
|
||||
assert result["source"] == "skill_md"
|
||||
assert result["warnings"] == []
|
||||
assert [file["path"] for file in result["files"]] == ["SKILL.md", "references/guide.md", "scripts/run.py"]
|
||||
assert result["files"][0]["available_in_drive"] is True
|
||||
assert result["files"][1]["available_in_drive"] is True
|
||||
assert result["files"][1]["drive_key"] == "pdf-toolkit/references/guide.md"
|
||||
assert result["file_tree"][0]["name"] == "references"
|
||||
assert result["file_tree"][1]["name"] == "scripts"
|
||||
assert result["file_tree"][2]["name"] == "SKILL.md"
|
||||
assert result["skill_md"]["text"] == "# PDF Toolkit\n"
|
||||
|
||||
|
||||
def test_inspect_skill_falls_back_to_drive_keys_when_manifest_missing():
|
||||
_commit_skill(manifest_files=None)
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\n"])
|
||||
result = AgentDriveService().inspect_skill(
|
||||
tenant_id=TENANT, agent_id=AGENT, skill_path="pdf-toolkit", session=session_factory.create_session()
|
||||
)
|
||||
|
||||
assert result["warnings"] == ["manifest_files_unavailable"]
|
||||
assert [file["path"] for file in result["files"]] == ["SKILL.md"]
|
||||
|
||||
|
||||
def test_preview_skill_archive_member_from_manifest_without_drive_row():
|
||||
_commit_skill(manifest_files=["SKILL.md", "references/guide.md"])
|
||||
archive = _zip_bytes({"SKILL.md": b"# PDF Toolkit\n", "references/guide.md": b"Guide content\n"})
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
storage_mock.load_stream.return_value = iter([archive])
|
||||
result = AgentDriveService().preview(
|
||||
tenant_id=TENANT,
|
||||
agent_id=AGENT,
|
||||
key="pdf-toolkit/references/guide.md",
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"key": "pdf-toolkit/references/guide.md",
|
||||
"size": len(b"Guide content\n"),
|
||||
"truncated": False,
|
||||
"binary": False,
|
||||
"text": "Guide content\n",
|
||||
}
|
||||
|
||||
|
||||
def test_download_url_signs_skill_archive_member_from_manifest_without_drive_row():
|
||||
_commit_skill(manifest_files=["SKILL.md", "references/guide.md"])
|
||||
|
||||
with patch.object(
|
||||
AgentDriveService,
|
||||
"sign_archive_member_url",
|
||||
return_value="https://signed.example/member",
|
||||
) as sign:
|
||||
url = AgentDriveService().download_url(
|
||||
tenant_id=TENANT,
|
||||
agent_id=AGENT,
|
||||
key="pdf-toolkit/references/guide.md",
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
|
||||
assert url == "https://signed.example/member"
|
||||
kwargs = sign.call_args.kwargs
|
||||
assert kwargs["key"] == "pdf-toolkit/references/guide.md"
|
||||
assert kwargs["member_path"] == "references/guide.md"
|
||||
assert kwargs["for_external"] is True
|
||||
|
||||
|
||||
def test_skill_metadata_rejects_non_canonical_rows():
|
||||
tf = _seed_tool_file(name="not-skill.md")
|
||||
with pytest.raises(AgentDriveError) as exc_info:
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="files/not-skill.md",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
value_owned_by_drive=True,
|
||||
is_skill=True,
|
||||
skill_metadata=DriveSkillMetadata(name="Bad"),
|
||||
)
|
||||
],
|
||||
session=session_factory.create_session(),
|
||||
)
|
||||
assert exc_info.value.code == "invalid_skill_key"
|
||||
@ -145,3 +145,11 @@ def test_get_summary_rejects_missing_app() -> None:
|
||||
|
||||
with pytest.raises(AppDefinitionUnavailableError, match="App not found"):
|
||||
service.get_summary("missing")
|
||||
|
||||
|
||||
def test_get_site_configuration_rejects_missing_site() -> None:
|
||||
service, definitions = _service()
|
||||
definitions.get_site_configuration.return_value = None
|
||||
|
||||
with pytest.raises(AppDefinitionUnavailableError, match="Site not found"):
|
||||
service.get_site_configuration("app-1")
|
||||
|
||||
174
api/tests/unit_tests/services/test_email_code_login_challenge.py
Normal file
174
api/tests/unit_tests/services/test_email_code_login_challenge.py
Normal file
@ -0,0 +1,174 @@
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from redis.exceptions import ConnectionError
|
||||
|
||||
from services.email_code_login_challenge import (
|
||||
EmailCodeLoginChallengeStatus,
|
||||
EmailCodeLoginChallengeStore,
|
||||
EmailCodeLoginChallengeUnavailableError,
|
||||
)
|
||||
|
||||
TOKEN = "00000000-0000-4000-8000-000000000001"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def challenge_redis() -> Iterator[MagicMock]:
|
||||
with patch("services.email_code_login_challenge.redis_client") as mock_redis:
|
||||
yield mock_redis
|
||||
|
||||
|
||||
def test_create_stores_only_one_per_email_v2_challenge(
|
||||
challenge_redis: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr("services.email_code_login_challenge.dify_config.EMAIL_CODE_LOGIN_MAX_ATTEMPTS", 5)
|
||||
monkeypatch.setattr("services.email_code_login_challenge.dify_config.EMAIL_CODE_LOGIN_TOKEN_EXPIRY_MINUTES", 5)
|
||||
|
||||
with patch("services.email_code_login_challenge.uuid.uuid4", return_value=TOKEN):
|
||||
token = EmailCodeLoginChallengeStore.create(
|
||||
email="User@Example.com",
|
||||
code="123456",
|
||||
account_id="account-id",
|
||||
)
|
||||
|
||||
assert token == TOKEN
|
||||
challenge_key, ttl, serialized_payload = challenge_redis.setex.call_args.args
|
||||
assert challenge_key == EmailCodeLoginChallengeStore._challenge_key("user@example.com")
|
||||
assert ttl == 300
|
||||
assert json.loads(serialized_payload) == {
|
||||
"account_id": "account-id",
|
||||
"email": "user@example.com",
|
||||
"token_type": "email_code_login",
|
||||
"code": "123456",
|
||||
"remaining_attempts": 5,
|
||||
"challenge_version": 2,
|
||||
"state": "active",
|
||||
"token": TOKEN,
|
||||
}
|
||||
assert challenge_key != f"email_code_login:token:{TOKEN}"
|
||||
challenge_redis.set.assert_not_called()
|
||||
challenge_redis.delete.assert_not_called()
|
||||
|
||||
|
||||
def test_verify_current_challenge_decrements_budget_without_refreshing_ttl(challenge_redis: MagicMock) -> None:
|
||||
challenge_redis.eval.return_value = [3, 4]
|
||||
|
||||
result = EmailCodeLoginChallengeStore.verify(
|
||||
email="User@Example.com",
|
||||
code="654321",
|
||||
token=TOKEN,
|
||||
)
|
||||
|
||||
assert result.status is EmailCodeLoginChallengeStatus.INVALID_CODE
|
||||
assert result.remaining_attempts == 4
|
||||
eval_args = challenge_redis.eval.call_args.args
|
||||
assert eval_args[1] == 1
|
||||
assert eval_args[2] == EmailCodeLoginChallengeStore._challenge_key("user@example.com")
|
||||
assert eval_args[-5:] == ("email_code_login", TOKEN, "user@example.com", "654321", 2)
|
||||
challenge_redis.set.assert_not_called()
|
||||
challenge_redis.expire.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("lua_response", "expected_status"),
|
||||
[
|
||||
([1, -1], EmailCodeLoginChallengeStatus.INVALID_TOKEN),
|
||||
([2, -1], EmailCodeLoginChallengeStatus.EMAIL_MISMATCH),
|
||||
([4, -1], EmailCodeLoginChallengeStatus.VERIFIED),
|
||||
([6, 0], EmailCodeLoginChallengeStatus.EXHAUSTED),
|
||||
([8, -1], EmailCodeLoginChallengeStatus.INVALID_TOKEN),
|
||||
],
|
||||
)
|
||||
def test_verify_maps_v2_lua_result(
|
||||
challenge_redis: MagicMock,
|
||||
lua_response: list[int],
|
||||
expected_status: EmailCodeLoginChallengeStatus,
|
||||
) -> None:
|
||||
challenge_redis.eval.return_value = lua_response
|
||||
|
||||
result = EmailCodeLoginChallengeStore.verify(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
token=TOKEN,
|
||||
)
|
||||
|
||||
assert result.status is expected_status
|
||||
challenge_redis.eval.assert_called_once()
|
||||
|
||||
|
||||
def test_terminal_v2_challenge_blocks_pre_rollout_legacy_token_fallback(challenge_redis: MagicMock) -> None:
|
||||
legacy_token = "00000000-0000-4000-8000-000000000002"
|
||||
challenge_redis.eval.return_value = [8, -1]
|
||||
|
||||
result = EmailCodeLoginChallengeStore.verify(
|
||||
email="user@example.com",
|
||||
code="111111",
|
||||
token=legacy_token,
|
||||
)
|
||||
|
||||
assert result.status is EmailCodeLoginChallengeStatus.INVALID_TOKEN
|
||||
challenge_redis.eval.assert_called_once()
|
||||
assert f"email_code_login:token:{legacy_token}" not in challenge_redis.eval.call_args.args
|
||||
|
||||
|
||||
def test_verify_supports_unversioned_token_created_before_rollout(challenge_redis: MagicMock) -> None:
|
||||
challenge_redis.eval.side_effect = [[0, -1], [4, -1]]
|
||||
|
||||
result = EmailCodeLoginChallengeStore.verify(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
token=TOKEN,
|
||||
)
|
||||
|
||||
assert result.status is EmailCodeLoginChallengeStatus.VERIFIED
|
||||
assert challenge_redis.eval.call_count == 2
|
||||
legacy_args = challenge_redis.eval.call_args_list[1].args
|
||||
assert legacy_args[2] == f"email_code_login:token:{TOKEN}"
|
||||
assert legacy_args[-4:] == ("email_code_login", "user@example.com", "123456", 5)
|
||||
|
||||
|
||||
def test_verify_rejects_versioned_payload_in_legacy_fallback(challenge_redis: MagicMock) -> None:
|
||||
challenge_redis.eval.side_effect = [[0, -1], [7, -1]]
|
||||
|
||||
result = EmailCodeLoginChallengeStore.verify(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
token=TOKEN,
|
||||
)
|
||||
|
||||
assert result.status is EmailCodeLoginChallengeStatus.INVALID_TOKEN
|
||||
|
||||
|
||||
def test_create_fails_closed_on_redis_error(challenge_redis: MagicMock) -> None:
|
||||
challenge_redis.setex.side_effect = ConnectionError("redis unavailable")
|
||||
|
||||
with pytest.raises(EmailCodeLoginChallengeUnavailableError):
|
||||
EmailCodeLoginChallengeStore.create(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
account_id=None,
|
||||
)
|
||||
|
||||
|
||||
def test_verify_fails_closed_on_redis_error(challenge_redis: MagicMock) -> None:
|
||||
challenge_redis.eval.side_effect = ConnectionError("redis unavailable")
|
||||
|
||||
with pytest.raises(EmailCodeLoginChallengeUnavailableError):
|
||||
EmailCodeLoginChallengeStore.verify(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
token=TOKEN,
|
||||
)
|
||||
|
||||
|
||||
def test_verify_fails_closed_on_unexpected_lua_response(challenge_redis: MagicMock) -> None:
|
||||
challenge_redis.eval.return_value = None
|
||||
|
||||
with pytest.raises(EmailCodeLoginChallengeUnavailableError):
|
||||
EmailCodeLoginChallengeStore.verify(
|
||||
email="user@example.com",
|
||||
code="123456",
|
||||
token=TOKEN,
|
||||
)
|
||||
@ -36,6 +36,9 @@ def test_get_system_features_uses_configured_deployment_edition(
|
||||
|
||||
assert result.deployment_edition is edition
|
||||
assert result.model_dump(mode="json")["deployment_edition"] == edition.value
|
||||
webapp_auth_enabled = edition is DeploymentEdition.ENTERPRISE
|
||||
assert FeatureService.is_webapp_auth_enabled() is webapp_auth_enabled
|
||||
assert result.webapp_auth.enabled is webapp_auth_enabled
|
||||
if edition is DeploymentEdition.ENTERPRISE:
|
||||
fulfill_from_enterprise.assert_called_once_with(result)
|
||||
else:
|
||||
|
||||
@ -13,6 +13,15 @@ import services.message_service as service_module
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models.account import Account, AccountStatus
|
||||
from models.agent import (
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigSnapshot,
|
||||
AgentConfigVersionKind,
|
||||
AgentDebugConversation,
|
||||
AgentWorkspaceBinding,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import (
|
||||
ConversationFromSource,
|
||||
EndUserType,
|
||||
@ -38,7 +47,17 @@ from services.errors.message import (
|
||||
)
|
||||
from services.message_service import MessageService, attach_message_extra_contents
|
||||
|
||||
SQLITE_MODELS = (Conversation, Message, MessageFeedback, AppModelConfig, AppAnnotationSetting)
|
||||
SQLITE_MODELS = (
|
||||
Conversation,
|
||||
Message,
|
||||
MessageFeedback,
|
||||
AppModelConfig,
|
||||
AppAnnotationSetting,
|
||||
AgentConfigDraft,
|
||||
AgentConfigSnapshot,
|
||||
AgentDebugConversation,
|
||||
AgentWorkspaceBinding,
|
||||
)
|
||||
pytestmark = [
|
||||
pytest.mark.usefixtures("sqlite_session"),
|
||||
pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True),
|
||||
@ -672,6 +691,22 @@ class TestMessageServiceFeedback:
|
||||
|
||||
|
||||
class TestMessageServiceSuggestedQuestions:
|
||||
@staticmethod
|
||||
def _agent_soul(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
prompt: str | None = None,
|
||||
model: dict[str, object] | None = None,
|
||||
) -> AgentSoulConfig:
|
||||
suggested_questions: dict[str, object] = {"enabled": enabled}
|
||||
if prompt is not None:
|
||||
suggested_questions["prompt"] = prompt
|
||||
if model is not None:
|
||||
suggested_questions["model"] = model
|
||||
return AgentSoulConfig.model_validate(
|
||||
{"app_features": {"suggested_questions_after_answer": suggested_questions}}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _chat_boundaries(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@ -732,6 +767,236 @@ class TestMessageServiceSuggestedQuestions:
|
||||
assert result == ["Q1?"]
|
||||
llm_generator.generate_suggested_questions_after_answer.assert_called_once()
|
||||
|
||||
@pytest.mark.parametrize("draft_type", [AgentConfigDraftType.DRAFT, AgentConfigDraftType.DEBUG_BUILD])
|
||||
def test_agent_debug_uses_matching_draft(
|
||||
self,
|
||||
draft_type: AgentConfigDraftType,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
factory: MessageServiceTestDataFactory,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
conversation = factory.create_conversation()
|
||||
conversation.mode = AppMode.AGENT
|
||||
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
|
||||
account = factory.create_account()
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id="tenant-123",
|
||||
agent_id="agent-123",
|
||||
draft_type=draft_type,
|
||||
account_id=account.id if draft_type == AgentConfigDraftType.DEBUG_BUILD else None,
|
||||
draft_owner_key=account.id if draft_type == AgentConfigDraftType.DEBUG_BUILD else "",
|
||||
base_snapshot_id=None,
|
||||
home_snapshot_id=None,
|
||||
agent_workspace_binding_id=None,
|
||||
config_snapshot=self._agent_soul(
|
||||
prompt=f"{draft_type.value} prompt",
|
||||
model={
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {"temperature": 0.1},
|
||||
},
|
||||
),
|
||||
created_by=account.id,
|
||||
updated_by=account.id,
|
||||
)
|
||||
draft.id = "draft-123"
|
||||
mapping = AgentDebugConversation(
|
||||
tenant_id="tenant-123",
|
||||
agent_id="agent-123",
|
||||
app_id="app-123",
|
||||
account_id=account.id,
|
||||
draft_type=draft_type,
|
||||
conversation_id=conversation.id,
|
||||
)
|
||||
mapping.id = "mapping-123"
|
||||
app_model_config = AppModelConfig(
|
||||
app_id="app-123",
|
||||
suggested_questions_after_answer=json.dumps({"enabled": False}),
|
||||
)
|
||||
app_model_config.id = "config-123"
|
||||
_persist(sqlite_session, draft, mapping, app_model_config)
|
||||
roster_service = MagicMock()
|
||||
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
|
||||
app = factory.create_app(mode=AppMode.AGENT)
|
||||
app.app_model_config_id = app_model_config.id
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app,
|
||||
user=account,
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result == ["Q1?"]
|
||||
roster_service.assert_not_called()
|
||||
llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
histories="histories",
|
||||
instruction_prompt=f"{draft_type.value} prompt",
|
||||
model_config={
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {"temperature": 0.1},
|
||||
},
|
||||
)
|
||||
|
||||
def test_agent_published_conversation_uses_bound_snapshot(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
factory: MessageServiceTestDataFactory,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
conversation = factory.create_conversation()
|
||||
conversation.mode = AppMode.AGENT
|
||||
conversation.agent_workspace_binding_id = "binding-123"
|
||||
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
|
||||
snapshot = AgentConfigSnapshot(
|
||||
tenant_id="tenant-123",
|
||||
agent_id="agent-123",
|
||||
version=1,
|
||||
config_snapshot=self._agent_soul(prompt="bound snapshot prompt"),
|
||||
home_snapshot_id=None,
|
||||
summary=None,
|
||||
version_note=None,
|
||||
created_by="account-123",
|
||||
)
|
||||
snapshot.id = "snapshot-123"
|
||||
binding = AgentWorkspaceBinding(
|
||||
tenant_id="tenant-123",
|
||||
app_id="app-123",
|
||||
workspace_id="workspace-123",
|
||||
agent_id="agent-123",
|
||||
base_home_snapshot_id=None,
|
||||
agent_config_version_id=snapshot.id,
|
||||
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||
backend_binding_ref="backend-binding-123",
|
||||
session_snapshot=None,
|
||||
retired_at=None,
|
||||
pending_form_id=None,
|
||||
pending_tool_call_id=None,
|
||||
)
|
||||
binding.id = "binding-123"
|
||||
_persist(sqlite_session, snapshot, binding)
|
||||
roster_service = MagicMock()
|
||||
roster_service.return_value.get_published_agent_soul_for_app.return_value = self._agent_soul(
|
||||
prompt="current published prompt"
|
||||
)
|
||||
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=factory.create_app(mode=AppMode.AGENT),
|
||||
user=factory.create_end_user(),
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result == ["Q1?"]
|
||||
roster_service.assert_not_called()
|
||||
llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
histories="histories",
|
||||
instruction_prompt="bound snapshot prompt",
|
||||
model_config=None,
|
||||
)
|
||||
|
||||
def test_agent_without_binding_uses_published_soul(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
factory: MessageServiceTestDataFactory,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
conversation = factory.create_conversation()
|
||||
conversation.mode = AppMode.AGENT
|
||||
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
|
||||
roster_service = MagicMock()
|
||||
roster_service.return_value.get_published_agent_soul_for_app.return_value = self._agent_soul(
|
||||
prompt="published prompt"
|
||||
)
|
||||
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=factory.create_app(mode=AppMode.AGENT),
|
||||
user=factory.create_end_user(),
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result == ["Q1?"]
|
||||
roster_service.return_value.get_published_agent_soul_for_app.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
app_id="app-123",
|
||||
)
|
||||
llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
histories="histories",
|
||||
instruction_prompt="published prompt",
|
||||
model_config=None,
|
||||
)
|
||||
|
||||
def test_historical_agent_without_soul_uses_current_app_model_config(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
factory: MessageServiceTestDataFactory,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
conversation = factory.create_conversation()
|
||||
conversation.mode = AppMode.AGENT
|
||||
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
|
||||
app_model_config = AppModelConfig(
|
||||
app_id="app-123",
|
||||
suggested_questions_after_answer=json.dumps({"enabled": True, "prompt": "legacy prompt"}),
|
||||
)
|
||||
app_model_config.id = "config-123"
|
||||
_persist(sqlite_session, app_model_config)
|
||||
app = factory.create_app(mode=AppMode.AGENT)
|
||||
app.app_model_config_id = app_model_config.id
|
||||
roster_service = MagicMock()
|
||||
roster_service.return_value.get_published_agent_soul_for_app.return_value = None
|
||||
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
|
||||
|
||||
result = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app,
|
||||
user=factory.create_end_user(),
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result == ["Q1?"]
|
||||
llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
|
||||
tenant_id="tenant-123",
|
||||
histories="histories",
|
||||
instruction_prompt="legacy prompt",
|
||||
model_config=None,
|
||||
)
|
||||
|
||||
def test_agent_disabled_raises_disabled_error(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
factory: MessageServiceTestDataFactory,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
conversation = factory.create_conversation()
|
||||
conversation.mode = AppMode.AGENT
|
||||
self._chat_boundaries(monkeypatch, conversation)
|
||||
roster_service = MagicMock()
|
||||
roster_service.return_value.get_published_agent_soul_for_app.return_value = self._agent_soul(enabled=False)
|
||||
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
|
||||
|
||||
with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError):
|
||||
MessageService.get_suggested_questions_after_answer(
|
||||
app_model=factory.create_app(mode=AppMode.AGENT),
|
||||
user=factory.create_end_user(),
|
||||
message_id="msg-123",
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "expected_prompt", "expected_model"),
|
||||
[
|
||||
|
||||
@ -5,6 +5,7 @@ import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from services.turnstile_service import (
|
||||
EMAIL_CODE_VERIFY_ACTION,
|
||||
TurnstileChallengeRejectedError,
|
||||
TurnstileService,
|
||||
TurnstileUpstreamError,
|
||||
@ -46,6 +47,19 @@ def test_verify_accepts_subdomain_and_forwards_remote_ip(monkeypatch: pytest.Mon
|
||||
)
|
||||
|
||||
|
||||
def test_verify_accepts_caller_scoped_action(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
mock_response(
|
||||
monkeypatch,
|
||||
payload={"success": True, "action": EMAIL_CODE_VERIFY_ACTION, "hostname": "agent.dify.dev"},
|
||||
)
|
||||
|
||||
TurnstileService.verify(
|
||||
token="verified-token",
|
||||
remote_ip=None,
|
||||
expected_action=EMAIL_CODE_VERIFY_ACTION,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token", [None, "", " ", "x" * 2049])
|
||||
def test_verify_rejects_missing_or_oversized_token(monkeypatch: pytest.MonkeyPatch, token: str | None) -> None:
|
||||
post = MagicMock()
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
from unittest.mock import MagicMock, create_autospec
|
||||
|
||||
import pytest
|
||||
|
||||
from enums import WebAppAccessMode
|
||||
from services.webapp_access_query_service import (
|
||||
WebAppAccessAppNotFoundError,
|
||||
WebAppAccessQuery,
|
||||
WebAppAccessQueryService,
|
||||
WebAppAccessReferenceRequiredError,
|
||||
)
|
||||
|
||||
|
||||
def _service(
|
||||
*,
|
||||
access: MagicMock,
|
||||
enabled: bool = True,
|
||||
access_mode: WebAppAccessMode = WebAppAccessMode.PRIVATE,
|
||||
) -> tuple[WebAppAccessQueryService, MagicMock]:
|
||||
access_mode_for_app = MagicMock(return_value=access_mode)
|
||||
return (
|
||||
WebAppAccessQueryService(
|
||||
access=access,
|
||||
webapp_auth_enabled=enabled,
|
||||
access_mode_for_app=access_mode_for_app,
|
||||
),
|
||||
access_mode_for_app,
|
||||
)
|
||||
|
||||
|
||||
def test_disabled_auth_returns_public_before_resolving_app() -> None:
|
||||
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
|
||||
service, access_mode_for_app = _service(access=access, enabled=False)
|
||||
|
||||
assert service.get_access_mode(app_id=None, app_code=None) is WebAppAccessMode.PUBLIC
|
||||
access.find_app_id_by_code.assert_not_called()
|
||||
access_mode_for_app.assert_not_called()
|
||||
|
||||
|
||||
def test_enabled_auth_reads_access_mode_by_app_id() -> None:
|
||||
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
|
||||
service, access_mode_for_app = _service(access=access)
|
||||
|
||||
assert service.get_access_mode(app_id="app-1", app_code=None) is WebAppAccessMode.PRIVATE
|
||||
access.find_app_id_by_code.assert_not_called()
|
||||
access_mode_for_app.assert_called_once_with("app-1")
|
||||
|
||||
|
||||
def test_app_code_takes_precedence_over_app_id() -> None:
|
||||
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
|
||||
access.find_app_id_by_code.return_value = "resolved-id"
|
||||
service, access_mode_for_app = _service(access=access, access_mode=WebAppAccessMode.SSO_VERIFIED)
|
||||
|
||||
assert service.get_access_mode(app_id="ignored-id", app_code="code-1") is WebAppAccessMode.SSO_VERIFIED
|
||||
access.find_app_id_by_code.assert_called_once_with("code-1")
|
||||
access_mode_for_app.assert_called_once_with("resolved-id")
|
||||
|
||||
|
||||
def test_missing_app_code_raises_not_found() -> None:
|
||||
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
|
||||
access.find_app_id_by_code.return_value = None
|
||||
service, access_mode_for_app = _service(access=access)
|
||||
|
||||
with pytest.raises(WebAppAccessAppNotFoundError):
|
||||
service.get_access_mode(app_id="must-not-fallback", app_code="missing-code")
|
||||
|
||||
access_mode_for_app.assert_not_called()
|
||||
|
||||
|
||||
def test_enabled_auth_requires_app_id_or_code() -> None:
|
||||
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
|
||||
service, access_mode_for_app = _service(access=access)
|
||||
|
||||
with pytest.raises(WebAppAccessReferenceRequiredError, match="^appId or appCode must be provided$"):
|
||||
service.get_access_mode(app_id=None, app_code=None)
|
||||
|
||||
access_mode_for_app.assert_not_called()
|
||||
|
||||
|
||||
def test_repository_failure_is_not_hidden() -> None:
|
||||
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
|
||||
failure = TypeError("repository bug")
|
||||
access.find_app_id_by_code.side_effect = failure
|
||||
service, _ = _service(access=access)
|
||||
|
||||
with pytest.raises(TypeError) as raised:
|
||||
service.get_access_mode(app_id=None, app_code="code-1")
|
||||
|
||||
assert raised.value is failure
|
||||
|
||||
|
||||
def test_access_mode_failure_is_not_hidden() -> None:
|
||||
access: MagicMock = create_autospec(WebAppAccessQuery, instance=True, spec_set=True)
|
||||
service, access_mode_for_app = _service(access=access)
|
||||
failure = TypeError("adapter bug")
|
||||
access_mode_for_app.side_effect = failure
|
||||
|
||||
with pytest.raises(TypeError) as raised:
|
||||
service.get_access_mode(app_id="app-1", app_code=None)
|
||||
|
||||
assert raised.value is failure
|
||||
@ -27,7 +27,7 @@ from models import (
|
||||
PinnedConversation,
|
||||
SavedMessage,
|
||||
)
|
||||
from models.agent import AgentConfigDraftType, AgentDriveFile, AgentDriveFileKind
|
||||
from models.agent import AgentConfigDraftType
|
||||
from models.enums import (
|
||||
ConversationFromSource,
|
||||
ConversationStatus,
|
||||
@ -93,14 +93,13 @@ def _tool_file(*, name: str, conversation_id: str | None = CONVERSATION_ID) -> T
|
||||
)
|
||||
|
||||
|
||||
def test_cleanup_removes_owned_resources_and_preserves_drive_files(sqlite_session: Session) -> None:
|
||||
def test_cleanup_removes_owned_resources(sqlite_session: Session) -> None:
|
||||
conversation = _conversation(CONVERSATION_ID, deleted=True)
|
||||
other_conversation = _conversation(OTHER_CONVERSATION_ID, deleted=False)
|
||||
message = _message()
|
||||
owned_file = _tool_file(name="owned.txt")
|
||||
drive_file = _tool_file(name="drive.txt")
|
||||
other_file = _tool_file(name="other.txt", conversation_id=OTHER_CONVERSATION_ID)
|
||||
sqlite_session.add_all([conversation, other_conversation, message, owned_file, drive_file, other_file])
|
||||
sqlite_session.add_all([conversation, other_conversation, message, owned_file, other_file])
|
||||
sqlite_session.flush()
|
||||
|
||||
message_chain = MessageChain(message_id=MESSAGE_ID, type=MessageChainType.SYSTEM, input=None, output=None)
|
||||
@ -202,15 +201,6 @@ def test_cleanup_removes_owned_resources_and_preserves_drive_files(sqlite_sessio
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
conversation_id=CONVERSATION_ID,
|
||||
),
|
||||
AgentDriveFile(
|
||||
tenant_id=TENANT_ID,
|
||||
agent_id=AGENT_ID,
|
||||
key="drive.txt",
|
||||
file_kind=AgentDriveFileKind.TOOL_FILE,
|
||||
file_id=drive_file.id,
|
||||
value_owned_by_drive=False,
|
||||
is_skill=False,
|
||||
),
|
||||
HumanInputFormRecipient(
|
||||
form_id=form.id,
|
||||
delivery_id=delivery.id,
|
||||
@ -230,7 +220,6 @@ def test_cleanup_removes_owned_resources_and_preserves_drive_files(sqlite_sessio
|
||||
form_id = form.id
|
||||
owned_file_id = owned_file.id
|
||||
owned_file_key = owned_file.file_key
|
||||
drive_file_id = drive_file.id
|
||||
other_file_id = other_file.id
|
||||
|
||||
with patch("tasks.delete_conversation_task.storage") as storage_mock:
|
||||
@ -245,12 +234,6 @@ def test_cleanup_removes_owned_resources_and_preserves_drive_files(sqlite_sessio
|
||||
)
|
||||
assert sqlite_session.scalar(select(HumanInputForm).where(HumanInputForm.id == form_id)) is None
|
||||
assert sqlite_session.get(ToolFile, owned_file_id) is None
|
||||
preserved_drive_file = sqlite_session.get(ToolFile, drive_file_id)
|
||||
assert preserved_drive_file is not None
|
||||
assert preserved_drive_file.conversation_id is None
|
||||
preserved_drive_entry = sqlite_session.scalar(select(AgentDriveFile).where(AgentDriveFile.file_id == drive_file_id))
|
||||
assert preserved_drive_entry is not None
|
||||
assert preserved_drive_entry.value_owned_by_drive is True
|
||||
assert sqlite_session.get(ToolFile, other_file_id) is not None
|
||||
assert sqlite_session.get(Conversation, OTHER_CONVERSATION_ID) is not None
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// dify-agent-cli is the Go replacement for the Python dify-agent CLI.
|
||||
// It communicates with the Agent Stub server via HTTP to provide
|
||||
// connect, file, drive, and config operations inside the sandbox container.
|
||||
// connect, file, and config operations inside the sandbox container.
|
||||
package main
|
||||
|
||||
import (
|
||||
@ -17,7 +17,6 @@ import (
|
||||
var knownRootCommands = map[string]struct{}{
|
||||
"config": {},
|
||||
"connect": {},
|
||||
"drive": {},
|
||||
"file": {},
|
||||
}
|
||||
|
||||
@ -76,7 +75,6 @@ func newRootCommand() *cobra.Command {
|
||||
root.AddCommand(
|
||||
newConnectCommand(),
|
||||
newFileCommand(),
|
||||
newDriveCommand(),
|
||||
newConfigCommand(),
|
||||
)
|
||||
return root
|
||||
@ -142,60 +140,6 @@ func newFileCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDriveCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "drive",
|
||||
Short: "List, pull, or push agent drive files through the Agent Stub.",
|
||||
}
|
||||
|
||||
var listJSON bool
|
||||
list := &cobra.Command{
|
||||
Use: "list [REMOTE_PREFIX]",
|
||||
Short: "List drive files visible to the current sandbox execution.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: withEnv(func(env *agentcli.Environment, args []string, _ *cobra.Command) error {
|
||||
prefix := ""
|
||||
if len(args) > 0 {
|
||||
prefix = args[0]
|
||||
}
|
||||
return agentcli.RunDriveList(env, prefix, listJSON)
|
||||
}),
|
||||
}
|
||||
list.Flags().BoolVar(&listJSON, "json", false, "Emit the drive manifest as JSON.")
|
||||
|
||||
var pullTo string
|
||||
var pullJSON bool
|
||||
pull := &cobra.Command{
|
||||
Use: "pull [REMOTE]...",
|
||||
Short: "Pull one or more drive keys/prefixes into one local directory tree.",
|
||||
RunE: withEnv(func(env *agentcli.Environment, args []string, _ *cobra.Command) error {
|
||||
localBase := pullTo
|
||||
if localBase == "" {
|
||||
localBase = agentcli.ReadDriveBase()
|
||||
}
|
||||
return agentcli.RunDrivePull(env, args, localBase, pullJSON)
|
||||
}),
|
||||
}
|
||||
pull.Flags().StringVar(&pullTo, "to", "", "Local base directory for pulled drive files.")
|
||||
pull.Flags().BoolVar(&pullJSON, "json", false, "Emit the pull result as JSON.")
|
||||
|
||||
var pushKind string
|
||||
var pushJSON bool
|
||||
push := &cobra.Command{
|
||||
Use: "push LOCAL_PATH REMOTE_PATH",
|
||||
Short: "Upload one local file or directory into the agent drive.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: withEnv(func(env *agentcli.Environment, args []string, _ *cobra.Command) error {
|
||||
return agentcli.RunDrivePush(env, args[0], args[1], pushKind)
|
||||
}),
|
||||
}
|
||||
push.Flags().StringVar(&pushKind, "kind", "", "Directory upload kind: skill or dir.")
|
||||
push.Flags().BoolVar(&pushJSON, "json", false, "Accepted for consistency; drive push output is already emitted as JSON.")
|
||||
|
||||
cmd.AddCommand(list, pull, push)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newConfigCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
|
||||
@ -38,7 +38,7 @@ func TestCommandHelp(t *testing.T) {
|
||||
{
|
||||
name: "root",
|
||||
args: []string{"--help"},
|
||||
want: []string{"Usage:", "dify-agent", "config", "connect", "drive", "file"},
|
||||
want: []string{"Usage:", "dify-agent", "config", "connect", "file"},
|
||||
},
|
||||
{
|
||||
name: "connect",
|
||||
@ -70,26 +70,6 @@ func TestCommandHelp(t *testing.T) {
|
||||
args: []string{"file", "public-url", "--help"},
|
||||
want: []string{"dify-agent file public-url", "Create a browser-visible download URL"},
|
||||
},
|
||||
{
|
||||
name: "drive",
|
||||
args: []string{"drive", "--help"},
|
||||
want: []string{"dify-agent drive", "list", "pull", "push"},
|
||||
},
|
||||
{
|
||||
name: "drive list",
|
||||
args: []string{"drive", "list", "--help"},
|
||||
want: []string{"dify-agent drive list", "List drive files", "--json"},
|
||||
},
|
||||
{
|
||||
name: "drive pull",
|
||||
args: []string{"drive", "pull", "--help"},
|
||||
want: []string{"dify-agent drive pull", "Pull one or more drive", "--to", "--json"},
|
||||
},
|
||||
{
|
||||
name: "drive push",
|
||||
args: []string{"drive", "push", "--help"},
|
||||
want: []string{"dify-agent drive push", "Upload one local file or directory", "--kind", "--json"},
|
||||
},
|
||||
{
|
||||
name: "config",
|
||||
args: []string{"config", "--help"},
|
||||
|
||||
@ -71,9 +71,8 @@ COPY --from=go-builder /bin/shellctl-runner /usr/local/bin/shellctl-runner
|
||||
COPY --from=go-builder /bin/dify-agent /usr/local/bin/dify-agent
|
||||
|
||||
RUN useradd --create-home --shell /bin/sh dify \
|
||||
&& mkdir -p /mnt/drive \
|
||||
&& chown dify:dify /home \
|
||||
&& chown -R dify:dify /home/dify /mnt/drive
|
||||
&& chown -R dify:dify /home/dify
|
||||
|
||||
USER dify
|
||||
WORKDIR /home/dify
|
||||
|
||||
@ -135,3 +135,26 @@ func extractZip(archivePath string, targetDir string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldSkipDir(name string) bool {
|
||||
skip := map[string]bool{
|
||||
".git": true, "__pycache__": true, ".pytest_cache": true,
|
||||
".mypy_cache": true, ".ruff_cache": true, ".venv": true, "node_modules": true,
|
||||
}
|
||||
return skip[name]
|
||||
}
|
||||
|
||||
func buildSkillArchive(dirPath string) (string, error) {
|
||||
tmpFile, err := os.CreateTemp("", "skill-archive-*.zip")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp archive: %w", err)
|
||||
}
|
||||
archivePath := tmpFile.Name()
|
||||
_ = tmpFile.Close()
|
||||
|
||||
if err := createZipArchive(archivePath, dirPath); err != nil {
|
||||
_ = os.Remove(archivePath)
|
||||
return "", err
|
||||
}
|
||||
return archivePath, nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user