mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
fix: gate Agent access until first publish (#40105)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
co-authored by
autofix-ci[bot]
yyh
parent
05c7386013
commit
90d6046345
@@ -88,8 +88,9 @@ default_app_templates: Mapping[AppMode, Mapping] = {
|
||||
AppMode.AGENT: {
|
||||
"app": {
|
||||
"mode": AppMode.AGENT,
|
||||
"enable_site": True,
|
||||
"enable_api": True,
|
||||
# Public access is enabled atomically by the first successful publish.
|
||||
"enable_site": False,
|
||||
"enable_api": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Any
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
|
||||
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
|
||||
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
@@ -34,9 +35,7 @@ def get_published_agent_app_feature_dict_and_user_input_form(
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent App has no bound Agent")
|
||||
# active_config_is_published means the draft has no unpublished edits; the public app
|
||||
# can still read parameters from the active snapshot while a newer draft is pending.
|
||||
if not agent.active_config_snapshot_id:
|
||||
if not agent_has_workflow_callable_active_snapshot(session=session, agent=agent):
|
||||
raise AgentAppNotPublishedError("Agent has not been published")
|
||||
|
||||
snapshot = session.scalar(
|
||||
|
||||
@@ -43,6 +43,7 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
|
||||
from fields.agent_fields import (
|
||||
AgentConfigDraftSummaryResponse,
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
@@ -139,6 +140,7 @@ class AgentApiStatusPayload(BaseModel):
|
||||
|
||||
|
||||
class AgentApiAccessResponse(BaseModel):
|
||||
access_ready: bool
|
||||
enabled: bool
|
||||
service_api_base_url: str
|
||||
streaming_only: bool = True
|
||||
@@ -257,6 +259,7 @@ class AgentAppDetailWithSite(GenericAppDetailWithSite):
|
||||
debug_conversation_has_messages: bool = False
|
||||
debug_conversation_message_count: int = 0
|
||||
role: str | None = None
|
||||
access_ready: bool = False
|
||||
|
||||
|
||||
class AgentDebugConversationRefreshResponse(BaseModel):
|
||||
@@ -400,6 +403,7 @@ def _serialize_agent_app_detail(
|
||||
payload["debug_conversation_has_messages"] = message_count > 0
|
||||
payload["debug_conversation_message_count"] = message_count
|
||||
payload["role"] = agent.role or ""
|
||||
payload["access_ready"] = agent_has_workflow_callable_active_snapshot(session=session, agent=agent)
|
||||
return payload
|
||||
|
||||
|
||||
@@ -491,10 +495,20 @@ def _agent_api_key_count(session: Session, app_id: str) -> int:
|
||||
)
|
||||
|
||||
|
||||
def _agent_app_access_ready(session: Session, app_model: App) -> bool:
|
||||
agent = _agent_roster_service(session).get_app_backing_agent(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=str(app_model.id),
|
||||
)
|
||||
return bool(agent and agent_has_workflow_callable_active_snapshot(session=session, agent=agent))
|
||||
|
||||
|
||||
def _serialize_agent_api_access(session: Session, app_model: App) -> dict:
|
||||
base_url = app_model.api_base_url
|
||||
access_ready = _agent_app_access_ready(session, app_model)
|
||||
response = AgentApiAccessResponse(
|
||||
enabled=bool(app_model.enable_api),
|
||||
access_ready=access_ready,
|
||||
enabled=bool(app_model.enable_api and access_ready),
|
||||
service_api_base_url=base_url,
|
||||
chat_endpoint=f"{base_url}/chat-messages",
|
||||
stop_endpoint=f"{base_url}/chat-messages/{{task_id}}/stop",
|
||||
|
||||
@@ -21,6 +21,7 @@ from models.dataset import Dataset
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App
|
||||
from services.api_token_service import ApiTokenCache
|
||||
from services.app_service import AppService
|
||||
|
||||
from . import console_ns
|
||||
from .wraps import (
|
||||
@@ -103,7 +104,9 @@ class BaseApiKeyListResource(Resource):
|
||||
|
||||
def _create_api_key(self, resource_id: str, current_tenant_id: str, *, session: Session) -> ApiToken:
|
||||
assert self.resource_id_field is not None, "resource_id_field must be set"
|
||||
_get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
|
||||
resource = _get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
|
||||
if isinstance(resource, App):
|
||||
AppService.ensure_agent_app_access_ready(resource, session=session)
|
||||
current_key_count: int = (
|
||||
session.scalar(
|
||||
select(func.count(ApiToken.id)).where(
|
||||
|
||||
@@ -27,6 +27,7 @@ from clients.agent_backend import AgentBackendRunEventAdapter
|
||||
from clients.agent_backend.factory import create_agent_backend_run_client
|
||||
from configs import dify_config
|
||||
from constants import UUID_NIL
|
||||
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
|
||||
from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter
|
||||
from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager
|
||||
from core.app.apps.agent_app.app_runner import AgentAppRunner
|
||||
@@ -58,7 +59,6 @@ from models.agent import (
|
||||
AgentConfigSnapshot,
|
||||
AgentConfigVersionKind,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
AgentWorkingResourceStatus,
|
||||
AgentWorkspaceBinding,
|
||||
@@ -644,12 +644,6 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent App has no bound Agent")
|
||||
if (
|
||||
agent.source == AgentSource.IMPORTED
|
||||
and not agent.active_config_is_published
|
||||
and invoke_from != InvokeFrom.DEBUGGER
|
||||
):
|
||||
raise AgentAppNotPublishedError("Agent has not been published")
|
||||
if invoke_from == InvokeFrom.DEBUGGER:
|
||||
draft = self._resolve_debug_draft(
|
||||
tenant_id=app_model.tenant_id,
|
||||
@@ -664,9 +658,9 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
"build_draft" if draft.draft_type == AgentConfigDraftType.DEBUG_BUILD else "draft"
|
||||
)
|
||||
return agent, draft.id, config_version_kind, agent_soul
|
||||
# active_config_is_published tracks whether the editable draft matches the active snapshot.
|
||||
# Public runtime must keep serving the active snapshot even when unpublished draft edits exist.
|
||||
if not agent.active_config_snapshot_id:
|
||||
# Dirty drafts do not revoke a published snapshot, while the seeded
|
||||
# create/import snapshot must never become public runtime configuration.
|
||||
if not agent_has_workflow_callable_active_snapshot(session=session, agent=agent):
|
||||
raise AgentAppNotPublishedError("Agent has not been published")
|
||||
conversation_binding = self._resolve_conversation_binding(
|
||||
session=session,
|
||||
|
||||
@@ -13248,6 +13248,7 @@ Model class for AI model.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| access_ready | boolean | | Yes |
|
||||
| api_key_count | integer | | Yes |
|
||||
| api_rph | integer | | Yes |
|
||||
| api_rpm | integer | | Yes |
|
||||
@@ -13313,6 +13314,7 @@ Model class for AI model.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| access_mode | string | | No |
|
||||
| access_ready | boolean | | No |
|
||||
| api_base_url | string | | No |
|
||||
| app_id | string | | No |
|
||||
| backing_app_id | string | | No |
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
|
||||
from libs.helper import to_timestamp
|
||||
from models import Account, Conversation
|
||||
from models import Account, App, Conversation
|
||||
from models.agent import (
|
||||
APP_BACKED_AGENT_SOURCES,
|
||||
Agent,
|
||||
@@ -623,6 +623,7 @@ class AgentComposerService:
|
||||
agent = cls._require_agent(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
if agent.scope != AgentScope.ROSTER or agent.source not in APP_BACKED_AGENT_SOURCES:
|
||||
raise AgentNotFoundError()
|
||||
access_was_ready = agent_has_workflow_callable_active_snapshot(session=session, agent=agent)
|
||||
draft = cls._get_or_create_agent_draft(
|
||||
session=session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -665,6 +666,22 @@ class AgentComposerService:
|
||||
agent.updated_by = account_id
|
||||
draft.base_snapshot_id = version.id
|
||||
draft.updated_by = account_id
|
||||
if not access_was_ready:
|
||||
if not agent.app_id:
|
||||
raise AgentNotFoundError()
|
||||
app = session.scalar(
|
||||
select(App)
|
||||
.where(
|
||||
App.tenant_id == tenant_id,
|
||||
App.id == agent.app_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if app is None:
|
||||
raise AgentNotFoundError()
|
||||
app.enable_site = True
|
||||
app.enable_api = True
|
||||
app.updated_by = account_id
|
||||
session.flush()
|
||||
return {
|
||||
"result": "success",
|
||||
|
||||
@@ -29,6 +29,12 @@ class AgentModelNotConfiguredError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class AgentAccessNotReadyError(BaseHTTPException):
|
||||
error_code = "agent_not_published"
|
||||
description = "Publish the Agent before enabling Web App or API access."
|
||||
code = 409
|
||||
|
||||
|
||||
class AgentBuildSandboxNotFoundError(BaseHTTPException):
|
||||
error_code = "agent_build_sandbox_not_found"
|
||||
description = "The retained Build Sandbox is no longer available."
|
||||
|
||||
@@ -1095,8 +1095,10 @@ class AgentRosterService:
|
||||
session=self._session,
|
||||
)
|
||||
|
||||
target_app.enable_site = source_app.enable_site
|
||||
target_app.enable_api = source_app.enable_api
|
||||
# A copy owns a new publication history. It remains private until its
|
||||
# first successful publish even when the source Agent is public.
|
||||
target_app.enable_site = False
|
||||
target_app.enable_api = False
|
||||
target_app.use_icon_as_answer_icon = source_app.use_icon_as_answer_icon
|
||||
target_app.tracing = source_app.tracing
|
||||
|
||||
@@ -1164,7 +1166,7 @@ class AgentRosterService:
|
||||
target_version.version_note = source_version.version_note
|
||||
target_version.created_by = account_id
|
||||
target_agent.active_config_has_model = agent_soul_has_model(target_version.config_snapshot)
|
||||
target_agent.active_config_is_published = source_agent.active_config_is_published
|
||||
target_agent.active_config_is_published = False
|
||||
target_agent.updated_by = account_id
|
||||
|
||||
def _next_duplicate_agent_name(self, *, tenant_id: str, base_name: str) -> str:
|
||||
|
||||
@@ -482,8 +482,8 @@ class AppDslService:
|
||||
app.icon_type = resolved_icon_type
|
||||
app.icon = icon
|
||||
app.icon_background = icon_background or app_data.get("icon_background", "#FFFFFF")
|
||||
app.enable_site = True
|
||||
app.enable_api = True
|
||||
app.enable_site = app_mode != AppMode.AGENT
|
||||
app.enable_api = app_mode != AppMode.AGENT
|
||||
app.use_icon_as_answer_icon = app_data.get("use_icon_as_answer_icon", False)
|
||||
app.created_by = account.id
|
||||
app.maintainer = account.id
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
|
||||
from configs import dify_config
|
||||
from constants.model_template import default_app_templates
|
||||
from core.agent.entities import AgentToolEntity
|
||||
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
|
||||
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
|
||||
from core.model_manager import ModelManager
|
||||
from core.tools.tool_manager import ToolManager
|
||||
@@ -38,7 +39,7 @@ from models.agent import (
|
||||
from models.model import App, AppMode, AppModelConfig, IconType, Site, load_annotation_reply_config
|
||||
from models.tools import ApiToolProvider
|
||||
from models.workflow import Workflow
|
||||
from services.agent.errors import AgentNameConflictError
|
||||
from services.agent.errors import AgentAccessNotReadyError, AgentNameConflictError
|
||||
from services.agent.home_snapshot_service import AgentHomeSnapshotService
|
||||
from services.agent.retirement_service import WorkflowAgentRetirementService
|
||||
from services.agent.workspace_service import AgentWorkspaceService
|
||||
@@ -915,6 +916,30 @@ class AppService:
|
||||
|
||||
return app
|
||||
|
||||
@staticmethod
|
||||
def is_agent_app_access_ready(app: App, *, session: Session) -> bool:
|
||||
"""Return whether an Agent App has a publish-visible active snapshot."""
|
||||
|
||||
if app.mode != AppMode.AGENT:
|
||||
return True
|
||||
agent = session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == app.tenant_id,
|
||||
Agent.app_id == app.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return bool(agent and agent_has_workflow_callable_active_snapshot(session=session, agent=agent))
|
||||
|
||||
@classmethod
|
||||
def ensure_agent_app_access_ready(cls, app: App, *, session: Session) -> None:
|
||||
if not cls.is_agent_app_access_ready(app, session=session):
|
||||
raise AgentAccessNotReadyError()
|
||||
|
||||
def update_app_site_status(self, app: App, enable_site: bool, *, session: Session) -> App:
|
||||
"""
|
||||
Update app site status
|
||||
@@ -922,6 +947,8 @@ class AppService:
|
||||
:param enable_site: enable site status
|
||||
:return: App instance
|
||||
"""
|
||||
if enable_site:
|
||||
self.ensure_agent_app_access_ready(app, session=session)
|
||||
if enable_site == app.enable_site:
|
||||
return app
|
||||
assert current_user is not None
|
||||
@@ -941,6 +968,8 @@ class AppService:
|
||||
:param enable_api: enable api status
|
||||
:return: App instance
|
||||
"""
|
||||
if enable_api:
|
||||
self.ensure_agent_app_access_ready(app, session=session)
|
||||
if enable_api == app.enable_api:
|
||||
return app
|
||||
assert current_user is not None
|
||||
|
||||
@@ -1201,6 +1201,10 @@ class TestAppDslService:
|
||||
)
|
||||
assert imported_agent is not None
|
||||
assert imported_agent.active_config_is_published is False
|
||||
imported_app = db_session_with_containers.get(App, result.app_id)
|
||||
assert imported_app is not None
|
||||
assert imported_app.enable_site is False
|
||||
assert imported_app.enable_api is False
|
||||
draft = db_session_with_containers.scalar(
|
||||
select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.agent_id == imported_agent.id,
|
||||
|
||||
@@ -8,7 +8,15 @@ from sqlalchemy.orm import Session
|
||||
from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
)
|
||||
from models.model import AppAnnotationSetting
|
||||
|
||||
|
||||
@@ -55,6 +63,7 @@ def _persist_snapshot(
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
config_snapshot: dict[str, Any],
|
||||
publish_visible: bool = True,
|
||||
) -> AgentConfigSnapshot:
|
||||
snapshot = AgentConfigSnapshot(
|
||||
id=snapshot_id,
|
||||
@@ -65,13 +74,42 @@ def _persist_snapshot(
|
||||
config_snapshot=config_snapshot,
|
||||
)
|
||||
session.add(snapshot)
|
||||
if publish_visible:
|
||||
_persist_publish_revision(
|
||||
session,
|
||||
snapshot_id=snapshot_id,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
commit=False,
|
||||
)
|
||||
session.commit()
|
||||
return snapshot
|
||||
|
||||
|
||||
def _persist_publish_revision(
|
||||
session: Session,
|
||||
*,
|
||||
snapshot_id: str,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
session.add(
|
||||
AgentConfigRevision(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
current_snapshot_id=snapshot_id,
|
||||
revision=1,
|
||||
operation=AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
)
|
||||
)
|
||||
if commit:
|
||||
session.commit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(Agent, AgentConfigSnapshot, AppAnnotationSetting)],
|
||||
[(Agent, AgentConfigSnapshot, AgentConfigRevision, AppAnnotationSetting)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_published_agent_app_parameters_use_soul_file_upload(sqlite_session: Session):
|
||||
@@ -137,7 +175,7 @@ def test_published_agent_app_parameters_use_soul_file_upload(sqlite_session: Ses
|
||||
assert parameters["user_input_form"] == [{"text-input": {"label": "topic", "variable": "topic", "required": True}}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
|
||||
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True)
|
||||
def test_published_agent_app_parameters_requires_bound_agent(sqlite_session: Session):
|
||||
tenant_id = _stable_uuid("tenant:unbound")
|
||||
app_model = _app_model(tenant_id=tenant_id, bound_agent_id=None)
|
||||
@@ -146,7 +184,7 @@ def test_published_agent_app_parameters_requires_bound_agent(sqlite_session: Ses
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
|
||||
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True)
|
||||
def test_published_agent_app_parameters_requires_existing_active_agent(sqlite_session: Session):
|
||||
requested_tenant_id = _stable_uuid("tenant:requested")
|
||||
agent_id = _stable_uuid("agent:cross-tenant")
|
||||
@@ -170,7 +208,7 @@ def test_published_agent_app_parameters_requires_existing_active_agent(sqlite_se
|
||||
False,
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
|
||||
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True)
|
||||
def test_published_agent_app_parameters_requires_published_agent(
|
||||
active_config_is_published: bool, sqlite_session: Session
|
||||
):
|
||||
@@ -189,7 +227,7 @@ def test_published_agent_app_parameters_requires_published_agent(
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
|
||||
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True)
|
||||
def test_published_agent_app_parameters_allows_unpublished_draft_with_active_snapshot(sqlite_session: Session):
|
||||
tenant_id = _stable_uuid("tenant:unpublished-draft")
|
||||
agent_id = _stable_uuid("agent:unpublished-draft")
|
||||
@@ -219,7 +257,33 @@ def test_published_agent_app_parameters_allows_unpublished_draft_with_active_sna
|
||||
assert user_input_form == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
|
||||
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True)
|
||||
def test_published_agent_app_parameters_rejects_seeded_unpublished_snapshot(sqlite_session: Session):
|
||||
tenant_id = _stable_uuid("tenant:never-published")
|
||||
agent_id = _stable_uuid("agent:never-published")
|
||||
snapshot_id = _stable_uuid("snapshot:never-published")
|
||||
app_model = _app_model(tenant_id=tenant_id, bound_agent_id=agent_id)
|
||||
_persist_agent(
|
||||
sqlite_session,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
active_config_snapshot_id=snapshot_id,
|
||||
active_config_is_published=False,
|
||||
)
|
||||
_persist_snapshot(
|
||||
sqlite_session,
|
||||
snapshot_id=snapshot_id,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
config_snapshot={},
|
||||
publish_visible=False,
|
||||
)
|
||||
|
||||
with pytest.raises(AgentAppNotPublishedError, match="not been published"):
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True)
|
||||
def test_published_agent_app_parameters_requires_published_snapshot(sqlite_session: Session):
|
||||
tenant_id = _stable_uuid("tenant:missing-snapshot")
|
||||
agent_id = _stable_uuid("agent:missing-snapshot")
|
||||
@@ -231,12 +295,18 @@ def test_published_agent_app_parameters_requires_published_snapshot(sqlite_sessi
|
||||
active_config_snapshot_id=_stable_uuid("snapshot:missing"),
|
||||
active_config_is_published=True,
|
||||
)
|
||||
_persist_publish_revision(
|
||||
sqlite_session,
|
||||
snapshot_id=_stable_uuid("snapshot:missing"),
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
|
||||
with pytest.raises(AgentAppGeneratorError, match="published version not found"):
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True)
|
||||
@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True)
|
||||
def test_published_agent_app_parameters_allows_missing_legacy_app_model_config(sqlite_session: Session):
|
||||
tenant_id = _stable_uuid("tenant:no-legacy-config")
|
||||
agent_id = _stable_uuid("agent:no-legacy-config")
|
||||
|
||||
@@ -459,6 +459,11 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
|
||||
"get_system_features",
|
||||
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller,
|
||||
"agent_has_workflow_callable_active_snapshot",
|
||||
lambda **_kwargs: False,
|
||||
)
|
||||
|
||||
class FakeAppService:
|
||||
def get_app(self, app_obj: object, *, session: object) -> object:
|
||||
@@ -482,6 +487,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
|
||||
assert detail["debug_conversation_has_messages"] is True
|
||||
assert detail["debug_conversation_message_count"] == 2
|
||||
assert detail["role"] == "Resolved role"
|
||||
assert detail["access_ready"] is False
|
||||
assert "active_config_is_published" not in detail
|
||||
assert "bound_agent_id" not in detail
|
||||
assert captured["get_app"] == {"app": app_model, "session": session}
|
||||
@@ -699,12 +705,19 @@ def test_agent_publish_and_build_draft_routes_call_composer_service(
|
||||
def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
agent_id = "00000000-0000-0000-0000-000000000001"
|
||||
app_model = SimpleNamespace(
|
||||
id="app-1", enable_api=True, api_base_url="https://api.example.test/v1", api_rpm=60, api_rph=600
|
||||
id="app-1",
|
||||
tenant_id="tenant-1",
|
||||
enable_api=True,
|
||||
api_base_url="https://api.example.test/v1",
|
||||
api_rpm=60,
|
||||
api_rph=600,
|
||||
)
|
||||
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda _session, **kwargs: app_model)
|
||||
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda _session, app_id: 2)
|
||||
monkeypatch.setattr(roster_controller, "_agent_app_access_ready", lambda _session, _app: True)
|
||||
response = unwrap(AgentApiAccessApi.get)(AgentApiAccessApi(), MagicMock(), "tenant-1", agent_id)
|
||||
assert response == {
|
||||
"access_ready": True,
|
||||
"enabled": True,
|
||||
"service_api_base_url": "https://api.example.test/v1",
|
||||
"streaming_only": True,
|
||||
@@ -726,13 +739,19 @@ def test_agent_api_status_and_key_routes_resolve_backing_app(app: Flask, monkeyp
|
||||
agent_id = "00000000-0000-0000-0000-000000000001"
|
||||
api_key_id = "00000000-0000-0000-0000-000000000002"
|
||||
app_model = SimpleNamespace(
|
||||
id="app-1", enable_api=False, api_base_url="https://api.example.test/v1", api_rpm=0, api_rph=0
|
||||
id="app-1",
|
||||
tenant_id="tenant-1",
|
||||
enable_api=False,
|
||||
api_base_url="https://api.example.test/v1",
|
||||
api_rpm=0,
|
||||
api_rph=0,
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
session = MagicMock()
|
||||
resolve_app = Mock(return_value=app_model)
|
||||
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", resolve_app)
|
||||
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda _session, app_id: 1)
|
||||
monkeypatch.setattr(roster_controller, "_agent_app_access_ready", lambda _session, _app: True)
|
||||
|
||||
class FakeAppService:
|
||||
def update_app_api_status(self, app_obj: object, enable_api: bool, *, session: object) -> object:
|
||||
@@ -1363,7 +1382,6 @@ def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id(
|
||||
|
||||
|
||||
def test_agent_chat_stream_preflight_raises_first_error_event() -> None:
|
||||
|
||||
class ClosableStream:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
@@ -1492,7 +1510,6 @@ def test_build_chat_finalization_helper_forces_debug_build_and_push_prompt(
|
||||
|
||||
|
||||
def test_drain_streaming_generate_response_returns_on_message_end() -> None:
|
||||
|
||||
class ClosableResponse:
|
||||
def __init__(self) -> None:
|
||||
self._chunks = iter(
|
||||
|
||||
@@ -13,7 +13,8 @@ from controllers.console.apikey import BaseApiKeyListResource, BaseApiKeyResourc
|
||||
from models import Account
|
||||
from models.account import AccountStatus, TenantAccountRole
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App
|
||||
from models.model import ApiToken, App, AppMode
|
||||
from services.agent.errors import AgentAccessNotReadyError
|
||||
|
||||
|
||||
def _make_list_resource() -> BaseApiKeyListResource:
|
||||
@@ -110,6 +111,24 @@ def test_create_api_key_uses_injected_session_and_tenant_id() -> None:
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_create_agent_api_key_requires_published_access() -> None:
|
||||
resource = _make_list_resource()
|
||||
app = App(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT)
|
||||
session = MagicMock()
|
||||
session.execute.return_value.scalar_one_or_none.return_value = app
|
||||
|
||||
with patch(
|
||||
"controllers.console.apikey.AppService.ensure_agent_app_access_ready",
|
||||
side_effect=AgentAccessNotReadyError(),
|
||||
) as ensure_access_ready:
|
||||
with pytest.raises(AgentAccessNotReadyError):
|
||||
resource._create_api_key("app-1", "tenant-1", session=session)
|
||||
|
||||
ensure_access_ready.assert_called_once_with(app, session=session)
|
||||
session.scalar.assert_not_called()
|
||||
session.add.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_api_key_rejects_non_admin_account() -> None:
|
||||
resource = _make_key_resource()
|
||||
raw_delete = cast(
|
||||
|
||||
@@ -13,6 +13,7 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.apps.agent_app import app_generator
|
||||
from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from models.agent import AgentConfigDraft, AgentConfigDraftType, AgentConfigVersionKind, AgentScope, AgentSource
|
||||
@@ -238,6 +239,19 @@ class TestResolveDebugDraft:
|
||||
|
||||
|
||||
class TestResolveAgent:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _publish_visibility(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def is_publish_visible(*, agent: SimpleNamespace, **_kwargs: object) -> bool:
|
||||
if "publish_visible" in vars(agent):
|
||||
return bool(agent.publish_visible)
|
||||
return bool(agent.active_config_is_published)
|
||||
|
||||
monkeypatch.setattr(
|
||||
app_generator,
|
||||
"agent_has_workflow_callable_active_snapshot",
|
||||
is_publish_visible,
|
||||
)
|
||||
|
||||
def test_success_chains_to_resolve_by_id(self):
|
||||
bound_agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
@@ -270,6 +284,7 @@ class TestResolveAgent:
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id="snap-1",
|
||||
active_config_is_published=False,
|
||||
publish_visible=True,
|
||||
)
|
||||
inner_agent = SimpleNamespace(id="agent-1")
|
||||
snapshot = _snapshot()
|
||||
@@ -428,6 +443,24 @@ class TestResolveAgent:
|
||||
session=session,
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
def test_never_published_agent_app_is_not_available_to_public_runtime(self):
|
||||
bound_agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id="snap-1",
|
||||
active_config_is_published=False,
|
||||
publish_visible=False,
|
||||
)
|
||||
|
||||
with pytest.raises(AgentAppNotPublishedError, match="not been published"):
|
||||
AgentAppGenerator()._resolve_agent(
|
||||
SimpleNamespace(id="app-1", tenant_id="t1"),
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
draft_type=None,
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
session=_FakeScalarSession([bound_agent]),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
def test_unpublished_imported_agent_remains_available_to_debugger(self):
|
||||
bound_agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
|
||||
@@ -856,6 +856,7 @@ def test_publish_agent_app_draft_rejects_missing_model(monkeypatch: pytest.Monke
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
app_id="app-1",
|
||||
active_config_snapshot_id="version-1",
|
||||
active_config_is_published=False,
|
||||
)
|
||||
@@ -877,6 +878,7 @@ def test_publish_agent_app_draft_rejects_missing_model(monkeypatch: pytest.Monke
|
||||
raise AssertionError("knowledge datasets must not be validated when Agent Soul has no model")
|
||||
|
||||
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
|
||||
monkeypatch.setattr(composer_service, "agent_has_workflow_callable_active_snapshot", lambda **_kwargs: False)
|
||||
monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", fail_validate_knowledge_datasets)
|
||||
monkeypatch.setattr(AgentComposerService, "_create_config_version", fail_create_config_version)
|
||||
|
||||
@@ -908,6 +910,7 @@ def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest.
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
app_id="app-1",
|
||||
active_config_snapshot_id="version-1",
|
||||
)
|
||||
draft = AgentConfigDraft(
|
||||
@@ -920,12 +923,16 @@ def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest.
|
||||
config_snapshot=_agent_soul_with_model(),
|
||||
)
|
||||
version = SimpleNamespace(id="version-2")
|
||||
session.add_all([agent, draft])
|
||||
app = _app(mode=AppMode.AGENT)
|
||||
app.enable_site = False
|
||||
app.enable_api = False
|
||||
session.add_all([agent, draft, app])
|
||||
session.commit()
|
||||
created: dict[str, object] = {}
|
||||
calls: list[str] = []
|
||||
|
||||
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
|
||||
monkeypatch.setattr(composer_service, "agent_has_workflow_callable_active_snapshot", lambda **_kwargs: False)
|
||||
monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", lambda **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
composer_service,
|
||||
@@ -957,6 +964,9 @@ def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest.
|
||||
assert agent.active_config_snapshot_id == "version-2"
|
||||
assert agent.active_config_has_model is True
|
||||
assert agent.active_config_is_published is True
|
||||
assert app.enable_site is True
|
||||
assert app.enable_api is True
|
||||
assert app.updated_by == "account-1"
|
||||
|
||||
|
||||
def test_repeated_publish_reuses_normal_draft_home_without_creating_resources(
|
||||
@@ -973,6 +983,7 @@ def test_repeated_publish_reuses_normal_draft_home_without_creating_resources(
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
app_id="app-1",
|
||||
active_config_snapshot_id="version-1",
|
||||
)
|
||||
draft = AgentConfigDraft(
|
||||
@@ -984,12 +995,21 @@ def test_repeated_publish_reuses_normal_draft_home_without_creating_resources(
|
||||
home_snapshot_id="home-1",
|
||||
config_snapshot=_agent_soul_with_model(),
|
||||
)
|
||||
session.add_all([agent, draft])
|
||||
app = _app(mode=AppMode.AGENT)
|
||||
app.enable_site = False
|
||||
app.enable_api = False
|
||||
session.add_all([agent, draft, app])
|
||||
session.commit()
|
||||
published_homes: list[str] = []
|
||||
versions = iter([SimpleNamespace(id="version-2"), SimpleNamespace(id="version-3")])
|
||||
create_from_build = MagicMock()
|
||||
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda _payload: None)
|
||||
publish_visibility = iter([False, True])
|
||||
monkeypatch.setattr(
|
||||
composer_service,
|
||||
"agent_has_workflow_callable_active_snapshot",
|
||||
lambda **_kwargs: next(publish_visibility),
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", lambda **_kwargs: None)
|
||||
monkeypatch.setattr(composer_service, "validate_home_snapshot_binding", lambda **_kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
@@ -1007,6 +1027,8 @@ def test_repeated_publish_reuses_normal_draft_home_without_creating_resources(
|
||||
agent_id="agent-1",
|
||||
account_id="account-1",
|
||||
)
|
||||
app.enable_site = False
|
||||
app.enable_api = False
|
||||
second = AgentComposerService.publish_agent_app_draft(
|
||||
session=session,
|
||||
tenant_id="tenant-1",
|
||||
@@ -1018,6 +1040,8 @@ def test_repeated_publish_reuses_normal_draft_home_without_creating_resources(
|
||||
assert second["active_config_snapshot_id"] == "version-3"
|
||||
assert published_homes == ["home-1", "home-1"]
|
||||
assert draft.home_snapshot_id == "home-1"
|
||||
assert app.enable_site is False
|
||||
assert app.enable_api is False
|
||||
create_from_build.assert_not_called()
|
||||
|
||||
|
||||
@@ -5264,8 +5288,8 @@ class TestAgentAppBackingAgent:
|
||||
id="target-app",
|
||||
app_model_config=target_config,
|
||||
app_model_config_with_session=lambda *, session: target_config,
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
enable_site=False,
|
||||
enable_api=False,
|
||||
use_icon_as_answer_icon=False,
|
||||
tracing=None,
|
||||
)
|
||||
@@ -5363,7 +5387,7 @@ class TestAgentAppBackingAgent:
|
||||
assert params.mode == "agent"
|
||||
assert params.agent_role == "Analyst"
|
||||
assert target_app.enable_site is False
|
||||
assert target_app.enable_api is True
|
||||
assert target_app.enable_api is False
|
||||
assert target_app.use_icon_as_answer_icon is True
|
||||
assert target_app.tracing == "{}"
|
||||
assert target_config.opening_statement == "hello"
|
||||
@@ -5377,6 +5401,7 @@ class TestAgentAppBackingAgent:
|
||||
assert target_version.summary == "configured"
|
||||
assert target_version.version_note == "v1"
|
||||
assert target_agent.active_config_has_model is True
|
||||
assert target_agent.active_config_is_published is False
|
||||
assert target_agent.updated_by == "account-1"
|
||||
assert session.get(Agent, target_agent.id) is target_agent
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models import Account, Tenant
|
||||
from models.model import App, AppMode, AppModelConfig, IconType
|
||||
from models.workflow import Workflow
|
||||
from services.agent.errors import AgentNameConflictError
|
||||
from services.agent.errors import AgentAccessNotReadyError, AgentNameConflictError
|
||||
from services.app_service import AppListParams, AppService, CreateAppParams
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ class TestCreateAppTransactionBoundary:
|
||||
[AppService.update_app_site_status, AppService.update_app_api_status],
|
||||
)
|
||||
def test_app_status_updates_commit_before_signal(update_status: Callable[..., App]) -> None:
|
||||
app = cast(App, SimpleNamespace(enable_site=False, enable_api=False))
|
||||
app = cast(App, SimpleNamespace(enable_site=False, enable_api=False, mode=AppMode.CHAT))
|
||||
session = MagicMock()
|
||||
phase_events: list[str] = []
|
||||
session.commit.side_effect = lambda: phase_events.append("commit")
|
||||
@@ -123,6 +123,36 @@ def test_app_status_updates_commit_before_signal(update_status: Callable[..., Ap
|
||||
assert phase_events == ["commit", "signal"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"update_status",
|
||||
[
|
||||
AppService.update_app_site_status,
|
||||
AppService.update_app_api_status,
|
||||
],
|
||||
)
|
||||
def test_unpublished_agent_app_access_cannot_be_enabled(update_status: Callable[..., App]) -> None:
|
||||
app = cast(
|
||||
App,
|
||||
SimpleNamespace(
|
||||
id="app-1",
|
||||
tenant_id="tenant-1",
|
||||
mode=AppMode.AGENT,
|
||||
enable_site=False,
|
||||
enable_api=False,
|
||||
),
|
||||
)
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = SimpleNamespace(id="agent-1")
|
||||
|
||||
with patch("services.app_service.agent_has_workflow_callable_active_snapshot", return_value=False):
|
||||
with pytest.raises(AgentAccessNotReadyError):
|
||||
update_status(AppService(), app, True, session=session)
|
||||
|
||||
assert app.enable_site is False
|
||||
assert app.enable_api is False
|
||||
session.commit.assert_not_called()
|
||||
|
||||
|
||||
class TestOpenapiVisibilityHelpers:
|
||||
"""Coverage for the session-injected, openapi-visibility-scoped
|
||||
``AppService`` getters used by ``/openapi/v1/apps*``. These helpers
|
||||
@@ -411,6 +441,8 @@ class TestAgentAppType:
|
||||
# Runtime config comes from the Agent Soul, so no model_config is seeded.
|
||||
assert "model_config" not in default_app_templates[AppMode.AGENT]
|
||||
assert default_app_templates[AppMode.AGENT]["app"]["mode"] == AppMode.AGENT
|
||||
assert default_app_templates[AppMode.AGENT]["app"]["enable_site"] is False
|
||||
assert default_app_templates[AppMode.AGENT]["app"]["enable_api"] is False
|
||||
|
||||
def test_create_app_params_accepts_agent_mode(self):
|
||||
from services.app_service import CreateAppParams
|
||||
|
||||
Reference in New Issue
Block a user