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