From 4111751bdf78bf773a44c737a269d88412aa5299 Mon Sep 17 00:00:00 2001 From: GareArc Date: Sun, 21 Jun 2026 19:48:22 -0700 Subject: [PATCH] refactor(openapi/cli): split app usage-face from studio-app build-face Squash of PR #37641 (worktree-fix+app-abstraction-noun). Introduces two app nouns: - app: usage face (run/get/describe/resume), dual-subject account + external-SSO - studio-app: build face (export/import), account-only Backend: split read routes by subject with token-type-restricted guards; shared public projection builder (build_app_describe_response); drop author/tags from describe to prevent cross-tenant identity leak. CLI: selectAppReader subject dispatch (account vs permitted-external), AppReader strategy, studio-app export/import, refreshed help/guides. --- api/controllers/openapi/__init__.py | 4 +- api/controllers/openapi/_models.py | 6 +- api/controllers/openapi/apps.py | 78 +++++++++--------- .../openapi/apps_permitted_external.py | 21 +++++ api/openapi/markdown/openapi-openapi.md | 22 +++-- .../openapi/test_app_describe_builder.py | 73 +++++++++++++++++ .../openapi/test_pagination_envelope.py | 10 +-- cli/scripts/generate-command-tree.test.ts | 11 +++ cli/scripts/generate-command-tree.ts | 15 +++- cli/src/api/app-meta.ts | 6 +- cli/src/api/app-reader.test.ts | 30 +++++++ cli/src/api/app-reader.ts | 35 ++++++++ cli/src/api/apps.ts | 10 ++- cli/src/api/permitted-external-apps.test.ts | 27 +++++++ cli/src/api/permitted-external-apps.ts | 34 ++++++++ cli/src/cache/app-info.test.ts | 2 - cli/src/commands/agent-guides.test.ts | 4 + cli/src/commands/describe/app/handlers.ts | 10 +-- cli/src/commands/describe/app/run.test.ts | 14 +++- cli/src/commands/describe/app/run.ts | 4 +- cli/src/commands/export/studio-app/guide.ts | 12 +++ .../export/{app => studio-app}/index.ts | 19 +++-- .../export/{app => studio-app}/run.test.ts | 0 .../export/{app => studio-app}/run.ts | 5 +- cli/src/commands/get/app/run.test.ts | 25 +++++- cli/src/commands/get/app/run.ts | 25 +++--- cli/src/commands/import/studio-app/guide.ts | 17 ++++ .../import/{app => studio-app}/index.ts | 19 +++-- .../import/{app => studio-app}/run.test.ts | 0 .../import/{app => studio-app}/run.ts | 0 cli/src/commands/resume/app/run.test.ts | 66 +++++++++++++++ cli/src/commands/resume/app/run.ts | 42 +--------- cli/src/commands/run/app/input-flags.ts | 42 ++++++++++ cli/src/commands/run/app/run.test.ts | 34 +++++++- cli/src/commands/run/app/run.ts | 42 +--------- cli/src/commands/tree.generated.ts | 8 +- cli/src/help/topics.ts | 13 +++ cli/src/http/error-mapper.test.ts | 19 +++++ cli/src/http/error-mapper.ts | 8 ++ cli/src/http/orpc.test.ts | 4 +- cli/src/types/app-meta.test.ts | 2 - cli/test/e2e/setup/global-setup.ts | 6 +- .../suites/agent/agent-skill-workflow.e2e.ts | 17 +++- cli/test/e2e/suites/auth/whoami.e2e.ts | 12 +-- .../e2e/suites/discovery/describe-app.e2e.ts | 27 ++----- .../discovery/get-app-all-workspaces.e2e.ts | 12 +-- .../e2e/suites/discovery/get-app-list.e2e.ts | 12 ++- .../suites/discovery/get-app-single.e2e.ts | 9 ++- ...rt-app.e2e.ts => export-studio-app.e2e.ts} | 38 ++++----- .../error-handling/error-messages.e2e.ts | 18 ++--- cli/test/fixtures/dify-mock/server.ts | 30 ++++++- .../generated/api/openapi/orpc.gen.ts | 42 ++++++++-- .../generated/api/openapi/types.gen.ts | 32 ++++++-- .../generated/api/openapi/zod.gen.ts | 81 ++++++++++--------- 54 files changed, 828 insertions(+), 326 deletions(-) create mode 100644 api/tests/unit_tests/controllers/openapi/test_app_describe_builder.py create mode 100644 cli/src/api/app-reader.test.ts create mode 100644 cli/src/api/app-reader.ts create mode 100644 cli/src/api/permitted-external-apps.test.ts create mode 100644 cli/src/api/permitted-external-apps.ts create mode 100644 cli/src/commands/export/studio-app/guide.ts rename cli/src/commands/export/{app => studio-app}/index.ts (71%) rename cli/src/commands/export/{app => studio-app}/run.test.ts (100%) rename cli/src/commands/export/{app => studio-app}/run.ts (89%) create mode 100644 cli/src/commands/import/studio-app/guide.ts rename cli/src/commands/import/{app => studio-app}/index.ts (80%) rename cli/src/commands/import/{app => studio-app}/run.test.ts (100%) rename cli/src/commands/import/{app => studio-app}/run.ts (100%) create mode 100644 cli/src/commands/resume/app/run.test.ts create mode 100644 cli/src/commands/run/app/input-flags.ts rename cli/test/e2e/suites/dsl/{export-app.e2e.ts => export-studio-app.e2e.ts} (82%) diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index e8406ea00cb..2f23baa11a9 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -31,7 +31,7 @@ from controllers.openapi._models import ( AppDslExportQuery, AppDslExportResponse, AppDslImportPayload, - AppInfoResponse, + AppInfo, AppListQuery, AppListResponse, AppListRow, @@ -101,7 +101,7 @@ register_response_schema_models( MessageMetadata, AppListRow, AppListResponse, - AppInfoResponse, + AppInfo, AppDescribeInfo, AppDescribeResponse, AppDslExportResponse, diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index 7c225c85f65..b1a432abe3c 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -70,16 +70,14 @@ class PermittedExternalAppsListResponse(BaseModel): data: list[AppListRow] -class AppInfoResponse(BaseModel): +class AppInfo(BaseModel): id: str name: str description: str | None = None mode: str - author: str | None = None - tags: list[TagItem] = [] -class AppDescribeInfo(AppInfoResponse): +class AppDescribeInfo(AppInfo): updated_at: str | None = None service_api_enabled: bool is_agent: bool = False diff --git a/api/controllers/openapi/apps.py b/api/controllers/openapi/apps.py index c4796313c0b..768f62d2620 100644 --- a/api/controllers/openapi/apps.py +++ b/api/controllers/openapi/apps.py @@ -28,6 +28,7 @@ from core.app.app_config.common.parameters_mapping import get_parameters_from_fe from extensions.ext_database import db from libs.oauth_bearer import Scope, TokenType from models import App +from models.model import AppMode from services.account_service import TenantService from services.app_service import AppListParams, AppService from services.tag_service import TagService @@ -84,6 +85,42 @@ def parameters_payload(app: App) -> dict: return Parameters.model_validate(parameters).model_dump(mode="json") +def build_app_describe_response(app: App, fields: set[str] | None) -> AppDescribeResponse: + """Public projection of an app (name / params / input schema) — never internal config.""" + want_info = fields is None or "info" in fields + want_params = fields is None or "parameters" in fields + want_schema = fields is None or "input_schema" in fields + + info = ( + AppDescribeInfo( + id=str(app.id), + name=app.name, + mode=app.mode, + description=app.description, + updated_at=app.updated_at.isoformat() if app.updated_at else None, + service_api_enabled=bool(app.enable_api), + is_agent=app.mode in (AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT), + ) + if want_info + else None + ) + + parameters: dict[str, Any] | None = None + input_schema: dict[str, Any] | None = None + if want_params: + try: + parameters = parameters_payload(app) + except AppUnavailableError: + parameters = dict(_EMPTY_PARAMETERS) + if want_schema: + try: + input_schema = build_input_schema(app) + except AppUnavailableError: + input_schema = dict(EMPTY_INPUT_SCHEMA) + + return AppDescribeResponse(info=info, parameters=parameters, input_schema=input_schema) + + @openapi_ns.route("/apps//describe") class AppDescribeApi(AppReadResource): @auth_router.guard(scope=Scope.APPS_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT})) @@ -92,46 +129,7 @@ class AppDescribeApi(AppReadResource): def get(self, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery): # describe is UUID-only (workspace_id query param dropped in #37212). app = self._load(app_id) - - requested = query.fields - want_info = requested is None or "info" in requested - want_params = requested is None or "parameters" in requested - want_schema = requested is None or "input_schema" in requested - - info = ( - AppDescribeInfo( - id=str(app.id), - name=app.name, - mode=app.mode, - description=app.description, - tags=[TagItem(name=t.name) for t in app.tags], - author=app.author_name, - updated_at=app.updated_at.isoformat() if app.updated_at else None, - service_api_enabled=bool(app.enable_api), - is_agent=app.mode in ("agent-chat", "advanced-chat"), - ) - if want_info - else None - ) - - parameters: dict[str, Any] | None = None - input_schema: dict[str, Any] | None = None - if want_params: - try: - parameters = parameters_payload(app) - except AppUnavailableError: - parameters = dict(_EMPTY_PARAMETERS) - if want_schema: - try: - input_schema = build_input_schema(app) - except AppUnavailableError: - input_schema = dict(EMPTY_INPUT_SCHEMA) - - return AppDescribeResponse( - info=info, - parameters=parameters, - input_schema=input_schema, - ) + return build_app_describe_response(app, query.fields) @openapi_ns.route("/apps") diff --git a/api/controllers/openapi/apps_permitted_external.py b/api/controllers/openapi/apps_permitted_external.py index 0e889a2951c..949aa6a38d4 100644 --- a/api/controllers/openapi/apps_permitted_external.py +++ b/api/controllers/openapi/apps_permitted_external.py @@ -8,14 +8,18 @@ EE blueprint chain so this module is unreachable there. from __future__ import annotations from flask_restx import Resource +from werkzeug.exceptions import NotFound from controllers.openapi import openapi_ns from controllers.openapi._contract import accepts, returns from controllers.openapi._models import ( + AppDescribeQuery, + AppDescribeResponse, AppListRow, PermittedExternalAppsListQuery, PermittedExternalAppsListResponse, ) +from controllers.openapi.apps import build_app_describe_response from controllers.openapi.auth.composition import auth_router from controllers.openapi.auth.data import AuthData, Edition from extensions.ext_database import db @@ -82,3 +86,20 @@ class PermittedExternalAppsListApi(Resource): data=items, ) return env + + +@openapi_ns.route("/permitted-external-apps//describe") +class PermittedExternalAppDescribeApi(Resource): + @auth_router.guard( + scope=Scope.APPS_READ_PERMITTED_EXTERNAL, + allowed_token_types=frozenset({TokenType.OAUTH_EXTERNAL_SSO}), + edition=frozenset({Edition.EE}), + ) + @returns(200, AppDescribeResponse, description="Permitted external app description") + @accepts(query=AppDescribeQuery) + def get(self, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery): + # App already loaded and ACL-checked by the external_sso pipeline; project it. + app = auth_data.app + if app is None: + raise NotFound("app not found") + return build_app_describe_response(app, query.fields) diff --git a/api/openapi/markdown/openapi-openapi.md b/api/openapi/markdown/openapi-openapi.md index ce0150e8e88..6841be77ff1 100644 --- a/api/openapi/markdown/openapi-openapi.md +++ b/api/openapi/markdown/openapi-openapi.md @@ -331,6 +331,22 @@ Upload a file to use as an input variable when running the app | 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| | default | Error | **application/json**: [ErrorBody](#errorbody)
| +### [GET] /permitted-external-apps/{app_id}/describe +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| fields | query | | No | string | +| app_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Permitted external app description | **application/json**: [AppDescribeResponse](#appdescriberesponse)
| +| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)
| +| default | Error | **application/json**: [ErrorBody](#errorbody)
| + ### [GET] /workspaces #### Responses @@ -507,14 +523,12 @@ Upload a file to use as an input variable when running the app | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| author | string | | No | | description | string | | No | | id | string | | Yes | | is_agent | boolean | | No | | mode | string | | Yes | | name | string | | Yes | | service_api_enabled | boolean | | Yes | -| tags | [ [TagItem](#tagitem) ],
**Default:** | | No | | updated_at | string | | No | #### AppDescribeQuery @@ -568,16 +582,14 @@ Request body for POST /workspaces//apps/imports. | yaml_content | string | Inline YAML DSL string (required when mode is yaml-content) | No | | yaml_url | string | Remote URL to fetch YAML from (required when mode is yaml-url) | No | -#### AppInfoResponse +#### AppInfo | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| author | string | | No | | description | string | | No | | id | string | | Yes | | mode | string | | Yes | | name | string | | Yes | -| tags | [ [TagItem](#tagitem) ],
**Default:** | | No | #### AppListQuery diff --git a/api/tests/unit_tests/controllers/openapi/test_app_describe_builder.py b/api/tests/unit_tests/controllers/openapi/test_app_describe_builder.py new file mode 100644 index 00000000000..708a0e59865 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_app_describe_builder.py @@ -0,0 +1,73 @@ +from types import SimpleNamespace + +from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA +from controllers.openapi.apps import _EMPTY_PARAMETERS, build_app_describe_response +from controllers.service_api.app.error import AppUnavailableError + + +class _FakeApp(SimpleNamespace): + pass + + +def _app() -> _FakeApp: + from datetime import datetime + + return _FakeApp( + id="11111111-1111-1111-1111-111111111111", + name="Demo", + mode="chat", + description="d", + tags=[], + author_name="me", + updated_at=datetime(2026, 1, 1), + enable_api=True, + ) + + +def test_fields_none_returns_all_blocks(monkeypatch): + monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"}) + monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1}) + resp = build_app_describe_response(_app(), None) + assert resp.info is not None + assert resp.info.name == "Demo" + assert resp.parameters == {"k": "v"} + assert resp.input_schema == {"s": 1} + + +def test_fields_subset_limits_blocks(monkeypatch): + monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"}) + monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1}) + resp = build_app_describe_response(_app(), ["info"]) + assert resp.info is not None + assert resp.parameters is None + assert resp.input_schema is None + + +def test_info_omits_author_and_tags(monkeypatch): + monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {}) + monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {}) + resp = build_app_describe_response(_app(), ["info"]) + assert resp.info is not None + # Usage-face describe must not expose creator identity or tags (cross-tenant leak). + assert not hasattr(resp.info, "author") + assert not hasattr(resp.info, "tags") + + +def test_parameters_fallback_on_app_unavailable(monkeypatch): + def _raise(app): + raise AppUnavailableError() + + monkeypatch.setattr("controllers.openapi.apps.parameters_payload", _raise) + monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1}) + resp = build_app_describe_response(_app(), ["parameters"]) + assert resp.parameters == dict(_EMPTY_PARAMETERS) + + +def test_input_schema_fallback_on_app_unavailable(monkeypatch): + def _raise(app): + raise AppUnavailableError() + + monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"}) + monkeypatch.setattr("controllers.openapi.apps.build_input_schema", _raise) + resp = build_app_describe_response(_app(), ["input_schema"]) + assert resp.input_schema == dict(EMPTY_INPUT_SCHEMA) diff --git a/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py b/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py index 930647608fa..7b84911d788 100644 --- a/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py +++ b/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py @@ -63,23 +63,19 @@ def test_envelope_uses_pep695_generics(): def test_app_info_response_dump_matches_spec(): - from controllers.openapi._models import AppInfoResponse + from controllers.openapi._models import AppInfo - obj = AppInfoResponse( + obj = AppInfo( id="app1", name="X", description="d", mode="chat", - author="alice", - tags=[{"name": "prod"}], ) assert obj.model_dump(mode="json") == { "id": "app1", "name": "X", "description": "d", "mode": "chat", - "author": "alice", - "tags": [{"name": "prod"}], } @@ -91,8 +87,6 @@ def test_app_describe_response_nests_info_and_parameters(): name="X", mode="chat", description=None, - tags=[], - author=None, updated_at="2026-05-05T00:00:00+00:00", service_api_enabled=True, ) diff --git a/cli/scripts/generate-command-tree.test.ts b/cli/scripts/generate-command-tree.test.ts index cf25ddcf71a..c811de8b4fe 100644 --- a/cli/scripts/generate-command-tree.test.ts +++ b/cli/scripts/generate-command-tree.test.ts @@ -137,6 +137,17 @@ export const commandTree: CommandTree = { const verIdx = out.indexOf('Version') expect(authIdx).toBeLessThan(verIdx) }) + + it('quotes hyphenated keys and leaves plain identifier keys unquoted', () => { + const entries: CommandEntry[] = [ + { tokens: ['export', 'app'], identifier: 'ExportApp', importPath: '@/commands/export/app/index' }, + { tokens: ['export', 'studio-app'], identifier: 'ExportStudioApp', importPath: '@/commands/export/studio-app/index' }, + ] + const out = formatModule(entries, buildTree(entries)) + expect(out).toContain(`'studio-app': { command: ExportStudioApp, subcommands: {} },`) + expect(out).toContain(`app: { command: ExportApp, subcommands: {} },`) + expect(out).not.toContain(`'app':`) + }) }) function makeFixture(): string { diff --git a/cli/scripts/generate-command-tree.ts b/cli/scripts/generate-command-tree.ts index 769490df834..9f3357bd6ea 100644 --- a/cli/scripts/generate-command-tree.ts +++ b/cli/scripts/generate-command-tree.ts @@ -141,13 +141,24 @@ function emitNode(node: TreeNode, indent: string): string { return parts.join('\n') } +function needsQuoting(key: string): boolean { + // A bare object key must be a valid JS identifier: the start class excludes digits + // (letter/_/$ only), so a leading digit fails the match and the key gets quoted. + return !/^[A-Z_$][\w$]*$/i.test(key) +} + +function emitKey(key: string): string { + return needsQuoting(key) ? `'${key}'` : key +} + function emitEntry(key: string, node: TreeNode, indent: string): string { + const k = emitKey(key) const isLeaf = node.subcommands.size === 0 && node.command !== undefined if (isLeaf) - return `${indent}${key}: { command: ${node.command}, subcommands: {} },` + return `${indent}${k}: { command: ${node.command}, subcommands: {} },` return [ - `${indent}${key}: {`, + `${indent}${k}: {`, emitNode(node, indent), `${indent}},`, ].join('\n') diff --git a/cli/src/api/app-meta.ts b/cli/src/api/app-meta.ts index c2ca9a5aa9b..03ae59bd392 100644 --- a/cli/src/api/app-meta.ts +++ b/cli/src/api/app-meta.ts @@ -1,17 +1,17 @@ -import type { AppsClient } from './apps' +import type { AppReader } from './app-reader' import type { AppInfoCache } from '@/cache/app-info' import type { AppMeta, AppMetaFieldKey } from '@/types/app-meta' import { covers, fromDescribe, mergeMeta } from '@/types/app-meta' export type AppMetaClientOptions = { - readonly apps: AppsClient + readonly apps: AppReader readonly host: string readonly cache?: AppInfoCache readonly now?: () => Date } export class AppMetaClient { - private readonly apps: AppsClient + private readonly apps: AppReader private readonly host: string private readonly cache: AppInfoCache | undefined private readonly now: () => Date diff --git a/cli/src/api/app-reader.test.ts b/cli/src/api/app-reader.test.ts new file mode 100644 index 00000000000..ff584c85c49 --- /dev/null +++ b/cli/src/api/app-reader.test.ts @@ -0,0 +1,30 @@ +import type { ActiveContext } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' +import { describe, expect, it } from 'vitest' +import { selectAppReader, SubjectKind, subjectOf } from './app-reader' +import { AppsClient } from './apps' +import { PermittedExternalAppsClient } from './permitted-external-apps' + +const http = { baseURL: 'https://x', request: async () => new Response() } as unknown as HttpClient + +function ctx(external: boolean): ActiveContext { + return { + host: 'h', + email: 'e', + ctx: { + account: { id: 'a', email: 'e', name: 'n' }, + external_subject: external ? { email: 'e', issuer: 'i' } : undefined, + }, + } +} + +describe('selectAppReader', () => { + it('account login → AppsClient', () => { + expect(selectAppReader(ctx(false), http)).toBeInstanceOf(AppsClient) + expect(subjectOf(ctx(false))).toBe(SubjectKind.Account) + }) + it('external_subject present → PermittedExternalAppsClient', () => { + expect(selectAppReader(ctx(true), http)).toBeInstanceOf(PermittedExternalAppsClient) + expect(subjectOf(ctx(true))).toBe(SubjectKind.External) + }) +}) diff --git a/cli/src/api/app-reader.ts b/cli/src/api/app-reader.ts new file mode 100644 index 00000000000..fe41e35bfe9 --- /dev/null +++ b/cli/src/api/app-reader.ts @@ -0,0 +1,35 @@ +import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen' +import type { ListQuery } from './apps' +import type { ActiveContext } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' +import { AppsClient } from './apps' +import { PermittedExternalAppsClient } from './permitted-external-apps' + +export type AppReader = { + list: (q: ListQuery) => Promise + describe: (appId: string, fields?: readonly string[]) => Promise +} + +// The auth subject behind an openapi bearer token. Each kind reads apps from its own surface. +export const SubjectKind = { + Account: 'account', + External: 'external', +} as const + +export type SubjectKindValue = (typeof SubjectKind)[keyof typeof SubjectKind] + +export function subjectOf(active: ActiveContext): SubjectKindValue { + return active.ctx.external_subject !== undefined ? SubjectKind.External : SubjectKind.Account +} + +type AppReaderFactory = (http: HttpClient) => AppReader + +// Maps each auth subject to the app reader for its surface. +const APP_READER_BY_SUBJECT: Readonly> = { + [SubjectKind.Account]: http => new AppsClient(http), + [SubjectKind.External]: http => new PermittedExternalAppsClient(http), +} + +export function selectAppReader(active: ActiveContext, http: HttpClient): AppReader { + return APP_READER_BY_SUBJECT[subjectOf(active)](http) +} diff --git a/cli/src/api/apps.ts b/cli/src/api/apps.ts index ea0e41c252f..bf672f1f45d 100644 --- a/cli/src/api/apps.ts +++ b/cli/src/api/apps.ts @@ -1,4 +1,5 @@ import type { AppDescribeResponse, AppListResponse, AppMode } 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' import { createOpenApiClient } from '@/http/orpc' @@ -12,7 +13,12 @@ export type ListQuery = { readonly tag?: string } -export class AppsClient { +// An absent or empty mode filter means "any mode" — collapse both to undefined for the query. +export function normalizeMode(mode: AppMode | '' | undefined): AppMode | undefined { + return mode !== undefined && mode !== '' ? mode : undefined +} + +export class AppsClient implements AppReader { private readonly orpc: OpenApiClient constructor(http: HttpClient) { @@ -25,7 +31,7 @@ export class AppsClient { workspace_id: q.workspaceId, page: q.page ?? 1, limit: q.limit ?? 20, - mode: q.mode !== undefined && q.mode !== '' ? q.mode : undefined, + mode: normalizeMode(q.mode), name: q.name !== undefined && q.name !== '' ? q.name : undefined, tag: q.tag !== undefined && q.tag !== '' ? q.tag : undefined, }, diff --git a/cli/src/api/permitted-external-apps.test.ts b/cli/src/api/permitted-external-apps.test.ts new file mode 100644 index 00000000000..f6fa38cb3eb --- /dev/null +++ b/cli/src/api/permitted-external-apps.test.ts @@ -0,0 +1,27 @@ +import type { HttpClient } from '@/http/types' +import { describe, expect, it, vi } from 'vitest' +import { PermittedExternalAppsClient } from './permitted-external-apps' + +function fakeHttp() { + return { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient +} + +type WithOrpc = { orpc: unknown } + +describe('PermittedExternalAppsClient', () => { + it('list calls permittedExternalApps.get with paging/filter query', async () => { + const c = new PermittedExternalAppsClient(fakeHttp()) + const get = vi.fn().mockResolvedValue({ page: 1, limit: 20, total: 0, has_more: false, data: [] }) + ;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get, byAppId: { describe: { get: vi.fn() } } } } + await c.list({ workspaceId: '', page: 2, limit: 5, mode: undefined, name: 'a' }) + expect(get).toHaveBeenCalledWith({ query: { page: 2, limit: 5, mode: undefined, name: 'a' } }) + }) + + it('describe calls permittedExternalApps.byAppId.describe.get with app_id + fields', async () => { + const c = new PermittedExternalAppsClient(fakeHttp()) + const dget = vi.fn().mockResolvedValue({ info: null, parameters: null, input_schema: null }) + ;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get: vi.fn(), byAppId: { describe: { get: dget } } } } + await c.describe('app-1', ['info']) + expect(dget).toHaveBeenCalledWith({ params: { app_id: 'app-1' }, query: { fields: 'info' } }) + }) +}) diff --git a/cli/src/api/permitted-external-apps.ts b/cli/src/api/permitted-external-apps.ts new file mode 100644 index 00000000000..a1587bc416c --- /dev/null +++ b/cli/src/api/permitted-external-apps.ts @@ -0,0 +1,34 @@ +import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen' +import type { AppReader } from './app-reader' +import type { ListQuery } from './apps' +import type { OpenApiClient } from '@/http/orpc' +import type { HttpClient } from '@/http/types' +import { createOpenApiClient } from '@/http/orpc' +import { normalizeMode } from './apps' + +export class PermittedExternalAppsClient implements AppReader { + private readonly orpc: OpenApiClient + + constructor(http: HttpClient) { + this.orpc = createOpenApiClient(http) + } + + // workspaceId/tag are ignored: the external grant is not workspace-scoped. + async list(q: ListQuery): Promise { + return this.orpc.permittedExternalApps.get({ + query: { + page: q.page ?? 1, + limit: q.limit ?? 20, + mode: normalizeMode(q.mode), + name: q.name !== undefined && q.name !== '' ? q.name : undefined, + }, + }) + } + + async describe(appId: string, fields?: readonly string[]): Promise { + return this.orpc.permittedExternalApps.byAppId.describe.get({ + params: { app_id: appId }, + query: { fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined }, + }) + } +} diff --git a/cli/src/cache/app-info.test.ts b/cli/src/cache/app-info.test.ts index 50ac1c67d35..ae6f15f249d 100644 --- a/cli/src/cache/app-info.test.ts +++ b/cli/src/cache/app-info.test.ts @@ -21,8 +21,6 @@ function metaInfoOnly(): AppMeta { name: 'Greeter', description: '', mode: 'chat', - author: 'tester', - tags: [], updated_at: undefined, service_api_enabled: false, is_agent: false, diff --git a/cli/src/commands/agent-guides.test.ts b/cli/src/commands/agent-guides.test.ts index e1bcb0925aa..3ad18f540f1 100644 --- a/cli/src/commands/agent-guides.test.ts +++ b/cli/src/commands/agent-guides.test.ts @@ -2,7 +2,9 @@ import type { CommandConstructor } from '@/framework/command' import { describe, expect, it } from 'vitest' import Login from '@/commands/auth/login/index' import DescribeApp from '@/commands/describe/app/index' +import ExportStudioApp from '@/commands/export/studio-app/index' import GetApp from '@/commands/get/app/index' +import ImportStudioApp from '@/commands/import/studio-app/index' import ResumeApp from '@/commands/resume/app/index' import RunApp from '@/commands/run/app/index' @@ -13,6 +15,8 @@ const GUIDED_COMMANDS: ReadonlyArray = [ ['resume app', ResumeApp], ['describe app', DescribeApp], ['get app', GetApp], + ['export studio-app', ExportStudioApp], + ['import studio-app', ImportStudioApp], ['auth login', Login], ] diff --git a/cli/src/commands/describe/app/handlers.ts b/cli/src/commands/describe/app/handlers.ts index 6b934c87148..133260e6509 100644 --- a/cli/src/commands/describe/app/handlers.ts +++ b/cli/src/commands/describe/app/handlers.ts @@ -1,4 +1,4 @@ -import type { AppDescribeInfo, TagItem } from '@dify/contracts/api/openapi/types.gen' +import type { AppDescribeInfo } from '@dify/contracts/api/openapi/types.gen' import type { AppMeta } from '@/types/app-meta' export const APP_DESCRIBE_MODE_KEY = 'app-describe' @@ -28,10 +28,8 @@ export class AppDescribeOutput { ['Name', info.name], ['ID', info.id], ['Mode', info.mode], - ['Author', info.author ?? ''], ['Updated', info.updated_at ?? ''], ['Service API', info.service_api_enabled ? 'true' : 'false'], - ['Tags', joinTags(info.tags ?? [])], ] if (info.description !== '' && info.description !== undefined) rows.push(['Description', info.description ?? '']) @@ -55,12 +53,6 @@ export class AppDescribeOutput { } } -function joinTags(tags: readonly TagItem[]): string { - if (tags.length === 0) - return '' - return tags.map(t => t.name).join(',') -} - function alignedRows(rows: readonly [string, string][]): string[] { const widest = rows.reduce((m, [k]) => Math.max(m, k.length), 0) return rows.map(([k, v]) => `${`${k}:`.padEnd(widest + 2)}${v}`) diff --git a/cli/src/commands/describe/app/run.test.ts b/cli/src/commands/describe/app/run.test.ts index 4ed7cedc7a5..96dfae5acb5 100644 --- a/cli/src/commands/describe/app/run.test.ts +++ b/cli/src/commands/describe/app/run.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { startMock } from '@test/fixtures/dify-mock/server' import { testHttpClient } from '@test/fixtures/http-client' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { loadAppInfoCache } from '@/cache/app-info' import { formatted, stringifyOutput } from '@/framework/output' import { ENV_CACHE_DIR } from '@/store/dir' @@ -34,6 +34,7 @@ describe('runDescribeApp', () => { process.env[ENV_CACHE_DIR] = dir }) afterEach(async () => { + vi.restoreAllMocks() if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR] else @@ -60,8 +61,6 @@ describe('runDescribeApp', () => { expect(out).toContain('Mode:') expect(out).toContain('chat') expect(out).toContain('Service API:') - expect(out).toContain('Tags:') - expect(out).toContain('demo') expect(out).toContain('Description:') expect(out).toContain('Parameters:') }) @@ -115,4 +114,13 @@ describe('runDescribeApp', () => { }, )).rejects.toThrow() }) + + it('external login resolves describe via the permitted-external route', async () => { + const activeExt: ActiveContext = { host: mock.url, email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } } + const out = await runDescribeApp( + { appId: 'app-1' }, + { active: activeExt, http: testHttpClient(mock.url, 'dfoe_test'), host: mock.url }, + ) + expect(out.payload.info?.id).toBe('app-1') + }) }) diff --git a/cli/src/commands/describe/app/run.ts b/cli/src/commands/describe/app/run.ts index 8af2a5dc441..0d5eea784d4 100644 --- a/cli/src/commands/describe/app/run.ts +++ b/cli/src/commands/describe/app/run.ts @@ -3,7 +3,7 @@ import type { AppInfoCache } from '@/cache/app-info' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { AppMetaClient } from '@/api/app-meta' -import { AppsClient } from '@/api/apps' +import { selectAppReader } from '@/api/app-reader' import { runWithSpinner } from '@/sys/io/spinner' import { nullStreams } from '@/sys/io/streams' import { FieldInfo, FieldInputSchema, FieldParameters } from '@/types/app-meta' @@ -26,7 +26,7 @@ export type DescribeAppDeps = { } export async function runDescribeApp(opts: DescribeAppOptions, deps: DescribeAppDeps): Promise { - const apps = new AppsClient(deps.http) + const apps = selectAppReader(deps.active, deps.http) const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache }) const io = deps.io ?? nullStreams() const result = await runWithSpinner( diff --git a/cli/src/commands/export/studio-app/guide.ts b/cli/src/commands/export/studio-app/guide.ts new file mode 100644 index 00000000000..d41b502b191 --- /dev/null +++ b/cli/src/commands/export/studio-app/guide.ts @@ -0,0 +1,12 @@ +export const agentGuide = ` +WHEN TO USE + A studio app is what you build and edit in Studio on the web console, + inside a workspace — the app's source definition, not the published app + that 'run app' invokes. Export pulls that definition as YAML to back it + up, diff it, or recreate the app elsewhere with 'import studio-app'. To + run or inspect an app instead, use the 'app' noun. + +ERROR RECOVERY + app not found (404) difyctl get app + not logged in (exit 4) difyctl auth login +` diff --git a/cli/src/commands/export/app/index.ts b/cli/src/commands/export/studio-app/index.ts similarity index 71% rename from cli/src/commands/export/app/index.ts rename to cli/src/commands/export/studio-app/index.ts index 7afd0234982..69bdcf09aa3 100644 --- a/cli/src/commands/export/app/index.ts +++ b/cli/src/commands/export/studio-app/index.ts @@ -1,16 +1,17 @@ import { DifyCommand } from '@/commands/_shared/dify-command' import { httpRetryFlag } from '@/commands/_shared/global-flags' import { Args, Flags } from '@/framework/flags' +import { agentGuide } from './guide' import { runExportApp } from './run' -export default class ExportApp extends DifyCommand { - static override description = 'Export an app\'s DSL configuration as YAML' +export default class ExportStudioApp extends DifyCommand { + static override description = 'Export a studio app\'s DSL configuration as YAML' static override examples = [ - '<%= config.bin %> export app ', - '<%= config.bin %> export app --output ./my-app.yaml', - '<%= config.bin %> export app --include-secret', - '<%= config.bin %> export app --workflow-id ', + '<%= config.bin %> export studio-app ', + '<%= config.bin %> export studio-app --output ./my-app.yaml', + '<%= config.bin %> export studio-app --include-secret', + '<%= config.bin %> export studio-app --workflow-id ', ] static override args = { @@ -26,7 +27,7 @@ export default class ExportApp extends DifyCommand { } async run(argv: string[]) { - const { args, flags } = this.parse(ExportApp, argv) + const { args, flags } = this.parse(ExportStudioApp, argv) const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] }) const result = await runExportApp({ appId: args.id, @@ -42,4 +43,8 @@ export default class ExportApp extends DifyCommand { ctx.io.out.write('\n') } } + + override agentGuide(): string { + return agentGuide + } } diff --git a/cli/src/commands/export/app/run.test.ts b/cli/src/commands/export/studio-app/run.test.ts similarity index 100% rename from cli/src/commands/export/app/run.test.ts rename to cli/src/commands/export/studio-app/run.test.ts diff --git a/cli/src/commands/export/app/run.ts b/cli/src/commands/export/studio-app/run.ts similarity index 89% rename from cli/src/commands/export/app/run.ts rename to cli/src/commands/export/studio-app/run.ts index 93abe2f8b46..351a2fc349f 100644 --- a/cli/src/commands/export/app/run.ts +++ b/cli/src/commands/export/studio-app/run.ts @@ -35,9 +35,8 @@ export async function runExportApp(opts: ExportAppOptions, deps: ExportAppDeps): const io = deps.io ?? nullStreams() const dslFactory = deps.dslFactory ?? ((h: HttpClient) => new AppDslClient(h)) - // workspace is needed to satisfy the auth pipeline; resolving it here - // mirrors what other commands do even though the export endpoint does not - // take workspace_id as a query parameter (it loads tenant from app). + // workspace is resolved to satisfy the auth pipeline; the export endpoint itself + // takes no workspace_id query parameter (it loads tenant from the app). resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) const client = dslFactory(deps.http) diff --git a/cli/src/commands/get/app/run.test.ts b/cli/src/commands/get/app/run.test.ts index 7c0cc76c009..094fdc69c93 100644 --- a/cli/src/commands/get/app/run.test.ts +++ b/cli/src/commands/get/app/run.test.ts @@ -1,8 +1,9 @@ import type { DifyMock } from '@test/fixtures/dify-mock/server' import type { ActiveContext } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import { startMock } from '@test/fixtures/dify-mock/server' import { testHttpClient } from '@test/fixtures/http-client' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { stringifyOutput, table } from '@/framework/output' import { AppListOutput } from './handlers.js' import { runGetApp } from './run.js' @@ -25,6 +26,7 @@ describe('runGetApp', () => { }) afterEach(async () => { + vi.restoreAllMocks() await mock.stop() }) @@ -138,4 +140,25 @@ describe('runGetApp', () => { } await expect(runGetApp({}, { active: minimal, http: http() })).rejects.toThrow(/no workspace/) }) + + it('external login lists via permitted-external client without workspace', async () => { + const list = vi.fn().mockResolvedValue({ page: 1, limit: 20, total: 1, has_more: false, data: [{ id: 'x', name: 'X', description: null, mode: 'chat', tags: [], updated_at: null, created_by_name: null, workspace_id: 'w', workspace_name: 'W' }] }) + const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps') + vi.spyOn(PermittedExternalAppsClient.prototype, 'list').mockImplementation(list) + const active: ActiveContext = { host: 'h', email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } } + const http = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient + const res = await runGetApp({}, { active, http }) + expect(list).toHaveBeenCalled() + const firstCallArg = list.mock.calls[0]![0] as { workspaceId: string } + expect(firstCallArg.workspaceId).toBe('') + expect(res.data).toBeDefined() + }) + + it('--all-workspaces throws UsageInvalidFlag for external logins', async () => { + const active: ActiveContext = { host: 'h', email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } } + const httpClient = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient + await expect(runGetApp({ allWorkspaces: true }, { active, http: httpClient })) + .rejects + .toThrow(/--all-workspaces is not available for external logins/) + }) }) diff --git a/cli/src/commands/get/app/run.ts b/cli/src/commands/get/app/run.ts index 102cf066499..308b256bc84 100644 --- a/cli/src/commands/get/app/run.ts +++ b/cli/src/commands/get/app/run.ts @@ -1,9 +1,12 @@ import type { AppDescribeResponse, AppListResponse, AppMode } 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' import type { IOStreams } from '@/sys/io/streams' -import { AppsClient } from '@/api/apps' +import { selectAppReader, SubjectKind, subjectOf } from '@/api/app-reader' import { WorkspacesClient } from '@/api/workspaces' +import { newError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' import { LIMIT_DEFAULT, parseLimit } from '@/limit/limit' import { getEnv } from '@/sys/index' import { runWithSpinner } from '@/sys/io/spinner' @@ -28,7 +31,6 @@ export type GetAppDeps = { readonly http: HttpClient readonly io?: IOStreams readonly envLookup?: (k: string) => string | undefined - readonly appsFactory?: (http: HttpClient) => AppsClient readonly workspacesFactory?: (http: HttpClient) => WorkspacesClient } @@ -40,10 +42,10 @@ export type GetAppResult = { export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise { const env = deps.envLookup ?? getEnv - const appsFactory = deps.appsFactory ?? ((h: HttpClient) => new AppsClient(h)) const wsFactory = deps.workspacesFactory ?? ((h: HttpClient) => new WorkspacesClient(h)) - const apps = appsFactory(deps.http) + const external = subjectOf(deps.active) === SubjectKind.External + const apps = selectAppReader(deps.active, deps.http) const pageSize = resolveLimit(opts.limitRaw, env) const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page const label = opts.appId !== undefined && opts.appId !== '' ? 'Fetching app' : 'Fetching apps' @@ -53,15 +55,20 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise< { io, label }, async (): Promise => { if (opts.allWorkspaces === true) { + if (external) + throw newError(ErrorCode.UsageInvalidFlag, '--all-workspaces is not available for external logins') const ws = wsFactory(deps.http) return runAllWorkspaces(apps, ws, opts, page, pageSize) } if (opts.appId !== undefined && opts.appId !== '') { - const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) - const wsName = workspaceNameForId(deps.active, wsId) + const wsId = external ? '' : resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) + const wsName = external ? '' : workspaceNameForId(deps.active, wsId) const desc = await apps.describe(opts.appId, ['info']) return describeToEnvelope(desc, wsId, wsName) } + if (external) { + return apps.list({ workspaceId: '', page, limit: pageSize, mode: opts.mode, name: opts.name }) + } const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active }) return apps.list({ workspaceId: wsId, @@ -102,9 +109,9 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str name: desc.info.name, description: desc.info.description, mode: desc.info.mode as AppMode, - tags: desc.info.tags, + tags: [], updated_at: desc.info.updated_at, - created_by_name: desc.info.author === '' ? undefined : desc.info.author, + created_by_name: undefined, workspace_id: wsId, workspace_name: wsName === '' ? undefined : wsName, }], @@ -118,7 +125,7 @@ function workspaceNameForId(active: ActiveContext, id: string): string { } async function runAllWorkspaces( - apps: AppsClient, + apps: AppReader, ws: WorkspacesClient, opts: GetAppOptions, page: number, diff --git a/cli/src/commands/import/studio-app/guide.ts b/cli/src/commands/import/studio-app/guide.ts new file mode 100644 index 00000000000..66ecdda9aa5 --- /dev/null +++ b/cli/src/commands/import/studio-app/guide.ts @@ -0,0 +1,17 @@ +export const agentGuide = ` +WHEN TO USE + A studio app is what you build and edit in Studio on the web console, + inside a workspace — the app's source definition. Import materialises a + DSL YAML into a new (or existing) studio app; pair it with + 'export studio-app' to move an app between workspaces or instances. To + run or inspect the result, switch to the 'app' noun. + +BEHAVIOUR + A DSL version mismatch is auto-confirmed; no second command needed. + Missing plugin dependencies are listed on stderr — install them before + running the app. + +ERROR RECOVERY + workspace required difyctl get workspace + not logged in (exit 4) difyctl auth login +` diff --git a/cli/src/commands/import/app/index.ts b/cli/src/commands/import/studio-app/index.ts similarity index 80% rename from cli/src/commands/import/app/index.ts rename to cli/src/commands/import/studio-app/index.ts index fddda88f441..6e0b491199b 100644 --- a/cli/src/commands/import/app/index.ts +++ b/cli/src/commands/import/studio-app/index.ts @@ -1,16 +1,17 @@ import { DifyCommand } from '@/commands/_shared/dify-command' import { httpRetryFlag } from '@/commands/_shared/global-flags' import { Flags } from '@/framework/flags' +import { agentGuide } from './guide' import { pluginDependencyLabel, runImportApp } from './run' -export default class ImportApp extends DifyCommand { - static override description = 'Import an app from a DSL YAML file or URL' +export default class ImportStudioApp extends DifyCommand { + static override description = 'Import a studio app from a DSL YAML file or URL' static override examples = [ - '<%= config.bin %> import app --from-file ./app.yaml', - '<%= config.bin %> import app --from-file /path/to/app.yaml --name "My App"', - '<%= config.bin %> import app --from-url https://example.com/my-app.yaml', - '<%= config.bin %> import app --from-file ./app.yaml --app-id ', + '<%= config.bin %> import studio-app --from-file ./app.yaml', + '<%= config.bin %> import studio-app --from-file /path/to/app.yaml --name "My App"', + '<%= config.bin %> import studio-app --from-url https://example.com/my-app.yaml', + '<%= config.bin %> import studio-app --from-file ./app.yaml --app-id ', ] static override flags = { @@ -27,7 +28,7 @@ export default class ImportApp extends DifyCommand { } async run(argv: string[]) { - const { flags } = this.parse(ImportApp, argv) + const { flags } = this.parse(ImportStudioApp, argv) if (flags['from-file'] === undefined && flags['from-url'] === undefined) this.error('one of --from-file or --from-url is required', { exit: 1 }) if (flags['from-file'] !== undefined && flags['from-url'] !== undefined) @@ -57,4 +58,8 @@ export default class ImportApp extends DifyCommand { ctx.io.err.write(` - ${pluginDependencyLabel(dep)}\n`) } } + + override agentGuide(): string { + return agentGuide + } } diff --git a/cli/src/commands/import/app/run.test.ts b/cli/src/commands/import/studio-app/run.test.ts similarity index 100% rename from cli/src/commands/import/app/run.test.ts rename to cli/src/commands/import/studio-app/run.test.ts diff --git a/cli/src/commands/import/app/run.ts b/cli/src/commands/import/studio-app/run.ts similarity index 100% rename from cli/src/commands/import/app/run.ts rename to cli/src/commands/import/studio-app/run.ts diff --git a/cli/src/commands/resume/app/run.test.ts b/cli/src/commands/resume/app/run.test.ts new file mode 100644 index 00000000000..bbe1024decc --- /dev/null +++ b/cli/src/commands/resume/app/run.test.ts @@ -0,0 +1,66 @@ +import type { ActiveContext } from '@/auth/hosts' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AppRunClient } from '@/api/app-run' +import { AppsClient } from '@/api/apps' +import { PermittedExternalAppsClient } from '@/api/permitted-external-apps' +import { bufferStreams } from '@/sys/io/streams' +import { resumeApp } from './run.js' + +const DESCRIBE_RESULT = { + info: { id: 'app-2', name: 'X', mode: 'workflow', description: '', tags: [], author: '', updated_at: null, service_api_enabled: true, is_agent: false }, + parameters: null, + input_schema: null, +} + +const FORM_RESP = { user_actions: [{ id: 'submit' }] } + +function makeExternalActive(): ActiveContext { + return { + host: 'http://localhost', + email: 'sso@x.io', + ctx: { + account: { id: 'acct-1', email: 'sso@x.io', name: 'SSO User' }, + external_subject: { email: 'sso@x.io', issuer: 'https://issuer.example.com' }, + }, + } as unknown as ActiveContext +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('resumeApp pre-flight subject strategy', () => { + it('external login: mode pre-flight calls PermittedExternalAppsClient.describe, not AppsClient.describe', async () => { + const externalDescribe = vi.fn().mockResolvedValue(DESCRIBE_RESULT) + const externalSpy = vi.spyOn(PermittedExternalAppsClient.prototype, 'describe').mockImplementation(externalDescribe) + const accountSpy = vi.spyOn(AppsClient.prototype, 'describe') + + vi.spyOn(AppRunClient.prototype, 'submitHumanInput').mockResolvedValue(undefined as never) + + const io = bufferStreams() + const http = { + baseURL: 'http://localhost', + request: vi.fn().mockImplementation((opts: { path: string }) => { + if (typeof opts.path === 'string' && opts.path.includes('form/human_input')) { + return Promise.resolve(FORM_RESP) + } + // reconnect stream — return an async iterable that ends immediately + const iter: AsyncIterable = { [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ done: true, value: undefined as never }) }) } + return Promise.resolve(iter) + }), + } as unknown as import('@/http/types').HttpClient + + try { + await resumeApp( + { appId: 'app-2', formToken: 'ft-1', workflowRunId: 'wf-run-1', action: 'submit', inputs: {} }, + { active: makeExternalActive(), http, host: 'http://localhost', io }, + ) + } + catch { + // run may fail after pre-flight due to stream mock; we only check which describe was called + } + + expect(externalSpy).toHaveBeenCalled() + expect(accountSpy).not.toHaveBeenCalled() + }) +}) diff --git a/cli/src/commands/resume/app/run.ts b/cli/src/commands/resume/app/run.ts index 81eb4e01f00..1dc2855a118 100644 --- a/cli/src/commands/resume/app/run.ts +++ b/cli/src/commands/resume/app/run.ts @@ -4,10 +4,11 @@ import type { RunContext } from '@/commands/run/app/_strategies/index' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { AppMetaClient } from '@/api/app-meta' +import { selectAppReader } from '@/api/app-reader' import { AppRunClient } from '@/api/app-run' -import { AppsClient } from '@/api/apps' import { pickStrategy } from '@/commands/run/app/_strategies/index' import { RUN_MODES } from '@/commands/run/app/handlers' +import { resolveInputs, TEXT_FORMATS } from '@/commands/run/app/input-flags' import { processExit } from '@/sys/index' import { colorEnabled, colorScheme } from '@/sys/io/color' import { FieldInfo } from '@/types/app-meta' @@ -37,45 +38,8 @@ export type ResumeAppDeps = { readonly exit?: (code: number) => never } -const TEXT_FORMATS = new Set(['', 'text']) - -async function resolveInputs( - inputsJson: string | undefined, - inputsFile: string | undefined, - directInputs: Readonly> | undefined, -): Promise> { - if (inputsJson !== undefined && inputsFile !== undefined) - throw new Error('--inputs and --inputs-file are mutually exclusive') - if (inputsJson !== undefined) { - let parsed: unknown - try { - parsed = JSON.parse(inputsJson) - } - catch { - throw new Error('--inputs must be valid JSON') - } - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) - throw new Error('--inputs must be a JSON object') - return parsed as Record - } - if (inputsFile !== undefined) { - const { readFile } = await import('node:fs/promises') - let parsed: unknown - try { - parsed = JSON.parse(await readFile(inputsFile, 'utf8')) - } - catch { - throw new Error('--inputs-file must contain valid JSON') - } - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) - throw new Error('--inputs-file must be a JSON object') - return parsed as Record - } - return { ...(directInputs ?? {}) } -} - export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise { - const apps = new AppsClient(deps.http) + const apps = selectAppReader(deps.active, deps.http) const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache }) const m = await meta.get(opts.appId, [FieldInfo]) const mode = m.info?.mode ?? RUN_MODES.Workflow diff --git a/cli/src/commands/run/app/input-flags.ts b/cli/src/commands/run/app/input-flags.ts new file mode 100644 index 00000000000..b6d296ad7c0 --- /dev/null +++ b/cli/src/commands/run/app/input-flags.ts @@ -0,0 +1,42 @@ +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' + +// Output formats that render the run/resume result as plain text rather than JSON/YAML. +export const TEXT_FORMATS = new Set(['', 'text']) + +// Shared by `run app` and `resume app`: --inputs (inline JSON) / --inputs-file (JSON file) / +// direct inputs are mutually exclusive ways to supply the run's variable map. +export async function resolveInputs( + inputsJson: string | undefined, + inputsFile: string | undefined, + directInputs: Readonly> | undefined, +): Promise> { + if (inputsJson !== undefined && inputsFile !== undefined) + throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs and --inputs-file are mutually exclusive' }) + if (inputsJson !== undefined) { + let parsed: unknown + try { + parsed = JSON.parse(inputsJson) + } + catch { + throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be valid JSON' }) + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) + throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be a JSON object' }) + return parsed as Record + } + if (inputsFile !== undefined) { + const { readFile } = await import('node:fs/promises') + let parsed: unknown + try { + parsed = JSON.parse(await readFile(inputsFile, 'utf8')) + } + catch { + throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must contain valid JSON' }) + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) + throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must be a JSON object' }) + return parsed as Record + } + return { ...(directInputs ?? {}) } +} diff --git a/cli/src/commands/run/app/run.test.ts b/cli/src/commands/run/app/run.test.ts index 4fff2d02873..03341776001 100644 --- a/cli/src/commands/run/app/run.test.ts +++ b/cli/src/commands/run/app/run.test.ts @@ -1,11 +1,12 @@ import type { DifyMock } from '@test/fixtures/dify-mock/server' import type { ActiveContext } from '@/auth/hosts' +import type { HttpClient } from '@/http/types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { startMock } from '@test/fixtures/dify-mock/server' import { testHttpClient } from '@test/fixtures/http-client' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { loadAppInfoCache } from '@/cache/app-info' import { resumeApp } from '@/commands/resume/app/run' import { ENV_CACHE_DIR } from '@/store/dir' @@ -381,4 +382,35 @@ describe('runApp', () => { expect(docInput.transfer_method).toBe('remote_url') expect(docInput.url).toBe('https://example.com/override.pdf') }) + + it('external login: mode pre-flight calls PermittedExternalAppsClient.describe, not AppsClient.describe', async () => { + const describeResult = { info: { id: 'app-1', name: 'X', mode: 'chat', description: '', tags: [], author: '', updated_at: null, service_api_enabled: true, is_agent: false }, parameters: null, input_schema: null } + const externalDescribe = vi.fn().mockResolvedValue(describeResult) + const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps') + const { AppsClient } = await import('@/api/apps') + const externalSpy = vi.spyOn(PermittedExternalAppsClient.prototype, 'describe').mockImplementation(externalDescribe) + const accountSpy = vi.spyOn(AppsClient.prototype, 'describe') + const io = bufferStreams() + const http = { baseURL: mock.url, request: vi.fn().mockResolvedValue({ answer: 'echo: hi', conversation_id: 'conv-1', message_id: 'msg-1', mode: 'chat', metadata: {} }) } as unknown as HttpClient + const activeExt: ActiveContext = { + host: mock.url, + email: 'sso@x.io', + ctx: { + account: { id: 'acct-1', email: 'sso@x.io', name: 'SSO User' }, + external_subject: { email: 'sso@x.io', issuer: 'https://issuer.example.com' }, + }, + } + try { + await runApp( + { appId: 'app-1', message: 'hi' }, + { active: activeExt, http, host: mock.url, io }, + ) + } + catch { + // run may fail due to mocked http; we only care about which describe was called + } + expect(externalSpy).toHaveBeenCalled() + expect(accountSpy).not.toHaveBeenCalled() + vi.restoreAllMocks() + }) }) diff --git a/cli/src/commands/run/app/run.ts b/cli/src/commands/run/app/run.ts index 8eb767c5dbe..ab468678a72 100644 --- a/cli/src/commands/run/app/run.ts +++ b/cli/src/commands/run/app/run.ts @@ -3,8 +3,8 @@ import type { AppInfoCache } from '@/cache/app-info' import type { HttpClient } from '@/http/types' import type { IOStreams } from '@/sys/io/streams' import { AppMetaClient } from '@/api/app-meta' +import { selectAppReader } from '@/api/app-reader' import { AppRunClient } from '@/api/app-run' -import { AppsClient } from '@/api/apps' import { FileUploadClient } from '@/api/file-upload' import { pickStrategy } from '@/commands/run/app/_strategies/index' import { BaseError, HttpClientError } from '@/errors/base' @@ -13,6 +13,7 @@ import { processExit } from '@/sys/index' import { FieldInfo } from '@/types/app-meta' import { resolveFileInputs } from './file-flags.js' import { RUN_MODES } from './handlers.js' +import { resolveInputs, TEXT_FORMATS } from './input-flags.js' export type RunAppOptions = { readonly appId: string @@ -40,45 +41,8 @@ export type RunAppDeps = { readonly exit?: (code: number) => never } -const TEXT_FORMATS = new Set(['', 'text']) - -async function resolveInputs( - inputsJson: string | undefined, - inputsFile: string | undefined, - directInputs: Readonly> | undefined, -): Promise> { - if (inputsJson !== undefined && inputsFile !== undefined) - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs and --inputs-file are mutually exclusive' }) - if (inputsJson !== undefined) { - let parsed: unknown - try { - parsed = JSON.parse(inputsJson) - } - catch { - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be valid JSON' }) - } - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be a JSON object' }) - return parsed as Record - } - if (inputsFile !== undefined) { - const { readFile } = await import('node:fs/promises') - let parsed: unknown - try { - parsed = JSON.parse(await readFile(inputsFile, 'utf8')) - } - catch { - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must contain valid JSON' }) - } - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) - throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must be a JSON object' }) - return parsed as Record - } - return { ...(directInputs ?? {}) } -} - export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise { - const apps = new AppsClient(deps.http) + const apps = selectAppReader(deps.active, deps.http) const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache }) try { diff --git a/cli/src/commands/tree.generated.ts b/cli/src/commands/tree.generated.ts index 03963865e56..fdaf8f269ed 100644 --- a/cli/src/commands/tree.generated.ts +++ b/cli/src/commands/tree.generated.ts @@ -17,11 +17,11 @@ import CreateMember from '@/commands/create/member/index' import DeleteMember from '@/commands/delete/member/index' import DescribeApp from '@/commands/describe/app/index' import EnvList from '@/commands/env/list/index' -import ExportApp from '@/commands/export/app/index' +import ExportStudioApp from '@/commands/export/studio-app/index' import GetApp from '@/commands/get/app/index' import GetMember from '@/commands/get/member/index' import GetWorkspace from '@/commands/get/workspace/index' -import ImportApp from '@/commands/import/app/index' +import ImportStudioApp from '@/commands/import/studio-app/index' import ResumeApp from '@/commands/resume/app/index' import RunApp from '@/commands/run/app/index' import SetMember from '@/commands/set/member/index' @@ -77,7 +77,7 @@ export const commandTree: CommandTree = { }, export: { subcommands: { - app: { command: ExportApp, subcommands: {} }, + 'studio-app': { command: ExportStudioApp, subcommands: {} }, }, }, get: { @@ -89,7 +89,7 @@ export const commandTree: CommandTree = { }, import: { subcommands: { - app: { command: ImportApp, subcommands: {} }, + 'studio-app': { command: ImportStudioApp, subcommands: {} }, }, }, resume: { diff --git a/cli/src/help/topics.ts b/cli/src/help/topics.ts index 3da5c0f8dfc..35f2b42718e 100644 --- a/cli/src/help/topics.ts +++ b/cli/src/help/topics.ts @@ -22,6 +22,9 @@ const ACCOUNT_HELP_TEXT = `difyctl: account-bearer onboarding difyctl run app "hello" -o json Tips: + * Two app nouns: 'studio-app' is what you build and edit in Studio on the + web console inside a workspace (its source definition — export or move it); + 'app' is a published app you run and inspect. * 'difyctl auth list' shows your authenticated contexts; 'difyctl use host' and 'difyctl use account' switch between them. * Pass --workspace to target a non-default workspace. @@ -74,6 +77,16 @@ OUTPUT Pass -o json (or -o yaml) on every command — the JSON shape is stable and documented. Without it you get human tables meant for a terminal. +APP vs STUDIO-APP + Two nouns, two faces of the same app: + studio-app what you build and edit in Studio on the web console, + inside a workspace — the app's source definition. + app a published app, live and runnable. + Use 'studio-app' to work with the definition you manage on the website + (export it, move it between workspaces or instances); use 'app' to run + and inspect a published one. The COMMANDS list shows the verbs each + noun supports. + DISCOVERY difyctl help -o json full command tree + this contract, machine-readable difyctl get app -o json list apps (ids + modes) diff --git a/cli/src/http/error-mapper.test.ts b/cli/src/http/error-mapper.test.ts index 0a487723352..3244222da07 100644 --- a/cli/src/http/error-mapper.test.ts +++ b/cli/src/http/error-mapper.test.ts @@ -72,6 +72,25 @@ describe('classifyResponse — canonical ErrorBody', () => { }) }) +describe('classifyResponse 403', () => { + it('maps 403 to AccessDenied (exit 4 bucket)', async () => { + const req403 = new Request('https://x/openapi/v1/apps/abc/export') + const res403 = new Response( + JSON.stringify({ code: 'unsupported_token_type', message: 'unsupported_token_type', status: 403 }), + { status: 403, headers: { 'content-type': 'application/json' } }, + ) + const err = await classifyResponse(req403, res403) + expect(err.code).toBe(ErrorCode.AccessDenied) + expect(err.message).toBe('unsupported_token_type') + }) + + it('403 with no parseable ErrorBody falls back to generic denied message', async () => { + const err = await classified(403, 'not json') + expect(err.code).toBe(ErrorCode.AccessDenied) + expect(err.message).toBe('not permitted') + }) +}) + describe('classifyResponse — non-conforming bodies (no fallback by design)', () => { it('non-JSON body yields no serverError, classification by status', async () => { const err = await classified(502, 'bad gateway') diff --git a/cli/src/http/error-mapper.ts b/cli/src/http/error-mapper.ts index aca1a7e6184..34d7637d4e0 100644 --- a/cli/src/http/error-mapper.ts +++ b/cli/src/http/error-mapper.ts @@ -44,9 +44,17 @@ const RATE_LIMITED_CLASS: StatusClass = { includeRaw: false, } +const ACCESS_DENIED_CLASS: StatusClass = { + code: ErrorCode.AccessDenied, + fallbackMessage: () => 'not permitted', + includeRaw: false, +} + function statusClass(status: number): StatusClass { if (status === 401) return AUTH_EXPIRED_CLASS + if (status === 403) + return ACCESS_DENIED_CLASS if (status === 429) return RATE_LIMITED_CLASS if (status >= 500) diff --git a/cli/src/http/orpc.test.ts b/cli/src/http/orpc.test.ts index c99232b975f..0d1f2e12d57 100644 --- a/cli/src/http/orpc.test.ts +++ b/cli/src/http/orpc.test.ts @@ -44,10 +44,10 @@ describe('createOpenApiClient error mapping', () => { } it('recovers Dify message from a canonical ErrorBody 4xx response', async () => { - const caught = await classifiedError(403, { code: 'access_denied', message: 'no access', status: 403 }) + const caught = await classifiedError(422, { code: 'invalid_param', message: 'no access', status: 422 }) expect(caught.code).toBe(ErrorCode.Server4xxOther) - expect(caught.httpStatus).toBe(403) + expect(caught.httpStatus).toBe(422) expect(caught.message).toBe('no access') // Parity with the transport path: the migrated endpoint's error keeps the request // method/url and the raw body, so formatted errors still print the `request:` line diff --git a/cli/src/types/app-meta.test.ts b/cli/src/types/app-meta.test.ts index b62d81579ea..c1ac765190d 100644 --- a/cli/src/types/app-meta.test.ts +++ b/cli/src/types/app-meta.test.ts @@ -9,8 +9,6 @@ function describeResp(): AppDescribeResponse { name: 'Greeter', description: '', mode: 'chat', - author: 'tester', - tags: [], updated_at: undefined, service_api_enabled: false, is_agent: false, diff --git a/cli/test/e2e/setup/global-setup.ts b/cli/test/e2e/setup/global-setup.ts index 35e171ecafb..20b23295acb 100644 --- a/cli/test/e2e/setup/global-setup.ts +++ b/cli/test/e2e/setup/global-setup.ts @@ -519,14 +519,14 @@ async function provisionApps( async function importAppCli(filePath: string, wsId: string): Promise { const result = await run( - ['import', 'app', '--from-file', filePath, '--workspace', wsId], + ['import', 'studio-app', '--from-file', filePath, '--workspace', wsId], { configDir, timeout: 60_000 }, ) if (result.exitCode !== 0) - throw new Error(`import app failed (exit ${result.exitCode}): ${result.stderr}`) + throw new Error(`import studio-app failed (exit ${result.exitCode}): ${result.stderr}`) const match = result.stderr.match(/app ([0-9a-f-]{36})/) if (!match?.[1]) - throw new Error(`import app: could not parse app_id: ${result.stderr}`) + throw new Error(`import studio-app: could not parse app_id: ${result.stderr}`) return match[1] } diff --git a/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts b/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts index a1dea13e66a..dd83592be36 100644 --- a/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts +++ b/cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts @@ -288,7 +288,7 @@ describe('E2E / agent skill — get app -o json (auth required)', () => { expect(line.trim()).not.toMatch(/\s/) }) - itWithSso('[P0] [SSO] dfoe_ get app → JSON error envelope (insufficient_scope)', async () => { + itWithSso('[P0] [SSO] dfoe_ get app -o json → permitted-apps list envelope', async () => { const tc = await withTempConfig() try { const { mkdir, writeFile } = await import('node:fs/promises') @@ -296,12 +296,21 @@ describe('E2E / agent skill — get app -o json (auth required)', () => { await mkdir(tc.configDir, { recursive: true }) await writeFile( join(tc.configDir, 'hosts.yml'), - `${[`current_host: ${E.host}`, 'token_storage: file', 'tokens:', ` bearer: ${E.ssoToken}`].join('\n')}\n`, + `${[ + `current_host: ${E.host}`, + 'token_storage: file', + 'tokens:', + ` bearer: ${E.ssoToken}`, + 'external_subject:', + ' email: sso@example.com', + ' issuer: https://issuer.example.com', + ].join('\n')}\n`, { mode: 0o600 }, ) const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir }) - expect(r.exitCode).not.toBe(0) - assertErrorEnvelope(r) + assertExitCode(r, 0) + const parsed = assertJson<{ data: unknown[] }>(r) + expect(Array.isArray(parsed.data), 'permitted-apps envelope has a data array').toBe(true) } finally { await tc.cleanup() } }) diff --git a/cli/test/e2e/suites/auth/whoami.e2e.ts b/cli/test/e2e/suites/auth/whoami.e2e.ts index 2caec57dd7e..69404bb9550 100644 --- a/cli/test/e2e/suites/auth/whoami.e2e.ts +++ b/cli/test/e2e/suites/auth/whoami.e2e.ts @@ -57,6 +57,8 @@ describe('E2E / difyctl auth whoami + SSO session', () => { }) } + const itWithSso = optionalIt(Boolean(E.ssoToken)) + // ── auth whoami — internal user ────────────────────────────────────────────── it('[P0] internal user auth whoami outputs email', async () => { @@ -123,12 +125,12 @@ describe('E2E / difyctl auth whoami + SSO session', () => { expect(result.exitCode).not.toBe(0) }) - it('[P0] external user get app returns insufficient_scope error', async () => { - // Spec: external user get app returns insufficient_scope + itWithSso('[P0] external user can list permitted apps via SSO token', async () => { + // External users read apps via the permitted-external surface (no workspace scope). await withSSOAuth() const result = await r(['get', 'app']) - expect(result.exitCode).not.toBe(0) - expect(result.stderr).toMatch(/insufficient|scope|workspace|SSO/i) + assertExitCode(result, 0) + expect(result.stdout).toMatch(/NAME\s+ID\s+MODE/i) }) it('[P0] external user whoami outputs SSO email', async () => { @@ -138,8 +140,6 @@ describe('E2E / difyctl auth whoami + SSO session', () => { expect(result.stdout).toContain('sso-user@example.com') }) - const itWithSso = optionalIt(Boolean(E.ssoToken)) - itWithSso('[P0] external user can execute run app using SSO token', async () => { await injectSsoAuth(configDir, { host: E.host, diff --git a/cli/test/e2e/suites/discovery/describe-app.e2e.ts b/cli/test/e2e/suites/discovery/describe-app.e2e.ts index 75cfe226370..68902a32da6 100644 --- a/cli/test/e2e/suites/discovery/describe-app.e2e.ts +++ b/cli/test/e2e/suites/discovery/describe-app.e2e.ts @@ -67,12 +67,6 @@ describe('E2E / difyctl describe app', () => { expect(result.stdout).toMatch(/Name:/i) }) - it('[P1] describe output contains Tags field', async () => { - const result = await fx.r(['describe', 'app', E.chatAppId]) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/Tags:/i) - }) - // ── Input schema ────────────────────────────────────────────────────────── it('[P0] describe output contains Parameters section', async () => { @@ -172,8 +166,9 @@ describe('E2E / difyctl describe app', () => { // ── External SSO ────────────────────────────────────────────────────────── - itWithSso('[P0] external SSO user describe app returns insufficient_scope (3.86)', async () => { - // Spec 3.86: dfoe_ token → insufficient_scope, exit non-0. + itWithSso('[P0] external SSO user can describe a permitted app', async () => { + // A dfoe_ token resolves `describe app` via the permitted-external surface + // (not the account /apps surface), so a permitted app describes successfully. // Uses DIFY_E2E_SSO_TOKEN; skipped when not configured. const { mkdir, writeFile } = await import('node:fs/promises') const { join } = await import('node:path') @@ -191,8 +186,10 @@ describe('E2E / difyctl describe app', () => { ].join('\n')}\n` await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) const result = await run(['describe', 'app', E.chatAppId], { configDir: ssoTmp.configDir }) - expect(result.exitCode, 'SSO user describe app should exit non-zero').not.toBe(0) - expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i) + assertExitCode(result, 0) + expect(result.stdout).toMatch(/ID:/i) + expect(result.stdout).toContain(E.chatAppId) + expect(result.stdout).toMatch(/Mode:/i) } finally { await ssoTmp.cleanup() @@ -225,16 +222,6 @@ describe('E2E / difyctl describe app', () => { expect(result.stdout).toContain('e2e-test') }) - it('[P1] describe output contains Author field (3.67)', async () => { - // Spec 3.67: output includes Author field when app has an author. - const result = await withRetry( - () => fx.r(['describe', 'app', E.chatAppId]), - { attempts: 3, delayMs: 2000 }, - ) - assertExitCode(result, 0) - expect(result.stdout).toMatch(/Author:/i) - }) - it('[P0] Inputs section shows parameter names (3.70)', async () => { // Spec 3.70: Parameters/Inputs section displays variable names. // workflow app has x, num, enum_var, paragraph. diff --git a/cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts b/cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts index 38d9e0a427f..271bb3381fd 100644 --- a/cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts +++ b/cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts @@ -151,15 +151,15 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => { // ── External SSO ────────────────────────────────────────────────────────── - itWithSso('[P0] external SSO user get app -A returns insufficient_scope error (3.103)', async () => { - // Spec 3.103: dfoe_ token on -A → insufficient_scope, exit non-0. - // Merged from two duplicate fake-token cases; now uses real DIFY_E2E_SSO_TOKEN. + itWithSso('[P0] external SSO user get app -A is rejected as an invalid flag', async () => { + // --all-workspaces is meaningless for external SSO users (no workspace + // scope), so the CLI rejects it client-side with usage_invalid_flag (exit 2). + // Uses real DIFY_E2E_SSO_TOKEN; skipped when not configured. const { mkdir, writeFile } = await import('node:fs/promises') const { join } = await import('node:path') const ssoTmp = await withTempConfig() try { await mkdir(ssoTmp.configDir, { recursive: true }) - // Use minimal SSO hosts.yml (no workspace) so CLI hits the scope/auth error path. const hostsYml = `${[ `current_host: ${E.host}`, `token_storage: file`, @@ -171,8 +171,8 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => { ].join('\n')}\n` await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) const result = await run(['get', 'app', '-A'], { configDir: ssoTmp.configDir }) - expect(result.exitCode, 'SSO user -A should exit non-zero').not.toBe(0) - expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth|missing/i) + assertExitCode(result, 2) + expect(result.stderr).toMatch(/--all-workspaces is not available for external logins/) } finally { await ssoTmp.cleanup() 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 781d351826b..af2ac2fb800 100644 --- a/cli/test/e2e/suites/discovery/get-app-list.e2e.ts +++ b/cli/test/e2e/suites/discovery/get-app-list.e2e.ts @@ -206,17 +206,15 @@ describe('E2E / difyctl get app (list)', () => { // ── External SSO ────────────────────────────────────────────────────────── - itWithSso('[P0] external SSO user get app returns insufficient_scope error (3.24 / 3.25)', async () => { - // Spec 3.24: dfoe_ token → insufficient_scope; Spec 3.25: exit code is 1. + itWithSso('[P0] external SSO user can list permitted apps', async () => { + // A dfoe_ token lists apps via the permitted-external surface + // (apps:read:permitted-external scope), with no workspace scoping. // Uses DIFY_E2E_SSO_TOKEN (itWithSso skips when not configured). const { mkdir, writeFile } = await import('node:fs/promises') const { join } = await import('node:path') const ssoTmp = await withTempConfig() try { await mkdir(ssoTmp.configDir, { recursive: true }) - // SSO (dfoe_) users have apps:run scope only, not apps:list. - // Inject a minimal hosts.yml without workspace so the CLI reaches the - // scope-check path rather than resolving the workspace successfully. const hostsYml = `${[ `current_host: ${E.host}`, `token_storage: file`, @@ -228,8 +226,8 @@ describe('E2E / difyctl get app (list)', () => { ].join('\n')}\n` await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) const result = await run(['get', 'app'], { configDir: ssoTmp.configDir }) - expect(result.exitCode, 'SSO user get app should exit non-zero').not.toBe(0) - expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i) + assertExitCode(result, 0) + expect(result.stdout).toMatch(/NAME\s+ID\s+MODE/i) } finally { await ssoTmp.cleanup() diff --git a/cli/test/e2e/suites/discovery/get-app-single.e2e.ts b/cli/test/e2e/suites/discovery/get-app-single.e2e.ts index b09ce25d679..c1fba19d245 100644 --- a/cli/test/e2e/suites/discovery/get-app-single.e2e.ts +++ b/cli/test/e2e/suites/discovery/get-app-single.e2e.ts @@ -68,8 +68,9 @@ describe('E2E / difyctl get app (single)', () => { // ── External SSO ────────────────────────────────────────────────────────── - itWithSso('[P0] external SSO user get app returns insufficient_scope error (3.55)', async () => { - // Spec 3.55: dfoe_ token on get app → insufficient_scope, exit 1. + itWithSso('[P0] external SSO user can get a permitted app by id', async () => { + // A dfoe_ token resolves get app via the permitted-external describe + // surface (apps:read:permitted-external scope), so a permitted app is returned. // Uses DIFY_E2E_SSO_TOKEN; skipped when not configured. const { mkdir, writeFile } = await import('node:fs/promises') const { join } = await import('node:path') @@ -87,8 +88,8 @@ describe('E2E / difyctl get app (single)', () => { ].join('\n')}\n` await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) const result = await run(['get', 'app', E.chatAppId], { configDir: ssoTmp.configDir }) - expect(result.exitCode, 'SSO user get app should exit non-zero').not.toBe(0) - expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i) + assertExitCode(result, 0) + expect(result.stdout).toContain(E.chatAppId) } finally { await ssoTmp.cleanup() diff --git a/cli/test/e2e/suites/dsl/export-app.e2e.ts b/cli/test/e2e/suites/dsl/export-studio-app.e2e.ts similarity index 82% rename from cli/test/e2e/suites/dsl/export-app.e2e.ts rename to cli/test/e2e/suites/dsl/export-studio-app.e2e.ts index f96fbd216a4..e158ceaccec 100644 --- a/cli/test/e2e/suites/dsl/export-app.e2e.ts +++ b/cli/test/e2e/suites/dsl/export-studio-app.e2e.ts @@ -1,5 +1,5 @@ /** - * E2E: difyctl export app — DSL export + * E2E: difyctl export studio-app — DSL export * * Prerequisites (DIFY_E2E_* env vars): * DIFY_E2E_WORKFLOW_APP_ID — echo-workflow app (no model provider dependency) @@ -21,7 +21,7 @@ import { resolveEnv } from '../../setup/env.js' const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities const E = resolveEnv(caps) -describe('E2E / difyctl export app', () => { +describe('E2E / difyctl export studio-app', () => { let fx: AuthFixture beforeEach(async () => { @@ -34,37 +34,37 @@ describe('E2E / difyctl export app', () => { // ── Basic export ────────────────────────────────────────────────────────── it('[P0] exported DSL is non-empty YAML printed to stdout', async () => { - const result = await fx.r(['export', 'app', E.workflowAppId]) + const result = await fx.r(['export', 'studio-app', E.workflowAppId]) assertExitCode(result, 0) expect(result.stdout.trim().length).toBeGreaterThan(0) }) it('[P0] exported YAML contains kind: app', async () => { - const result = await fx.r(['export', 'app', E.workflowAppId]) + const result = await fx.r(['export', 'studio-app', E.workflowAppId]) assertExitCode(result, 0) expect(result.stdout).toMatch(/^kind:\s*app/m) }) it('[P0] exported YAML contains version field', async () => { - const result = await fx.r(['export', 'app', E.workflowAppId]) + const result = await fx.r(['export', 'studio-app', E.workflowAppId]) assertExitCode(result, 0) expect(result.stdout).toMatch(/^version:/m) }) it('[P0] exported YAML contains app section with mode', async () => { - const result = await fx.r(['export', 'app', E.workflowAppId]) + const result = await fx.r(['export', 'studio-app', E.workflowAppId]) assertExitCode(result, 0) expect(result.stdout).toMatch(/^\s+mode:/m) }) it('[P1] exported YAML ends with a newline (POSIX pipe convention)', async () => { - const result = await fx.r(['export', 'app', E.workflowAppId]) + const result = await fx.r(['export', 'studio-app', E.workflowAppId]) assertExitCode(result, 0) expect(result.stdout.endsWith('\n')).toBe(true) }) it('[P1] chat app export also succeeds and includes mode', async () => { - const result = await fx.r(['export', 'app', E.chatAppId]) + const result = await fx.r(['export', 'studio-app', E.chatAppId]) assertExitCode(result, 0) expect(result.stdout).toMatch(/^kind:\s*app/m) expect(result.stdout).toMatch(/^\s+mode:/m) @@ -76,7 +76,7 @@ describe('E2E / difyctl export app', () => { const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-')) const outPath = join(dir, 'exported.yaml') try { - const result = await fx.r(['export', 'app', E.workflowAppId, '--output', outPath]) + const result = await fx.r(['export', 'studio-app', E.workflowAppId, '--output', outPath]) assertExitCode(result, 0) const content = await readFile(outPath, 'utf8') expect(content).toMatch(/^kind:\s*app/m) @@ -92,8 +92,8 @@ describe('E2E / difyctl export app', () => { const outPath = join(dir, 'exported.yaml') try { const [stdoutResult, fileResult] = await Promise.all([ - fx.r(['export', 'app', E.workflowAppId]), - fx.r(['export', 'app', E.workflowAppId, '--output', outPath]).then(async (r) => { + fx.r(['export', 'studio-app', E.workflowAppId]), + fx.r(['export', 'studio-app', E.workflowAppId, '--output', outPath]).then(async (r) => { const content = await readFile(outPath, 'utf8') return { exitCode: r.exitCode, content } }), @@ -113,12 +113,12 @@ describe('E2E / difyctl export app', () => { const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-roundtrip-')) const dslPath = join(dir, 'roundtrip.yaml') try { - const exportResult = await fx.r(['export', 'app', E.workflowAppId, '--output', dslPath]) + const exportResult = await fx.r(['export', 'studio-app', E.workflowAppId, '--output', dslPath]) assertExitCode(exportResult, 0) const importResult = await fx.r([ 'import', - 'app', + 'studio-app', '--from-file', dslPath, '--name', @@ -137,7 +137,7 @@ describe('E2E / difyctl export app', () => { // ── Error scenarios ─────────────────────────────────────────────────────── it('[P0] non-existent app returns exit code 1 with error in stderr', async () => { - const result = await fx.r(['export', 'app', 'nonexistent-app-id-export-e2e']) + const result = await fx.r(['export', 'studio-app', 'nonexistent-app-id-export-e2e']) expect(result.exitCode).toBe(1) expect(result.stderr.length).toBeGreaterThan(0) }) @@ -145,7 +145,7 @@ describe('E2E / difyctl export app', () => { it('[P0] unauthenticated export returns auth error (exit code 4)', async () => { const unauthTmp = await withTempConfig() try { - const result = await run(['export', 'app', E.workflowAppId], { + const result = await run(['export', 'studio-app', E.workflowAppId], { configDir: unauthTmp.configDir, }) assertExitCode(result, 4) @@ -156,13 +156,13 @@ describe('E2E / difyctl export app', () => { }) it('[P1] export with missing app id argument exits non-zero', async () => { - const result = await fx.r(['export', 'app']) + const result = await fx.r(['export', 'studio-app']) expect(result.exitCode).not.toBe(0) expect(result.stderr).toMatch(/missing required argument|required|app id/i) }) it('[P1] malformed --workflow-id returns a 4xx, not a 5xx', async () => { - const result = await fx.r(['export', 'app', E.workflowAppId, '--workflow-id', 'not-a-uuid']) + const result = await fx.r(['export', 'studio-app', E.workflowAppId, '--workflow-id', 'not-a-uuid']) expect(result.exitCode).not.toBe(0) expect(result.stderr).toMatch(/http_status:\s*4\d\d/) expect(result.stderr).not.toMatch(/http_status:\s*5\d\d/) @@ -171,7 +171,7 @@ describe('E2E / difyctl export app', () => { it('[P1] non-existent --workflow-id returns 404, not a 5xx', async () => { const result = await fx.r([ 'export', - 'app', + 'studio-app', E.workflowAppId, '--workflow-id', '00000000-0000-0000-0000-000000000000', @@ -184,7 +184,7 @@ describe('E2E / difyctl export app', () => { const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-nofile-')) const outPath = join(dir, 'should-not-exist.yaml') try { - const result = await fx.r(['export', 'app', 'nonexistent-app-id-nofile-e2e', '--output', outPath]) + const result = await fx.r(['export', 'studio-app', 'nonexistent-app-id-nofile-e2e', '--output', outPath]) expect(result.exitCode).not.toBe(0) const exists = await readFile(outPath, 'utf8').then(() => true).catch(() => false) expect(exists, 'output file must not be created on export failure').toBe(false) diff --git a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts index 932998d9afe..5c4a9f79fa0 100644 --- a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts +++ b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts @@ -82,10 +82,9 @@ describe('E2E / error message standards (spec 5.3)', () => { // ── 5.63 dfoe_ token insufficient_scope ────────────────────────────────── - itWithSso('[P0] 5.63 dfoe_ SSO token with workspace returns insufficient_scope for management commands', async () => { - // Spec 5.63: an external SSO token (dfoe_) must not be able to access - // internal management APIs; the CLI must return an insufficient_scope - // error with exit 1. + itWithSso('[P0] dfoe_ SSO token is denied account-only management commands', async () => { + // A dfoe_ SSO token is rejected with a non-zero exit when it targets an + // account-only management command (`export studio-app`). const { mkdir } = await import('node:fs/promises') const ssoTmp = await withTempConfig() try { @@ -95,16 +94,13 @@ describe('E2E / error message standards (spec 5.3)', () => { `token_storage: file`, `tokens:`, ` bearer: ${E.ssoToken}`, - `workspace:`, - ` id: ${E.workspaceId}`, - ` name: "${E.workspaceName}"`, - ` role: member`, + `external_subject:`, + ` email: sso@example.com`, + ` issuer: https://issuer.example.com`, ].join('\n')}\n` await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 }) - const result = await run(['get', 'app'], { configDir: ssoTmp.configDir }) + const result = await run(['export', 'studio-app', E.chatAppId], { configDir: ssoTmp.configDir }) assertNonZeroExit(result) - // In this environment ssoToken may be a dfoa_ token; the server returns - // either insufficient_scope or server_5xx — both are non-zero exits. expect(result.stderr.trim().length, 'stderr must contain an error message').toBeGreaterThan(0) } finally { diff --git a/cli/test/fixtures/dify-mock/server.ts b/cli/test/fixtures/dify-mock/server.ts index 96edc96f9ba..9dc07bca877 100644 --- a/cli/test/fixtures/dify-mock/server.ts +++ b/cli/test/fixtures/dify-mock/server.ts @@ -269,8 +269,34 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { name: app.name, description: app.description, mode: app.mode, - author: app.author ?? '', - tags: app.tags, + updated_at: app.updated_at, + service_api_enabled: app.service_api_enabled ?? false, + is_agent: app.is_agent ?? false, + } + : null, + parameters: wantParams ? (app.parameters ?? null) : null, + input_schema: wantInputSchema ? (app.input_schema ?? null) : null, + }) + }) + + app.get('/openapi/v1/permitted-external-apps/:id/describe', (c) => { + const id = c.req.param('id') + const fieldsRaw = c.req.query('fields') ?? '' + const fields = fieldsRaw === '' ? [] : fieldsRaw.split(',').map(s => s.trim()).filter(s => s !== '') + // External subjects have no workspace scope; the app is reachable across workspaces. + const app = APPS.find(a => a.id === id) + if (app === undefined) + return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 }) + const wantInfo = fields.length === 0 || fields.includes('info') + const wantParams = fields.length === 0 || fields.includes('parameters') + const wantInputSchema = fields.length === 0 || fields.includes('input_schema') + return c.json({ + info: wantInfo + ? { + id: app.id, + name: app.name, + description: app.description, + mode: app.mode, updated_at: app.updated_at, service_api_enabled: app.service_api_enabled ?? false, is_agent: app.is_agent ?? false, diff --git a/packages/contracts/generated/api/openapi/orpc.gen.ts b/packages/contracts/generated/api/openapi/orpc.gen.ts index bc7cbea340b..47aa1b90d6a 100644 --- a/packages/contracts/generated/api/openapi/orpc.gen.ts +++ b/packages/contracts/generated/api/openapi/orpc.gen.ts @@ -30,6 +30,9 @@ import { zGetHealthResponse, zGetOauthDeviceLookupQuery, zGetOauthDeviceLookupResponse, + zGetPermittedExternalAppsByAppIdDescribePath, + zGetPermittedExternalAppsByAppIdDescribeQuery, + zGetPermittedExternalAppsByAppIdDescribeResponse, zGetPermittedExternalAppsQuery, zGetPermittedExternalAppsResponse, zGetVersionResponse, @@ -450,6 +453,30 @@ export const oauth = { } export const get12 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getPermittedExternalAppsByAppIdDescribe', + path: '/permitted-external-apps/{app_id}/describe', + tags: ['openapi'], + }) + .input( + z.object({ + params: zGetPermittedExternalAppsByAppIdDescribePath, + query: zGetPermittedExternalAppsByAppIdDescribeQuery.optional(), + }), + ) + .output(zGetPermittedExternalAppsByAppIdDescribeResponse) + +export const describe2 = { + get: get12, +} + +export const byAppId2 = { + describe: describe2, +} + +export const get13 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -461,7 +488,8 @@ export const get12 = oc .output(zGetPermittedExternalAppsResponse) export const permittedExternalApps = { - get: get12, + get: get13, + byAppId: byAppId2, } export const post9 = oc @@ -544,7 +572,7 @@ export const byMemberId = { role, } -export const get13 = oc +export const get14 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -578,7 +606,7 @@ export const post11 = oc .output(zPostWorkspacesByWorkspaceIdMembersResponse) export const members = { - get: get13, + get: get14, post: post11, byMemberId, } @@ -598,7 +626,7 @@ export const switch_ = { post: post12, } -export const get14 = oc +export const get15 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -610,13 +638,13 @@ export const get14 = oc .output(zGetWorkspacesByWorkspaceIdResponse) export const byWorkspaceId = { - get: get14, + get: get15, apps: apps2, members, switch: switch_, } -export const get15 = oc +export const get16 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -627,7 +655,7 @@ export const get15 = oc .output(zGetWorkspacesResponse) export const workspaces = { - get: get15, + get: get16, byWorkspaceId, } diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index e1217f3f6d7..b3262944a90 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -20,14 +20,12 @@ export type AccountResponse = { } export type AppDescribeInfo = { - author?: string | null description?: string | null id: string is_agent?: boolean mode: string name: string service_api_enabled: boolean - tags?: Array updated_at?: string | null } @@ -66,13 +64,11 @@ export type AppDslImportPayload = { yaml_url?: string | null } -export type AppInfoResponse = { - author?: string | null +export type AppInfo = { description?: string | null id: string mode: string name: string - tags?: Array } export type AppListQuery = { @@ -945,6 +941,32 @@ export type GetPermittedExternalAppsResponses = { export type GetPermittedExternalAppsResponse = GetPermittedExternalAppsResponses[keyof GetPermittedExternalAppsResponses] +export type GetPermittedExternalAppsByAppIdDescribeData = { + body?: never + path: { + app_id: string + } + query?: { + fields?: string + } + url: '/permitted-external-apps/{app_id}/describe' +} + +export type GetPermittedExternalAppsByAppIdDescribeErrors = { + 422: ErrorBody + default: ErrorBody +} + +export type GetPermittedExternalAppsByAppIdDescribeError + = GetPermittedExternalAppsByAppIdDescribeErrors[keyof GetPermittedExternalAppsByAppIdDescribeErrors] + +export type GetPermittedExternalAppsByAppIdDescribeResponses = { + 200: AppDescribeResponse +} + +export type GetPermittedExternalAppsByAppIdDescribeResponse + = GetPermittedExternalAppsByAppIdDescribeResponses[keyof GetPermittedExternalAppsByAppIdDescribeResponses] + export type GetWorkspacesData = { body?: never path?: never diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 51a3cb8f480..b1f6b1554f1 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -11,6 +11,19 @@ export const zAccountPayload = z.object({ name: z.string(), }) +/** + * AppDescribeInfo + */ +export const zAppDescribeInfo = z.object({ + description: z.string().nullish(), + id: z.string(), + is_agent: z.boolean().optional().default(false), + mode: z.string(), + name: z.string(), + service_api_enabled: z.boolean(), + updated_at: z.string().nullish(), +}) + /** * AppDescribeQuery * @@ -22,6 +35,15 @@ export const zAppDescribeQuery = z.object({ fields: z.string().optional(), }) +/** + * AppDescribeResponse + */ +export const zAppDescribeResponse = z.object({ + info: zAppDescribeInfo.nullish(), + input_schema: z.record(z.string(), z.unknown()).nullish(), + parameters: z.record(z.string(), z.unknown()).nullish(), +}) + /** * AppDslExportQuery * @@ -58,6 +80,16 @@ export const zAppDslImportPayload = z.object({ yaml_url: z.string().nullish(), }) +/** + * AppInfo + */ +export const zAppInfo = z.object({ + description: z.string().nullish(), + id: z.string(), + mode: z.string(), + name: z.string(), +}) + /** * AppMode */ @@ -465,42 +497,6 @@ export const zTagItem = z.object({ name: z.string(), }) -/** - * AppDescribeInfo - */ -export const zAppDescribeInfo = z.object({ - author: z.string().nullish(), - description: z.string().nullish(), - id: z.string(), - is_agent: z.boolean().optional().default(false), - mode: z.string(), - name: z.string(), - service_api_enabled: z.boolean(), - tags: z.array(zTagItem).optional().default([]), - updated_at: z.string().nullish(), -}) - -/** - * AppDescribeResponse - */ -export const zAppDescribeResponse = z.object({ - info: zAppDescribeInfo.nullish(), - input_schema: z.record(z.string(), z.unknown()).nullish(), - parameters: z.record(z.string(), z.unknown()).nullish(), -}) - -/** - * AppInfoResponse - */ -export const zAppInfoResponse = z.object({ - author: z.string().nullish(), - description: z.string().nullish(), - id: z.string(), - mode: z.string(), - name: z.string(), - tags: z.array(zTagItem).optional().default([]), -}) - /** * AppListRow */ @@ -896,6 +892,19 @@ export const zGetPermittedExternalAppsQuery = z.object({ */ export const zGetPermittedExternalAppsResponse = zPermittedExternalAppsListResponse +export const zGetPermittedExternalAppsByAppIdDescribePath = z.object({ + app_id: z.string(), +}) + +export const zGetPermittedExternalAppsByAppIdDescribeQuery = z.object({ + fields: z.string().optional(), +}) + +/** + * Permitted external app description + */ +export const zGetPermittedExternalAppsByAppIdDescribeResponse = zAppDescribeResponse + /** * Workspace list */