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:
zyssyz123
2026-08-06 12:36:24 +00:00
committed by GitHub
co-authored by autofix-ci[bot] yyh
parent 05c7386013
commit 90d6046345
34 changed files with 442 additions and 149 deletions
+3 -2
View File
@@ -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(
+15 -1
View File
@@ -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",
+4 -1
View File
@@ -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(
+4 -10
View File
@@ -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,
+2
View File
@@ -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 |
+18 -1
View File
@@ -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",
+6
View File
@@ -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."
+5 -3
View File
@@ -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:
+2 -2
View File
@@ -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
+30 -1
View File
@@ -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