Merge remote-tracking branch 'origin/main' into deploy/konwledge

This commit is contained in:
Stephen Zhou 2026-08-20 10:11:26 +08:00
commit db8c40b23e
No known key found for this signature in database
236 changed files with 4947 additions and 12659 deletions

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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()

View File

@ -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

View File

@ -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,

View File

@ -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",

View File

@ -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})

View File

@ -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)

View File

@ -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",
]

View File

@ -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."

View File

@ -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(

View File

@ -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):

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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",

View File

@ -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}

View File

@ -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)

View File

@ -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")

View File

@ -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."""

View File

@ -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(
{

View File

@ -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."""

View File

@ -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(

View File

@ -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"],
)

View File

@ -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",

View File

@ -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)

View File

@ -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

View File

@ -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 |

View File

@ -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.

View File

@ -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}:

View 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

View File

@ -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."""

View File

@ -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")

View File

@ -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,

View File

@ -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

View File

@ -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]:

View File

@ -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:

View 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"]

View File

@ -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

View File

@ -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"]

View File

@ -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",
]

View File

@ -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

View File

@ -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

View File

@ -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

View 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}"

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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(

View File

@ -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

View 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)

View File

@ -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)

View 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)

View File

@ -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",

View File

@ -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

View File

@ -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"]

View File

@ -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(

View File

@ -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:

View File

@ -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
)

View File

@ -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

View File

@ -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")

View File

@ -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):

View File

@ -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()

View File

@ -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"]

View File

@ -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

View File

@ -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))

View File

@ -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,

View File

@ -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

View File

@ -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),

View File

@ -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(

View File

@ -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():

View File

@ -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

View File

@ -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)

View 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()

View File

@ -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",

View File

@ -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,

View File

@ -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

View File

@ -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(

View File

@ -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:

View File

@ -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()

View File

@ -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"

View File

@ -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 ────────────────────────────────────────────────────────────

View File

@ -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

View File

@ -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()

View File

@ -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",
)

View File

@ -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([]) == {}

View File

@ -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"

View File

@ -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")

View 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,
)

View File

@ -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:

View File

@ -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"),
[

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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"},

View File

@ -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

View File

@ -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