From b3e5f29421d1695c07c8794f05523ad163483b21 Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:40:05 -0700 Subject: [PATCH] fix(app): derive get-app --mode whitelist from listable app types (#37761) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/openapi/_models.py | 33 ++++++- api/controllers/openapi/apps.py | 9 ++ api/openapi/markdown/openapi-openapi.md | 28 +++++- .../controllers/openapi/_mode_constants.py | 10 ++ .../openapi/test_app_list_query.py | 55 ++++++----- .../controllers/openapi/test_app_payloads.py | 15 +++ .../test_apps_permitted_external_query.py | 11 ++- .../openapi/test_supported_app_type.py | 24 +++++ cli/src/api/apps.ts | 6 +- cli/src/commands/get/app/index.ts | 18 ++-- .../commands/get/app/mode-whitelist.test.ts | 13 +++ cli/src/commands/get/app/run.ts | 4 +- .../e2e/suites/discovery/get-app-list.e2e.ts | 13 +++ .../generated/api/openapi/types.gen.ts | 26 ++--- .../generated/api/openapi/zod.gen.ts | 99 ++++++++++--------- 15 files changed, 246 insertions(+), 118 deletions(-) create mode 100644 api/tests/unit_tests/controllers/openapi/_mode_constants.py create mode 100644 api/tests/unit_tests/controllers/openapi/test_supported_app_type.py create mode 100644 cli/src/commands/get/app/mode-whitelist.test.ts diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index e846db3ea75..6e8a9c9d439 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Any, Literal +from enum import StrEnum +from typing import Any, Final, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -13,6 +14,30 @@ from models.model import AppMode MAX_PAGE_LIMIT = 200 +class SupportedAppType(StrEnum): + """App types the ``app`` usage face (``get app``) lists and filters. + + A curated subset of :class:`AppMode`: the real, user-facing app categories. + Excludes runtime-only mode tags that are not standalone apps + (``rag-pipeline`` is a knowledge ``Pipeline``; ``channel`` is unused) and the + roster-owned ``agent`` type (surfaced through the roster, not this list). + + Members reference ``AppMode.*.value`` so the subset relationship is + type-checked: dropping a member from ``AppMode`` breaks this at import. + This is the single source for the listable set — params, filters, and the + generated CLI whitelist all derive from it. + """ + + COMPLETION = AppMode.COMPLETION.value + CHAT = AppMode.CHAT.value + ADVANCED_CHAT = AppMode.ADVANCED_CHAT.value + WORKFLOW = AppMode.WORKFLOW.value + AGENT_CHAT = AppMode.AGENT_CHAT.value + + +SUPPORTED_APP_TYPES: Final[tuple[AppMode, ...]] = tuple(AppMode(t.value) for t in SupportedAppType) + + class UsageInfo(BaseModel): prompt_tokens: int = 0 completion_tokens: int = 0 @@ -279,12 +304,12 @@ class AppDescribeQuery(BaseModel): class AppListQuery(BaseModel): - """mode is a closed enum.""" + """mode is a closed enum of listable app types.""" workspace_id: UUIDStr page: int = Field(1, ge=1) limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT) - mode: AppMode | None = None + mode: SupportedAppType | None = None name: str | None = Field(None, max_length=200) @@ -335,7 +360,7 @@ class PermittedExternalAppsListQuery(BaseModel): page: int = Field(1, ge=1) limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT) - mode: AppMode | None = None + mode: SupportedAppType | None = None name: str | None = Field(None, max_length=200) diff --git a/api/controllers/openapi/apps.py b/api/controllers/openapi/apps.py index c2626cd5d8c..181af5c0742 100644 --- a/api/controllers/openapi/apps.py +++ b/api/controllers/openapi/apps.py @@ -16,6 +16,7 @@ from controllers.openapi import openapi_ns from controllers.openapi._contract import accepts, returns from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA, build_input_schema, resolve_app_config from controllers.openapi._models import ( + SUPPORTED_APP_TYPES, AppDescribeInfo, AppDescribeQuery, AppDescribeResponse, @@ -37,6 +38,11 @@ from services.app_service import AppListParams, AppService _ALLOWED_DESCRIBE_FIELDS: frozenset[str] = frozenset({"info", "parameters", "input_schema"}) +def _is_listable(app: App) -> bool: + """Whether the openapi app face exposes this app (curated, listable types only).""" + return app.mode in SUPPORTED_APP_TYPES + + _EMPTY_PARAMETERS: dict[str, Any] = { "opening_statement": None, "suggested_questions": [], @@ -171,6 +177,8 @@ class AppListApi(Resource): app: App | None = AppService.get_visible_app_by_id(db.session, str(parsed_uuid)) if app is None or str(app.tenant_id) != workspace_id: return empty + if not _is_listable(app): + return empty # Apply RBAC visibility to the UUID fast-path the same way the service # layer does for paginated queries (id in accessible set OR own app). if apply_rbac_filter and not access_filter.is_app_accessible( @@ -223,6 +231,7 @@ class AppListApi(Resource): workspace_name=tenant_name, ) for r in pagination.items + if _is_listable(r) ] env = AppListResponse( diff --git a/api/openapi/markdown/openapi-openapi.md b/api/openapi/markdown/openapi-openapi.md index bd93557edcf..4bb6761c22e 100644 --- a/api/openapi/markdown/openapi-openapi.md +++ b/api/openapi/markdown/openapi-openapi.md @@ -80,7 +80,7 @@ User-scoped operations | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | limit | query | | No | integer,
**Default:** 20 | -| mode | query | | No | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "channel", "chat", "completion", "rag-pipeline", "workflow" | +| mode | query | App types the ``app`` usage face (``get app``) lists and filters. A curated subset of :class:`AppMode`: the real, user-facing app categories. Excludes runtime-only mode tags that are not standalone apps (``rag-pipeline`` is a knowledge ``Pipeline``; ``channel`` is unused) and the roster-owned ``agent`` type (surfaced through the roster, not this list). Members reference ``AppMode.*.value`` so the subset relationship is type-checked: dropping a member from ``AppMode`` breaks this at import. This is the single source for the listable set — params, filters, and the generated CLI whitelist all derive from it. | No | string,
**Available values:** "advanced-chat", "agent-chat", "chat", "completion", "workflow" | | name | query | | No | string | | page | query | | No | integer,
**Default:** 1 | | workspace_id | query | | Yes | string | @@ -318,7 +318,7 @@ Upload a file to use as an input variable when running the app | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | limit | query | | No | integer,
**Default:** 20 | -| mode | query | | No | string,
**Available values:** "advanced-chat", "agent", "agent-chat", "channel", "chat", "completion", "rag-pipeline", "workflow" | +| mode | query | App types the ``app`` usage face (``get app``) lists and filters. A curated subset of :class:`AppMode`: the real, user-facing app categories. Excludes runtime-only mode tags that are not standalone apps (``rag-pipeline`` is a knowledge ``Pipeline``; ``channel`` is unused) and the roster-owned ``agent`` type (surfaced through the roster, not this list). Members reference ``AppMode.*.value`` so the subset relationship is type-checked: dropping a member from ``AppMode`` breaks this at import. This is the single source for the listable set — params, filters, and the generated CLI whitelist all derive from it. | No | string,
**Available values:** "advanced-chat", "agent-chat", "chat", "completion", "workflow" | | name | query | | No | string | | page | query | | No | integer,
**Default:** 1 | @@ -592,12 +592,12 @@ Request body for POST /workspaces//apps/imports. #### AppListQuery -mode is a closed enum. +mode is a closed enum of listable app types. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | limit | integer,
**Default:** 20 | | No | -| mode | [AppMode](#appmode) | | No | +| mode | [SupportedAppType](#supportedapptype) | | No | | name | string | | No | | page | integer,
**Default:** 1 | | No | | workspace_id | string | | Yes | @@ -922,7 +922,7 @@ Strict (extra='forbid'). | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | limit | integer,
**Default:** 20 | | No | -| mode | [AppMode](#appmode) | | No | +| mode | [SupportedAppType](#supportedapptype) | | No | | name | string | | No | | page | integer,
**Default:** 1 | | No | @@ -990,6 +990,24 @@ Pagination for GET /account/sessions. Strict (extra='forbid'). | last_used_at | string | | No | | prefix | string | | Yes | +#### SupportedAppType + +App types the ``app`` usage face (``get app``) lists and filters. + +A curated subset of :class:`AppMode`: the real, user-facing app categories. +Excludes runtime-only mode tags that are not standalone apps +(``rag-pipeline`` is a knowledge ``Pipeline``; ``channel`` is unused) and the +roster-owned ``agent`` type (surfaced through the roster, not this list). + +Members reference ``AppMode.*.value`` so the subset relationship is +type-checked: dropping a member from ``AppMode`` breaks this at import. +This is the single source for the listable set — params, filters, and the +generated CLI whitelist all derive from it. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| SupportedAppType | string | App types the ``app`` usage face (``get app``) lists and filters. A curated subset of :class:`AppMode`: the real, user-facing app categories. Excludes runtime-only mode tags that are not standalone apps (``rag-pipeline`` is a knowledge ``Pipeline``; ``channel`` is unused) and the roster-owned ``agent`` type (surfaced through the roster, not this list). Members reference ``AppMode.*.value`` so the subset relationship is type-checked: dropping a member from ``AppMode`` breaks this at import. This is the single source for the listable set — params, filters, and the generated CLI whitelist all derive from it. | | + #### TaskStopResponse 200 body for POST /apps//tasks//stop. The handler always returns diff --git a/api/tests/unit_tests/controllers/openapi/_mode_constants.py b/api/tests/unit_tests/controllers/openapi/_mode_constants.py new file mode 100644 index 00000000000..2a8e477c754 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/_mode_constants.py @@ -0,0 +1,10 @@ +"""Shared mode lists for the openapi app-list query tests. + +Single source so adding/removing a listable app type is a one-line change +across every query-validator test. +""" + +from __future__ import annotations + +LISTABLE_MODES = ["completion", "chat", "advanced-chat", "workflow", "agent-chat"] +NON_LISTABLE_MODES = ["rag-pipeline", "channel", "agent"] diff --git a/api/tests/unit_tests/controllers/openapi/test_app_list_query.py b/api/tests/unit_tests/controllers/openapi/test_app_list_query.py index e0b15585323..7cc2149baff 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_list_query.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_list_query.py @@ -4,7 +4,7 @@ Runs against the model directly, not the HTTP layer. Pins: - defaults match the plan (page=1, limit=20). - workspace_id is required. - numeric bounds enforced (page >= 1, limit in [1, MAX_PAGE_LIMIT]). -- mode validates against the AppMode enum. +- mode validates against the SupportedAppType enum (listable app types only). - name has a length cap. """ @@ -16,10 +16,14 @@ from pydantic import ValidationError from controllers.openapi._models import MAX_PAGE_LIMIT from controllers.openapi.apps import AppListQuery +from ._mode_constants import LISTABLE_MODES, NON_LISTABLE_MODES + +WS_ID = "00000000-0000-0000-0000-000000000001" + def test_defaults(): - q = AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001"}) - assert q.workspace_id == "00000000-0000-0000-0000-000000000001" + q = AppListQuery.model_validate({"workspace_id": WS_ID}) + assert q.workspace_id == WS_ID assert q.page == 1 assert q.limit == 20 assert q.mode is None @@ -33,64 +37,71 @@ def test_workspace_id_required(): def test_page_must_be_positive(): with pytest.raises(ValidationError): - AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "page": 0}) + AppListQuery.model_validate({"workspace_id": WS_ID, "page": 0}) with pytest.raises(ValidationError): - AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "page": -1}) + AppListQuery.model_validate({"workspace_id": WS_ID, "page": -1}) def test_page_rejects_non_integer_string(): with pytest.raises(ValidationError): - AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "page": "abc"}) + AppListQuery.model_validate({"workspace_id": WS_ID, "page": "abc"}) def test_limit_must_be_positive(): with pytest.raises(ValidationError): - AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "limit": 0}) + AppListQuery.model_validate({"workspace_id": WS_ID, "limit": 0}) with pytest.raises(ValidationError): - AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "limit": -1}) + AppListQuery.model_validate({"workspace_id": WS_ID, "limit": -1}) def test_limit_caps_at_max_page_limit(): # Boundary accepts. - q = AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "limit": MAX_PAGE_LIMIT}) + q = AppListQuery.model_validate({"workspace_id": WS_ID, "limit": MAX_PAGE_LIMIT}) assert q.limit == MAX_PAGE_LIMIT # Just over rejects. with pytest.raises(ValidationError): - AppListQuery.model_validate( - {"workspace_id": "00000000-0000-0000-0000-000000000001", "limit": MAX_PAGE_LIMIT + 1} - ) + AppListQuery.model_validate({"workspace_id": WS_ID, "limit": MAX_PAGE_LIMIT + 1}) -def test_mode_whitelisted_against_app_mode(): - # Valid mode passes. - q = AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "mode": "chat"}) +@pytest.mark.parametrize("mode", LISTABLE_MODES) +def test_mode_accepts_listable_app_types(mode: str): + q = AppListQuery.model_validate({"workspace_id": WS_ID, "mode": mode}) assert q.mode is not None - assert q.mode.value == "chat" + assert q.mode.value == mode - # Invalid mode rejects. + +def test_mode_rejects_unknown_value(): with pytest.raises(ValidationError): - AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "mode": "not-a-mode"}) + AppListQuery.model_validate({"workspace_id": WS_ID, "mode": "not-a-mode"}) + + +@pytest.mark.parametrize("mode", NON_LISTABLE_MODES) +def test_mode_rejects_non_listable_app_modes(mode: str): + """rag-pipeline (a knowledge Pipeline), channel (unused) and agent (roster-owned) + are AppMode members but not standalone listable apps — the `app` face rejects them.""" + with pytest.raises(ValidationError): + AppListQuery.model_validate({"workspace_id": WS_ID, "mode": mode}) def test_name_length_capped(): - AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "name": "x" * 200}) + AppListQuery.model_validate({"workspace_id": WS_ID, "name": "x" * 200}) with pytest.raises(ValidationError): - AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "name": "x" * 201}) + AppListQuery.model_validate({"workspace_id": WS_ID, "name": "x" * 201}) def test_all_fields_accept_valid_values(): """Pin the happy-path acceptance for every field in one place.""" q = AppListQuery.model_validate( { - "workspace_id": "00000000-0000-0000-0000-000000000001", + "workspace_id": WS_ID, "page": 5, "limit": 50, "mode": "workflow", "name": "search", } ) - assert q.workspace_id == "00000000-0000-0000-0000-000000000001" + assert q.workspace_id == WS_ID assert q.page == 5 assert q.limit == 50 assert q.mode is not None diff --git a/api/tests/unit_tests/controllers/openapi/test_app_payloads.py b/api/tests/unit_tests/controllers/openapi/test_app_payloads.py index 64cdc382500..12bf4c696f2 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_payloads.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_payloads.py @@ -10,9 +10,11 @@ import pytest from controllers.openapi.apps import ( # pyright: ignore[reportPrivateUsage] _EMPTY_PARAMETERS, + _is_listable, parameters_payload, ) from controllers.service_api.app.error import AppUnavailableError +from models.model import AppMode def _fake_app(**overrides): @@ -53,3 +55,16 @@ def test_empty_parameters_constant_matches_describe_fallback_shape(): assert _EMPTY_PARAMETERS["opening_statement"] is None assert _EMPTY_PARAMETERS["file_upload"] is None assert _EMPTY_PARAMETERS["system_parameters"] == {} + + +@pytest.mark.parametrize( + "mode", + [AppMode.COMPLETION, AppMode.CHAT, AppMode.ADVANCED_CHAT, AppMode.WORKFLOW, AppMode.AGENT_CHAT], +) +def test_is_listable_accepts_supported_app_types(mode): + assert _is_listable(_fake_app(mode=mode)) is True + + +@pytest.mark.parametrize("mode", [AppMode.AGENT, AppMode.CHANNEL, AppMode.RAG_PIPELINE]) +def test_is_listable_hides_non_app_modes(mode): + assert _is_listable(_fake_app(mode=mode)) is False diff --git a/api/tests/unit_tests/controllers/openapi/test_apps_permitted_external_query.py b/api/tests/unit_tests/controllers/openapi/test_apps_permitted_external_query.py index 96873b04f46..0f530e3c3c7 100644 --- a/api/tests/unit_tests/controllers/openapi/test_apps_permitted_external_query.py +++ b/api/tests/unit_tests/controllers/openapi/test_apps_permitted_external_query.py @@ -13,6 +13,8 @@ from pydantic import ValidationError from controllers.openapi.apps_permitted_external import PermittedExternalAppsListQuery +from ._mode_constants import NON_LISTABLE_MODES + def test_query_defaults_match_apps_list(): q = PermittedExternalAppsListQuery.model_validate({}) @@ -36,11 +38,18 @@ def test_query_rejects_tag(): PermittedExternalAppsListQuery.model_validate({"tag": "prod"}) -def test_query_validates_mode_against_app_mode(): +def test_query_validates_mode_against_supported_app_type(): with pytest.raises(ValidationError): PermittedExternalAppsListQuery.model_validate({"mode": "not-a-mode"}) +@pytest.mark.parametrize("mode", NON_LISTABLE_MODES) +def test_query_rejects_non_listable_app_modes(mode: str): + """Non-app runtime modes and roster-owned agent are not listable here.""" + with pytest.raises(ValidationError): + PermittedExternalAppsListQuery.model_validate({"mode": mode}) + + def test_query_clamps_limit_at_max(): with pytest.raises(ValidationError): PermittedExternalAppsListQuery.model_validate({"limit": 500}) diff --git a/api/tests/unit_tests/controllers/openapi/test_supported_app_type.py b/api/tests/unit_tests/controllers/openapi/test_supported_app_type.py new file mode 100644 index 00000000000..3af1c148644 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_supported_app_type.py @@ -0,0 +1,24 @@ +"""Unit tests for SupportedAppType — the listable subset of AppMode that the +openapi `app` face (`get app`) exposes and the CLI `--mode` whitelist derives from. +""" + +from __future__ import annotations + +from controllers.openapi._models import SUPPORTED_APP_TYPES, SupportedAppType +from models.model import AppMode + + +def test_supported_app_type_is_the_listable_subset_of_app_mode(): + """SupportedAppType (and the derived SUPPORTED_APP_TYPES tuple) is exactly the + curated, listable subset of AppMode; non-app/runtime modes stay out.""" + assert {t.value for t in SupportedAppType} == { + "completion", + "chat", + "advanced-chat", + "workflow", + "agent-chat", + } + assert set(SUPPORTED_APP_TYPES) <= set(AppMode) + assert AppMode.AGENT not in SUPPORTED_APP_TYPES + assert AppMode.RAG_PIPELINE not in SUPPORTED_APP_TYPES + assert AppMode.CHANNEL not in SUPPORTED_APP_TYPES diff --git a/cli/src/api/apps.ts b/cli/src/api/apps.ts index 01b18d9a9da..1189fdeaa06 100644 --- a/cli/src/api/apps.ts +++ b/cli/src/api/apps.ts @@ -1,4 +1,4 @@ -import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen' +import type { AppDescribeResponse, AppListResponse, SupportedAppType } from '@dify/contracts/api/openapi/types.gen' import type { AppReader } from './app-reader' import type { OpenApiClient } from '@/http/orpc' import type { HttpClient } from '@/http/types' @@ -8,12 +8,12 @@ export type ListQuery = { readonly workspaceId: string readonly page?: number readonly limit?: number - readonly mode?: AppMode | '' + readonly mode?: SupportedAppType | '' readonly name?: string } // An absent or empty mode filter means "any mode" — collapse both to undefined for the query. -export function normalizeMode(mode: AppMode | '' | undefined): AppMode | undefined { +export function normalizeMode(mode: SupportedAppType | '' | undefined): SupportedAppType | undefined { return mode !== undefined && mode !== '' ? mode : undefined } diff --git a/cli/src/commands/get/app/index.ts b/cli/src/commands/get/app/index.ts index ffce31b7c49..4deed4ade9f 100644 --- a/cli/src/commands/get/app/index.ts +++ b/cli/src/commands/get/app/index.ts @@ -1,4 +1,5 @@ -import type { AppMode } from '@dify/contracts/api/openapi/types.gen' +import type { SupportedAppType } from '@dify/contracts/api/openapi/types.gen' +import { zSupportedAppType } from '@dify/contracts/api/openapi/zod.gen' import { DifyCommand } from '@/commands/_shared/dify-command' import { httpRetryFlag } from '@/commands/_shared/global-flags' import { Args, Flags } from '@/framework/flags' @@ -6,16 +7,9 @@ import { OutputFormat, table } from '@/framework/output' import { agentGuide } from './guide' import { runGetApp } from './run' -const APP_MODE_VALUES: readonly AppMode[] = [ - 'advanced-chat', - 'agent', - 'agent-chat', - 'channel', - 'chat', - 'completion', - 'rag-pipeline', - 'workflow', -] +// Single source: derived from the backend's listable app types (openapi codegen). +// Adding/removing a listable type is a backend-only change that flows here on regen. +const APP_MODE_VALUES: readonly SupportedAppType[] = zSupportedAppType.options export default class GetApp extends DifyCommand { static override description = 'List apps or describe one app\'s basic info' @@ -56,7 +50,7 @@ export default class GetApp extends DifyCommand { allWorkspaces: flags['all-workspaces'], page: flags.page, limitRaw: flags.limit, - mode: flags.mode as AppMode | undefined, + mode: flags.mode as SupportedAppType | undefined, name: flags.name, format, }, { active: ctx.active, http: ctx.http, io: ctx.io }) diff --git a/cli/src/commands/get/app/mode-whitelist.test.ts b/cli/src/commands/get/app/mode-whitelist.test.ts new file mode 100644 index 00000000000..93ecd86402c --- /dev/null +++ b/cli/src/commands/get/app/mode-whitelist.test.ts @@ -0,0 +1,13 @@ +import { zSupportedAppType } from '@dify/contracts/api/openapi/zod.gen' +import { describe, expect, it } from 'vitest' + +// The `get app --mode` whitelist is derived from this generated enum (see index.ts). +// These pins guard the original bug: the CLI must not advertise modes the backend +// rejects (rag-pipeline, channel) or modes that aren't listable here (agent). +describe('get app --mode whitelist', () => { + it('is exactly the listable app types', () => { + expect([...zSupportedAppType.options].sort()).toEqual( + ['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow'], + ) + }) +}) diff --git a/cli/src/commands/get/app/run.ts b/cli/src/commands/get/app/run.ts index c4a7911e0db..9008cfb70c5 100644 --- a/cli/src/commands/get/app/run.ts +++ b/cli/src/commands/get/app/run.ts @@ -1,4 +1,4 @@ -import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen' +import type { AppDescribeResponse, AppListResponse, AppMode, SupportedAppType } from '@dify/contracts/api/openapi/types.gen' import type { AppReader } from '@/api/app-reader' import type { ActiveContext } from '@/auth/hosts' import type { HttpClient } from '@/http/types' @@ -20,7 +20,7 @@ export type GetAppOptions = { readonly allWorkspaces?: boolean readonly page?: number readonly limitRaw?: string - readonly mode?: AppMode + readonly mode?: SupportedAppType readonly name?: string readonly format?: string } diff --git a/cli/test/e2e/suites/discovery/get-app-list.e2e.ts b/cli/test/e2e/suites/discovery/get-app-list.e2e.ts index 2e7623933b0..61d235794ad 100644 --- a/cli/test/e2e/suites/discovery/get-app-list.e2e.ts +++ b/cli/test/e2e/suites/discovery/get-app-list.e2e.ts @@ -177,6 +177,19 @@ describe('E2E / difyctl get app (list)', () => { expect(result.exitCode, '--mode chatbot should cause non-zero exit').not.toBe(0) }) + // Regression: rag-pipeline (a knowledge Pipeline), channel (unused) and agent + // (roster-owned) are AppMode members but not listable app types. The old CLI + // whitelist advertised rag-pipeline/channel, so the CLI forwarded them and the + // server replied 400. The whitelist now derives from SupportedAppType, so the + // CLI rejects them before any HTTP call. + it.each(['rag-pipeline', 'channel', 'agent'])( + '[P0] non-listable mode %s is intercepted client-side', + async (mode) => { + const result = await fx.r(['get', 'app', '--mode', mode]) + expect(result.exitCode, `--mode ${mode} should be rejected client-side`).not.toBe(0) + }, + ) + // ── workspace override ──────────────────────────────────────────────────── it('[P0] -w overrides the default workspace', async () => { diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index 185ee37aa6f..2d47f947247 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -73,7 +73,7 @@ export type AppInfo = { export type AppListQuery = { limit?: number - mode?: AppMode | null + mode?: SupportedAppType | null name?: string | null page?: number workspace_id: string @@ -354,7 +354,7 @@ export type Package = { export type PermittedExternalAppsListQuery = { limit?: number - mode?: AppMode | null + mode?: SupportedAppType | null name?: string | null page?: number } @@ -405,6 +405,8 @@ export type SessionRow = { prefix: string } +export type SupportedAppType = 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow' + export type TaskStopResponse = { result: 'success' } @@ -589,15 +591,7 @@ export type GetAppsData = { path?: never query: { limit?: number - mode?: - | 'advanced-chat' - | 'agent' - | 'agent-chat' - | 'channel' - | 'chat' - | 'completion' - | 'rag-pipeline' - | 'workflow' + mode?: 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow' name?: string page?: number workspace_id: string @@ -905,15 +899,7 @@ export type GetPermittedExternalAppsData = { path?: never query?: { limit?: number - mode?: - | 'advanced-chat' - | 'agent' - | 'agent-chat' - | 'channel' - | 'chat' - | 'completion' - | 'rag-pipeline' - | 'workflow' + mode?: 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow' name?: string page?: number } diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 804c75394f6..557447cc769 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -104,19 +104,6 @@ export const zAppMode = z.enum([ 'workflow', ]) -/** - * AppListQuery - * - * mode is a closed enum. - */ -export const zAppListQuery = z.object({ - limit: z.int().gte(1).lte(200).optional().default(20), - mode: zAppMode.nullish(), - name: z.string().max(200).nullish(), - page: z.int().gte(1).optional().default(1), - workspace_id: z.string(), -}) - /** * AppListRow */ @@ -452,18 +439,6 @@ export const zPackage = z.object({ version: z.string().nullish(), }) -/** - * PermittedExternalAppsListQuery - * - * Strict (extra='forbid'). - */ -export const zPermittedExternalAppsListQuery = z.object({ - limit: z.int().gte(1).lte(200).optional().default(20), - mode: zAppMode.nullish(), - name: z.string().max(200).nullish(), - page: z.int().gte(1).optional().default(1), -}) - /** * PermittedExternalAppsListResponse */ @@ -526,6 +501,54 @@ export const zSessionListResponse = z.object({ total: z.int(), }) +/** + * SupportedAppType + * + * App types the ``app`` usage face (``get app``) lists and filters. + * + * A curated subset of :class:`AppMode`: the real, user-facing app categories. + * Excludes runtime-only mode tags that are not standalone apps + * (``rag-pipeline`` is a knowledge ``Pipeline``; ``channel`` is unused) and the + * roster-owned ``agent`` type (surfaced through the roster, not this list). + * + * Members reference ``AppMode.*.value`` so the subset relationship is + * type-checked: dropping a member from ``AppMode`` breaks this at import. + * This is the single source for the listable set — params, filters, and the + * generated CLI whitelist all derive from it. + */ +export const zSupportedAppType = z.enum([ + 'advanced-chat', + 'agent-chat', + 'chat', + 'completion', + 'workflow', +]) + +/** + * AppListQuery + * + * mode is a closed enum of listable app types. + */ +export const zAppListQuery = z.object({ + limit: z.int().gte(1).lte(200).optional().default(20), + mode: zSupportedAppType.nullish(), + name: z.string().max(200).nullish(), + page: z.int().gte(1).optional().default(1), + workspace_id: z.string(), +}) + +/** + * PermittedExternalAppsListQuery + * + * Strict (extra='forbid'). + */ +export const zPermittedExternalAppsListQuery = z.object({ + limit: z.int().gte(1).lte(200).optional().default(20), + mode: zSupportedAppType.nullish(), + name: z.string().max(200).nullish(), + page: z.int().gte(1).optional().default(1), +}) + /** * TaskStopResponse * @@ -698,18 +721,7 @@ export const zDeleteAccountSessionsBySessionIdResponse = zRevokeResponse export const zGetAppsQuery = z.object({ limit: z.int().gte(1).lte(200).optional().default(20), - mode: z - .enum([ - 'advanced-chat', - 'agent', - 'agent-chat', - 'channel', - 'chat', - 'completion', - 'rag-pipeline', - 'workflow', - ]) - .optional(), + mode: z.enum(['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow']).optional(), name: z.string().max(200).optional(), page: z.int().gte(1).optional().default(1), workspace_id: z.string(), @@ -862,18 +874,7 @@ export const zPostOauthDeviceTokenResponse = zDeviceTokenResponse export const zGetPermittedExternalAppsQuery = z.object({ limit: z.int().gte(1).lte(200).optional().default(20), - mode: z - .enum([ - 'advanced-chat', - 'agent', - 'agent-chat', - 'channel', - 'chat', - 'completion', - 'rag-pipeline', - 'workflow', - ]) - .optional(), + mode: z.enum(['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow']).optional(), name: z.string().max(200).optional(), page: z.int().gte(1).optional().default(1), })