mirror of
https://github.com/langgenius/dify.git
synced 2026-06-24 13:01:16 +08:00
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>
This commit is contained in:
parent
26639e0923
commit
b3e5f29421
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -80,7 +80,7 @@ User-scoped operations
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| limit | query | | No | integer, <br>**Default:** 20 |
|
||||
| mode | query | | No | string, <br>**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, <br>**Available values:** "advanced-chat", "agent-chat", "chat", "completion", "workflow" |
|
||||
| name | query | | No | string |
|
||||
| page | query | | No | integer, <br>**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, <br>**Default:** 20 |
|
||||
| mode | query | | No | string, <br>**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, <br>**Available values:** "advanced-chat", "agent-chat", "chat", "completion", "workflow" |
|
||||
| name | query | | No | string |
|
||||
| page | query | | No | integer, <br>**Default:** 1 |
|
||||
|
||||
@ -592,12 +592,12 @@ Request body for POST /workspaces/<workspace_id>/apps/imports.
|
||||
|
||||
#### AppListQuery
|
||||
|
||||
mode is a closed enum.
|
||||
mode is a closed enum of listable app types.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| limit | integer, <br>**Default:** 20 | | No |
|
||||
| mode | [AppMode](#appmode) | | No |
|
||||
| mode | [SupportedAppType](#supportedapptype) | | No |
|
||||
| name | string | | No |
|
||||
| page | integer, <br>**Default:** 1 | | No |
|
||||
| workspace_id | string | | Yes |
|
||||
@ -922,7 +922,7 @@ Strict (extra='forbid').
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| limit | integer, <br>**Default:** 20 | | No |
|
||||
| mode | [AppMode](#appmode) | | No |
|
||||
| mode | [SupportedAppType](#supportedapptype) | | No |
|
||||
| name | string | | No |
|
||||
| page | integer, <br>**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/<id>/tasks/<task_id>/stop. The handler always returns
|
||||
|
||||
10
api/tests/unit_tests/controllers/openapi/_mode_constants.py
Normal file
10
api/tests/unit_tests/controllers/openapi/_mode_constants.py
Normal file
@ -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"]
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
|
||||
13
cli/src/commands/get/app/mode-whitelist.test.ts
Normal file
13
cli/src/commands/get/app/mode-whitelist.test.ts
Normal file
@ -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'],
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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),
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user