Compare commits

..

21 Commits

Author SHA1 Message Date
c3f56fcc9a refactor(openapi/cli): drop tag/author from app usage-face noun
The app noun is the usage face; tags and author are build/management
metadata that belong to studio-app, not here. Remove them end to end:

- backend: drop tags/created_by_name from AppListRow, tag from
  AppListQuery, the TagItem model, and the tag-name filter lookup;
  stop hardcoding the cross-tenant blanks in the permitted-external list
- cli: remove the --tag flag, TAGS/AUTHOR columns, and tag from the
  list query; single get app <id> no longer fabricates the fields
- regenerate openapi contracts (types/zod) and markdown docs

get app and get app <id> now agree (neither surfaces tags/author),
resolving the list-vs-single divergence raised in review.
2026-06-21 21:25:13 -07:00
00995545e7 fix import 2026-06-22 11:28:58 +08:00
475dafb8b7 fix missing decorator 2026-06-22 11:19:20 +08:00
a0bdb16cb9 Merge branch 'feat/openapi-rbac' into deploy/enterprise 2026-06-22 11:01:12 +08:00
4111751bdf 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.
2026-06-21 19:48:22 -07:00
c62276d7de Merge remote-tracking branch 'origin/main' into deploy/enterprise 2026-06-21 19:47:32 -07:00
8cc690268b fix: invalidate credential cache after OAuth refresh (#37630) 2026-06-22 02:30:32 +00:00
8cc6b16661 Merge remote-tracking branch 'upstream/main' into feat/openapi-rbac 2026-06-22 10:25:29 +08:00
yyh
f06127aaa4 test(dify-ui): align select form story with field primitives (#37670) 2026-06-22 02:17:35 +00:00
yyh
8c484411ea fix(web): simplify completed drawer dismissal (#37664) 2026-06-22 02:17:24 +00:00
yyh
4c083e76e2 feat(web): add app shell skip navigation (#37644) 2026-06-22 02:17:18 +00:00
yyh
24080010c9 chore(deps): bump base-ui to v1.6.0 (#37663) 2026-06-22 02:17:07 +00:00
c7ceaa5fe2 fix typecheck 2026-06-17 18:42:24 +08:00
547340ecca allow enduser access for app list/desc 2026-06-17 11:23:10 +08:00
0ca14cd8ad [autofix.ci] apply automated fixes 2026-06-17 03:18:50 +00:00
3c8d03d24f handle enduser in decorator 2026-06-17 11:15:34 +08:00
39bf04e7fe [autofix.ci] apply automated fixes 2026-06-17 02:30:55 +00:00
c38cba1f8c reorder decorators 2026-06-17 10:27:32 +08:00
a811522d5f Merge remote-tracking branch 'upstream/main' into feat/openapi-rbac 2026-06-17 09:53:32 +08:00
f533e992d4 fix(hitl): scope OpenAPI/Service-API resume to author-configured webapp forms
Pause-time token emission now draws only from the recipient set each API
surface is allowed to act on (emit ⊆ validate), so the CLI/OpenAPI caller is
never handed a token the resume endpoint would reject as 404 (WTA-867).

A form's recipients are partitioned once, per surface, into a single
FormDisposition: the surface-actionable recipient yields `form_token`, while
the rest are reported as `approval_channels` (e.g. ["email", "console"]) so the
caller is told where approval actually happens. Token and channels are two
projections of one decision (disposition_for_surface) loaded by one recipient
query (load_form_dispositions_by_form_id); the live pause path and the
reconnect snapshot path consume the same FormDisposition so they cannot drift.

RecipientType carries its user-facing approval-channel label as an enum tuple
value, set in __new__, so a new recipient type cannot be declared without one.

Tests: consolidate recipient/disposition/enrich tests into parametrized
matrices, add CONSOLE-surface and empty-token coverage, extract a shared fake
session for the pause-event tests.
2026-06-16 16:11:29 -07:00
d82b6fe48e guard openapi with rbac decorator 2026-06-15 13:51:30 +08:00
162 changed files with 1813 additions and 908 deletions

View File

@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
@ -91,7 +91,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
@ -142,7 +142,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false

View File

@ -20,7 +20,7 @@ jobs:
run: echo "autofix.ci updates pull request branches, not merge group refs."
- if: github.event_name != 'merge_group'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Check Docker Compose inputs
if: github.event_name != 'merge_group'

View File

@ -79,7 +79,7 @@ jobs:
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@ -123,7 +123,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@ -170,7 +170,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@ -233,7 +233,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@ -295,7 +295,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@ -351,7 +351,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false

View File

@ -23,7 +23,7 @@ jobs:
working-directory: ./cli
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 0

View File

@ -35,7 +35,7 @@ jobs:
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -98,7 +98,7 @@ jobs:
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 1

View File

@ -24,7 +24,7 @@ jobs:
shell: bash
steps:
- name: Checkout cli ref
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false

View File

@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
@ -63,7 +63,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false

View File

@ -24,7 +24,7 @@ jobs:
name: Require cherry-pick provenance
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0

View File

@ -48,7 +48,7 @@ jobs:
vdb-changed: ${{ steps.changes.outputs.vdb }}
migration-changed: ${{ steps.changes.outputs.migration }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:

View File

@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0

View File

@ -21,7 +21,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
steps:
- name: Checkout default branch (trusted code)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0

View File

@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0

View File

@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -71,7 +71,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -114,7 +114,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -171,7 +171,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
@ -189,7 +189,7 @@ jobs:
.editorconfig
- name: Super-linter
uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
uses: super-linter/super-linter/slim@9e863354e3ff62e0727d37183162c4a88873df41 # v8.6.0
if: steps.changed-files.outputs.any_changed == 'true'
env:
BASH_SEVERITY: warning

View File

@ -24,7 +24,7 @@ jobs:
working-directory: sdks/nodejs-client
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

@ -40,7 +40,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@ -158,7 +158,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@2fee15510437d71399d9139ed60433470484a8fb # v1.0.153
uses: anthropics/claude-code-action@806af32823ef69c8ef357086c573a902af641307 # v1.0.151
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0

View File

@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

@ -31,7 +31,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -64,7 +64,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -102,7 +102,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@ -134,7 +134,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

@ -20,6 +20,7 @@ Private helpers
from collections.abc import Callable
from functools import wraps
from typing import TYPE_CHECKING
from sqlalchemy import select
from werkzeug.exceptions import Forbidden, NotFound
@ -32,9 +33,39 @@ from models.dataset import Dataset
from models.model import App
from services.enterprise.rbac_service import RBACService
if TYPE_CHECKING:
from controllers.openapi.auth.data import AuthData
__all__ = ["RBACPermission", "RBACResourceScope", "rbac_permission_required"]
def openapi_rbac_permission_required[**P, R](
resource_type: RBACResourceScope,
scene: RBACPermission,
*,
resource_required: bool = True,
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""RBAC guard for OpenAPI endpoints that may be called by either an Account or an EndUser."""
inner = rbac_permission_required(resource_type, scene, resource_required=resource_required)
def decorator(view: Callable[P, R]) -> Callable[P, R]:
guarded = inner(view)
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
auth_data: "AuthData | None" = kwargs.get("auth_data")
if not auth_data:
raise Forbidden() # openapi auth pipeline is required
if auth_data.caller_kind == "end_user":
# end_user is handled by openapi scope control
return view(*args, **kwargs)
return guarded(*args, **kwargs)
return decorated
return decorator
def rbac_permission_required[**P, R](
resource_type: RBACResourceScope,
scene: RBACPermission,

View File

@ -31,7 +31,7 @@ from controllers.openapi._models import (
AppDslExportQuery,
AppDslExportResponse,
AppDslImportPayload,
AppInfoResponse,
AppInfo,
AppListQuery,
AppListResponse,
AppListRow,
@ -62,7 +62,6 @@ from controllers.openapi._models import (
SessionListQuery,
SessionListResponse,
SessionRow,
TagItem,
TaskStopResponse,
UsageInfo,
WorkflowRunData,
@ -96,12 +95,11 @@ register_response_schema_models(
openapi_ns,
ErrorBody,
EventStreamResponse,
TagItem,
UsageInfo,
MessageMetadata,
AppListRow,
AppListResponse,
AppInfoResponse,
AppInfo,
AppDescribeInfo,
AppDescribeResponse,
AppDslExportResponse,

View File

@ -63,6 +63,8 @@ class OpenApiErrorCode(StrEnum):
FILE_EXTENSION_BLOCKED = "file_extension_blocked"
MEMBER_LIMIT_EXCEEDED = "member_limit_exceeded"
MEMBER_LICENSE_EXCEEDED = "member_license_exceeded"
HUMAN_INPUT_FORM_NOT_FOUND = "form_not_found"
RECIPIENT_SURFACE_MISMATCH = "recipient_surface_mismatch"
class ErrorDetail(BaseModel):
@ -239,3 +241,16 @@ class MemberLicenseExceeded(OpenApiError): # noqa: N818
error_code = OpenApiErrorCode.MEMBER_LICENSE_EXCEEDED
description = "Workspace member license capacity reached."
hint = "Contact your workspace administrator to expand the license seat count."
class HumanInputFormNotFound(OpenApiError): # noqa: N818
code = 404
error_code = OpenApiErrorCode.HUMAN_INPUT_FORM_NOT_FOUND
description = "No human-input form matches this token. It may be wrong, expired, or already submitted."
class RecipientSurfaceMismatch(OpenApiError): # noqa: N818
code = 403
error_code = OpenApiErrorCode.RECIPIENT_SURFACE_MISMATCH
description = "This form's recipient can't be submitted via the OpenAPI surface."
hint = "Action it through its channel (web app or console)."

View File

@ -38,18 +38,12 @@ class PaginationEnvelope[T](BaseModel):
return cls(page=page, limit=limit, total=total, has_more=page * limit < total, data=items)
class TagItem(BaseModel):
name: str
class AppListRow(BaseModel):
id: str
name: str
description: str | None = None
mode: AppMode
tags: list[TagItem] = []
updated_at: str | None = None
created_by_name: str | None = None
workspace_id: str | None = None
workspace_name: str | None = None
@ -70,16 +64,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
@ -294,7 +286,6 @@ class AppListQuery(BaseModel):
limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT)
mode: AppMode | None = None
name: str | None = Field(None, max_length=200)
tag: str | None = Field(None, max_length=100)
class AppRunRequest(BaseModel):

View File

@ -5,6 +5,7 @@ from typing import cast
from flask_restx import Resource
from sqlalchemy.orm import Session
from controllers.common.wraps import RBACPermission, RBACResourceScope, rbac_permission_required
from controllers.openapi import openapi_ns
from controllers.openapi._contract import accepts, returns
from controllers.openapi._models import AppDslExportQuery, AppDslExportResponse, AppDslImportPayload
@ -38,6 +39,7 @@ class AppDslImportApi(Resource):
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
)
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL, resource_required=False)
@returns(200, Import, "Import completed")
@returns(202, Import, "Import pending confirmation")
@returns(400, Import, "Import failed")
@ -126,6 +128,7 @@ class AppDslExportApi(Resource):
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
)
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL)
@accepts(query=AppDslExportQuery)
@returns(200, AppDslExportResponse, "Export successful")
def get(self, app_id: str, *, auth_data: AuthData, query: AppDslExportQuery):
@ -156,6 +159,7 @@ class AppDslCheckDependenciesApi(Resource):
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
)
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL)
@returns(200, CheckDependenciesResult, "Dependencies checked")
def get(self, app_id: str, *, auth_data: AuthData):
app = cast(App, auth_data.app)

View File

@ -19,6 +19,7 @@ from werkzeug.exceptions import (
import services
from controllers.common.fields import EventStreamResponse
from controllers.common.wraps import RBACPermission, RBACResourceScope, openapi_rbac_permission_required
from controllers.openapi import openapi_ns
from controllers.openapi._audit import emit_app_run
from controllers.openapi._contract import accepts, returns
@ -137,6 +138,7 @@ _DISPATCH: dict[AppMode, Callable[[App, Any, AppRunRequest], Any]] = {
@openapi_ns.route("/apps/<string:app_id>/run")
class AppRunApi(Resource):
@auth_router.guard(scope=Scope.APPS_RUN)
@openapi_rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
@openapi_ns.response(200, "Run result (SSE stream)", openapi_ns.models[EventStreamResponse.__name__])
@accepts(body=AppRunRequest)
def post(self, app_id: str, *, auth_data: AuthData, body: AppRunRequest):
@ -168,6 +170,7 @@ class AppRunApi(Resource):
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>/stop")
class AppRunTaskStopApi(Resource):
@auth_router.guard(scope=Scope.APPS_RUN)
@openapi_rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
@returns(200, TaskStopResponse, description="Task stopped")
def post(self, app_id: str, task_id: str, *, auth_data: AuthData):
app_model, caller, caller_kind = auth_data.require_app_context()

View File

@ -9,6 +9,7 @@ from flask_restx import Resource
from werkzeug.exceptions import Conflict, NotFound, UnprocessableEntity
from controllers.common.fields import Parameters
from controllers.common.wraps import RBACPermission, RBACResourceScope, openapi_rbac_permission_required
from controllers.openapi import openapi_ns
from controllers.openapi._contract import accepts, returns
from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA, build_input_schema, resolve_app_config
@ -19,7 +20,6 @@ from controllers.openapi._models import (
AppListQuery,
AppListResponse,
AppListRow,
TagItem,
)
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData
@ -28,9 +28,9 @@ 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
_ALLOWED_DESCRIBE_FIELDS: frozenset[str] = frozenset({"info", "parameters", "input_schema"})
@ -84,59 +84,58 @@ 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/<string:app_id>/describe")
class AppDescribeApi(AppReadResource):
@auth_router.guard(scope=Scope.APPS_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
@openapi_rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@returns(200, AppDescribeResponse, description="App description")
@accepts(query=AppDescribeQuery)
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")
class AppListApi(Resource):
@auth_router.guard_workspace(scope=Scope.APPS_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
@openapi_rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT, resource_required=False)
@returns(200, AppListResponse, description="App list")
@accepts(query=AppListQuery)
def get(self, *, auth_data: AuthData, query: AppListQuery):
@ -163,28 +162,18 @@ class AppListApi(Resource):
name=app.name,
description=app.description,
mode=app.mode,
tags=[TagItem(name=t.name) for t in app.tags],
updated_at=app.updated_at.isoformat() if app.updated_at else None,
created_by_name=getattr(app, "author_name", None),
workspace_id=str(workspace_id),
workspace_name=tenant_name,
)
env = AppListResponse(page=1, limit=1, total=1, has_more=False, data=[item])
return env
tag_ids: list[str] | None = None
if query.tag:
tags = TagService.get_tag_by_tag_name("app", workspace_id, query.tag, db.session)
if not tags:
return empty
tag_ids = [tag.id for tag in tags]
params = AppListParams(
page=query.page,
limit=query.limit,
mode=query.mode.value if query.mode else "all", # type:ignore
name=query.name,
tag_ids=tag_ids,
status="normal",
# Visibility gate pushed into the query — pagination.total stays
# consistent across pages because invisible rows never count.
@ -205,9 +194,7 @@ class AppListApi(Resource):
name=r.name,
description=r.description,
mode=r.mode,
tags=[TagItem(name=t.name) for t in r.tags],
updated_at=r.updated_at.isoformat() if r.updated_at else None,
created_by_name=getattr(r, "author_name", None),
workspace_id=str(workspace_id),
workspace_name=tenant_name,
)

View File

@ -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
@ -67,9 +71,7 @@ class PermittedExternalAppsListApi(Resource):
name=app.name,
description=app.description,
mode=app.mode,
tags=[], # tenant-scoped; not surfaced cross-tenant
updated_at=app.updated_at.isoformat() if app.updated_at else None,
created_by_name=None, # cross-tenant author leak prevention
workspace_id=str(app.tenant_id),
workspace_name=tenant.name if tenant else None,
)
@ -82,3 +84,20 @@ class PermittedExternalAppsListApi(Resource):
data=items,
)
return env
@openapi_ns.route("/permitted-external-apps/<string:app_id>/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)

View File

@ -12,16 +12,21 @@ import logging
from flask import Response
from flask_restx import Resource
from werkzeug.exceptions import BadRequest, NotFound
from werkzeug.exceptions import BadRequest
from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values
from controllers.common.schema import register_schema_models
from controllers.common.wraps import RBACPermission, RBACResourceScope, openapi_rbac_permission_required
from controllers.openapi import openapi_ns
from controllers.openapi._contract import accepts, returns
from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch
from controllers.openapi._models import FormSubmitResponse, HumanInputFormDefinitionResponse
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData
from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface
from core.workflow.human_input_policy import (
HumanInputSurface,
is_recipient_type_allowed_for_surface,
)
from extensions.ext_database import db
from libs.helper import to_timestamp
from libs.oauth_bearer import Scope
@ -47,24 +52,25 @@ def _jsonify_form_definition(form) -> Response:
def _ensure_form_belongs_to_app(form, app_model: App) -> None:
if form.app_id != app_model.id or form.tenant_id != app_model.tenant_id:
raise NotFound("Form not found")
raise HumanInputFormNotFound()
def _ensure_form_is_allowed_for_openapi(form) -> None:
if not is_recipient_type_allowed_for_surface(form.recipient_type, HumanInputSurface.OPENAPI):
raise NotFound("Form not found")
raise RecipientSurfaceMismatch()
@openapi_ns.route("/apps/<string:app_id>/form/human_input/<string:form_token>")
class OpenApiWorkflowHumanInputFormApi(Resource):
@openapi_ns.response(200, "Form definition", openapi_ns.models[HumanInputFormDefinitionResponse.__name__])
@auth_router.guard(scope=Scope.APPS_RUN)
@openapi_rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
def get(self, app_id: str, form_token: str, *, auth_data: AuthData):
app_model, caller, caller_kind = auth_data.require_app_context()
app_model, _caller, _caller_kind = auth_data.require_app_context()
service = HumanInputService(db.engine)
form = service.get_form_by_token(form_token)
if form is None:
raise NotFound("Form not found")
raise HumanInputFormNotFound()
_ensure_form_belongs_to_app(form, app_model)
_ensure_form_is_allowed_for_openapi(form)
@ -72,6 +78,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
return _jsonify_form_definition(form)
@auth_router.guard(scope=Scope.APPS_RUN)
@openapi_rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
@returns(200, FormSubmitResponse, description="Form submitted")
@accepts(body=HumanInputFormSubmitPayload)
def post(self, app_id: str, form_token: str, *, auth_data: AuthData, body: HumanInputFormSubmitPayload):
@ -80,7 +87,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
service = HumanInputService(db.engine)
form = service.get_form_by_token(form_token)
if form is None:
raise NotFound("Form not found")
raise HumanInputFormNotFound()
_ensure_form_belongs_to_app(form, app_model)
_ensure_form_is_allowed_for_openapi(form)
@ -106,6 +113,6 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
submission_end_user_id=submission_end_user_id,
)
except FormNotFoundError:
raise NotFound("Form not found")
raise HumanInputFormNotFound()
return FormSubmitResponse()

View File

@ -19,6 +19,7 @@ from werkzeug.exceptions import NotFound, UnprocessableEntity
from controllers.common.fields import EventStreamResponse
from controllers.common.schema import query_params_from_model
from controllers.common.wraps import RBACPermission, RBACResourceScope, openapi_rbac_permission_required
from controllers.openapi import openapi_ns
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData
@ -47,6 +48,7 @@ class OpenApiWorkflowEventsApi(Resource):
@openapi_ns.doc(params=query_params_from_model(WorkflowEventsQuery))
@openapi_ns.response(200, "SSE event stream", openapi_ns.models[EventStreamResponse.__name__])
@auth_router.guard(scope=Scope.APPS_RUN)
@openapi_rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
def get(self, app_id: str, task_id: str, *, auth_data: AuthData):
app_model, caller, caller_kind = auth_data.require_app_context()
app_mode = AppMode.value_of(app_model.mode)

View File

@ -51,8 +51,11 @@ from core.tools.entities.tool_entities import ToolProviderType
from core.tools.tool_manager import ToolManager
from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE
from core.trigger.trigger_manager import TriggerManager
from core.workflow.human_input_forms import load_form_tokens_by_form_id
from core.workflow.human_input_forms import (
load_form_dispositions_by_form_id,
)
from core.workflow.human_input_policy import (
FormDisposition,
HumanInputSurface,
enrich_human_input_pause_reasons,
resolve_human_input_pause_reason_inputs,
@ -340,13 +343,14 @@ class WorkflowResponseConverter:
human_input_form_ids = [reason.form_id for reason in resolved_reasons if isinstance(reason, HumanInputRequired)]
expiration_times_by_form_id: dict[str, datetime] = {}
display_in_ui_by_form_id: dict[str, bool] = {}
form_token_by_form_id: dict[str, str] = {}
dispositions_by_form_id: dict[str, FormDisposition] = {}
if human_input_form_ids:
stmt = select(
HumanInputForm.id,
HumanInputForm.expiration_time,
HumanInputForm.form_definition,
).where(HumanInputForm.id.in_(human_input_form_ids))
hitl_surface = _INVOKE_FROM_TO_HITL_SURFACE.get(self._application_generate_entity.invoke_from)
with Session(bind=db.engine) as session:
for form_id, expiration_time, form_definition in session.execute(stmt):
expiration_times_by_form_id[str(form_id)] = expiration_time
@ -355,17 +359,17 @@ class WorkflowResponseConverter:
except (TypeError, json.JSONDecodeError):
definition_payload = {}
display_in_ui_by_form_id[str(form_id)] = bool(definition_payload.get("display_in_ui"))
form_token_by_form_id = load_form_tokens_by_form_id(
dispositions_by_form_id = load_form_dispositions_by_form_id(
human_input_form_ids,
session=session,
surface=_INVOKE_FROM_TO_HITL_SURFACE.get(self._application_generate_entity.invoke_from),
surface=hitl_surface,
)
# Reconnect paths must preserve the same pause-reason contract as live streams;
# otherwise clients see schema drift after resume.
pause_reasons = enrich_human_input_pause_reasons(
pause_reasons,
form_tokens_by_form_id=form_token_by_form_id,
dispositions_by_form_id=dispositions_by_form_id,
expiration_times_by_form_id={
form_id: int(expiration_time.timestamp())
for form_id, expiration_time in expiration_times_by_form_id.items()
@ -379,6 +383,7 @@ class WorkflowResponseConverter:
expiration_time = expiration_times_by_form_id.get(reason.form_id)
if expiration_time is None:
raise ValueError(f"HumanInputForm not found for pause reason, form_id={reason.form_id}")
disposition = dispositions_by_form_id.get(reason.form_id)
responses.append(
HumanInputRequiredResponse(
task_id=task_id,
@ -391,7 +396,8 @@ class WorkflowResponseConverter:
inputs=reason.inputs,
actions=reason.actions,
display_in_ui=display_in_ui_by_form_id.get(reason.form_id, False),
form_token=form_token_by_form_id.get(reason.form_id),
form_token=disposition.form_token if disposition else None,
approval_channels=list(disposition.approval_channels) if disposition else [],
resolved_default_values=reason.resolved_default_values,
expiration_time=int(expiration_time.timestamp()),
),

View File

@ -288,6 +288,7 @@ class HumanInputRequiredResponse(StreamResponse):
actions: Sequence[UserActionConfig] = Field(default_factory=list)
display_in_ui: bool = False
form_token: str | None = None
approval_channels: list[str] = Field(default_factory=list)
resolved_default_values: Mapping[str, Any] = Field(default_factory=dict)
expiration_time: int = Field(..., description="Unix timestamp in seconds")
@ -311,6 +312,7 @@ class HumanInputRequiredPauseReasonPayload(BaseModel):
actions: Sequence[UserActionConfig] = Field(default_factory=list)
display_in_ui: bool = False
form_token: str | None = None
approval_channels: list[str] = Field(default_factory=list)
resolved_default_values: Mapping[str, Any] = Field(default_factory=dict)
expiration_time: int
@ -325,6 +327,7 @@ class HumanInputRequiredPauseReasonPayload(BaseModel):
actions=data.actions,
display_in_ui=data.display_in_ui,
form_token=data.form_token,
approval_channels=data.approval_channels,
resolved_default_values=data.resolved_default_values,
expiration_time=data.expiration_time,
)

View File

@ -12,60 +12,61 @@ from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.workflow.human_input_policy import HumanInputSurface, get_preferred_form_token
from core.workflow.human_input_policy import (
FormDisposition,
HumanInputSurface,
disposition_for_surface,
)
from extensions.ext_database import db
from models.human_input import HumanInputFormRecipient, RecipientType
def load_form_dispositions_by_form_id(
form_ids: Sequence[str],
*,
session: Session | None = None,
surface: HumanInputSurface | None = None,
) -> dict[str, FormDisposition]:
"""Resolve each paused form's resume token and approval channels for `surface`."""
unique_form_ids = list(dict.fromkeys(form_ids))
if not unique_form_ids:
return {}
if session is not None:
return _load_form_dispositions_by_form_id(session, unique_form_ids, surface=surface)
with Session(bind=db.engine, expire_on_commit=False) as new_session:
return _load_form_dispositions_by_form_id(new_session, unique_form_ids, surface=surface)
def _load_form_dispositions_by_form_id(
session: Session,
form_ids: Sequence[str],
*,
surface: HumanInputSurface | None,
) -> dict[str, FormDisposition]:
recipients_by_form_id: dict[str, list[tuple[RecipientType, str]]] = {}
stmt = select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids))
for recipient in session.scalars(stmt):
recipients_by_form_id.setdefault(recipient.form_id, []).append(
(recipient.recipient_type, recipient.access_token or "")
)
return {
form_id: disposition_for_surface(recipients, surface=surface)
for form_id, recipients in recipients_by_form_id.items()
}
def load_form_tokens_by_form_id(
form_ids: Sequence[str],
*,
session: Session | None = None,
surface: HumanInputSurface | None = None,
) -> dict[str, str]:
"""Load the preferred access token for each human input form."""
unique_form_ids = list(dict.fromkeys(form_ids))
if not unique_form_ids:
return {}
if session is not None:
return _load_form_tokens_by_form_id(session, unique_form_ids, surface=surface)
with Session(bind=db.engine, expire_on_commit=False) as new_session:
return _load_form_tokens_by_form_id(new_session, unique_form_ids, surface=surface)
def _load_form_tokens_by_form_id(
session: Session,
form_ids: Sequence[str],
*,
surface: HumanInputSurface | None = None,
) -> dict[str, str]:
recipients_by_form_id: dict[str, list[tuple[RecipientType, str]]] = {}
stmt = select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids))
for recipient in session.scalars(stmt):
if not recipient.access_token:
continue
recipients_by_form_id.setdefault(recipient.form_id, []).append(
(recipient.recipient_type, recipient.access_token)
)
tokens_by_form_id: dict[str, str] = {}
for form_id, recipients in recipients_by_form_id.items():
token = _get_surface_form_token(recipients, surface=surface)
if token is not None:
tokens_by_form_id[form_id] = token
return tokens_by_form_id
def _get_surface_form_token(
recipients: Sequence[tuple[RecipientType, str]],
*,
surface: HumanInputSurface | None,
) -> str | None:
if surface in {HumanInputSurface.SERVICE_API, HumanInputSurface.OPENAPI}:
for recipient_type, token in recipients:
if recipient_type == RecipientType.STANDALONE_WEB_APP and token:
return token
return get_preferred_form_token(recipients)
"""Resume tokens only, for callers that don't surface approval channels."""
dispositions = load_form_dispositions_by_form_id(form_ids, session=session, surface=surface)
return {
form_id: disposition.form_token
for form_id, disposition in dispositions.items()
if disposition.form_token is not None
}

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Mapping, Sequence
from enum import StrEnum
from typing import Any
from typing import Any, NamedTuple
from graphon.entities.pause_reason import HumanInputRequired, PauseReason, PauseReasonType
from graphon.nodes.human_input.entities import FormInputConfig, SelectInputConfig
@ -20,7 +20,7 @@ class HumanInputSurface(StrEnum):
# SERVICE_API and OPENAPI are intentionally narrower than CONSOLE: token callers
# should only be able to act on end-user web forms, not internal console flows.
_ALLOWED_RECIPIENT_TYPES_BY_SURFACE: dict[HumanInputSurface, frozenset[RecipientType]] = {
ALLOWED_RECIPIENT_TYPES_BY_SURFACE: dict[HumanInputSurface, frozenset[RecipientType]] = {
HumanInputSurface.SERVICE_API: frozenset({RecipientType.STANDALONE_WEB_APP}),
HumanInputSurface.CONSOLE: frozenset({RecipientType.CONSOLE, RecipientType.BACKSTAGE}),
HumanInputSurface.OPENAPI: frozenset({RecipientType.STANDALONE_WEB_APP}),
@ -41,7 +41,7 @@ def is_recipient_type_allowed_for_surface(
) -> bool:
if recipient_type is None:
return False
return recipient_type in _ALLOWED_RECIPIENT_TYPES_BY_SURFACE[surface]
return recipient_type in ALLOWED_RECIPIENT_TYPES_BY_SURFACE[surface]
def get_preferred_form_token(
@ -59,10 +59,37 @@ def get_preferred_form_token(
return chosen_token
class FormDisposition(NamedTuple):
"""How a paused form resolves for one API surface.
A form's recipients split into those the surface may act on (yielding a resume
`form_token`) and those it may not (their channels named in `approval_channels`
so the caller is told where approval actually happens instead).
"""
form_token: str | None
approval_channels: list[str]
def disposition_for_surface(
recipients: Sequence[tuple[RecipientType, str]],
*,
surface: HumanInputSurface | None,
) -> FormDisposition:
if surface is None:
return FormDisposition(form_token=get_preferred_form_token(recipients), approval_channels=[])
allowed = ALLOWED_RECIPIENT_TYPES_BY_SURFACE[surface]
actionable = [(recipient_type, token) for recipient_type, token in recipients if recipient_type in allowed]
blocked_channels = {
recipient_type.approval_channel_label for recipient_type, _ in recipients if recipient_type not in allowed
}
return FormDisposition(form_token=get_preferred_form_token(actionable), approval_channels=sorted(blocked_channels))
def enrich_human_input_pause_reasons(
reasons: Sequence[Mapping[str, Any]],
*,
form_tokens_by_form_id: Mapping[str, str],
dispositions_by_form_id: Mapping[str, FormDisposition],
expiration_times_by_form_id: Mapping[str, int],
) -> list[dict[str, Any]]:
enriched: list[dict[str, Any]] = []
@ -71,7 +98,9 @@ def enrich_human_input_pause_reasons(
if updated.get("TYPE") == PauseReasonType.HUMAN_INPUT_REQUIRED:
form_id = updated.get("form_id")
if isinstance(form_id, str):
updated["form_token"] = form_tokens_by_form_id.get(form_id)
disposition = dispositions_by_form_id.get(form_id)
updated["form_token"] = disposition.form_token if disposition else None
updated["approval_channels"] = list(disposition.approval_channels) if disposition else []
expiration_time = expiration_times_by_form_id.get(form_id)
if expiration_time is not None:
updated["expiration_time"] = expiration_time

View File

@ -135,19 +135,32 @@ class HumanInputDelivery(DefaultFieldsMixin, Base):
class RecipientType(StrEnum):
# Second value = approval-channel label (surfaced in `approval_channels`).
# EMAIL_MEMBER member means that the
EMAIL_MEMBER = "email_member"
EMAIL_EXTERNAL = "email_external"
EMAIL_MEMBER = "email_member", "email"
EMAIL_EXTERNAL = "email_external", "email"
# STANDALONE_WEB_APP is used by the standalone web app.
#
# It's not used while running workflows / chatflows containing HumanInput
# node inside console.
STANDALONE_WEB_APP = "standalone_web_app"
STANDALONE_WEB_APP = "standalone_web_app", "web_app"
# CONSOLE is used while running workflows / chatflows containing HumanInput
# node inside console. (E.G. running installed apps or debugging workflows / chatflows)
CONSOLE = "console"
CONSOLE = "console", "console"
# BACKSTAGE is used for backstage input inside console.
BACKSTAGE = "backstage"
BACKSTAGE = "backstage", "console"
_approval_channel_label: str
def __new__(cls, value: str, approval_channel_label: str) -> "RecipientType":
member = str.__new__(cls, value)
member._value_ = value
member._approval_channel_label = approval_channel_label
return member
@property
def approval_channel_label(self) -> str:
return self._approval_channel_label
@final

View File

@ -83,7 +83,6 @@ User-scoped operations
| mode | query | | No | string, <br>**Available values:** "advanced-chat", "agent", "agent-chat", "channel", "chat", "completion", "rag-pipeline", "workflow" |
| name | query | | No | string |
| page | query | | No | integer, <br>**Default:** 1 |
| tag | query | | No | string |
| workspace_id | query | | Yes | string |
#### Responses
@ -331,6 +330,22 @@ Upload a file to use as an input variable when running the app
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [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)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /workspaces
#### Responses
@ -507,14 +522,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) ], <br>**Default:** | | No |
| updated_at | string | | No |
#### AppDescribeQuery
@ -568,16 +581,14 @@ Request body for POST /workspaces/<workspace_id>/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) ], <br>**Default:** | | No |
#### AppListQuery
@ -589,7 +600,6 @@ mode is a closed enum.
| mode | [AppMode](#appmode) | | No |
| name | string | | No |
| page | integer, <br>**Default:** 1 | | No |
| tag | string | | No |
| workspace_id | string | | Yes |
#### AppListResponse
@ -606,12 +616,10 @@ mode is a closed enum.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_by_name | string | | No |
| description | string | | No |
| id | string | | Yes |
| mode | [AppMode](#appmode) | | Yes |
| name | string | | Yes |
| tags | [ [TagItem](#tagitem) ], <br>**Default:** | | No |
| updated_at | string | | No |
| workspace_id | string | | No |
| workspace_name | string | | No |
@ -982,12 +990,6 @@ Pagination for GET /account/sessions. Strict (extra='forbid').
| last_used_at | string | | No |
| prefix | string | | Yes |
#### TagItem
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| name | string | | Yes |
#### TaskStopResponse
200 body for POST /apps/<id>/tasks/<task_id>/stop. The handler always returns

View File

@ -475,7 +475,7 @@ class TriggerProviderService:
tenant_id=tenant_id, provider_id=provider_id
)
# Create encrypter
encrypter, cache = create_provider_encrypter(
encrypter, _ = create_provider_encrypter(
tenant_id=tenant_id,
config=[x.to_basic_provider_config() for x in provider_controller.get_oauth_client_schema()],
cache=NoOpProviderCredentialCache(),
@ -506,14 +506,20 @@ class TriggerProviderService:
subscription.credentials = dict(encrypter.encrypt(dict(refreshed_credentials.credentials)))
subscription.credential_expires_at = refreshed_credentials.expires_at
# Clear cache
cache.delete()
return {
provider_id_value = subscription.provider_id
result = {
"result": "success",
"expires_at": refreshed_credentials.expires_at,
}
# Clear the trigger runtime credential cache after the DB commit so dispatch uses the refreshed token.
delete_cache_for_subscription(
tenant_id=tenant_id,
provider_id=provider_id_value,
subscription_id=subscription_id,
)
return result
@classmethod
def refresh_subscription(
cls,

View File

@ -23,8 +23,11 @@ from core.app.entities.task_entities import (
WorkflowStartStreamResponse,
)
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext
from core.workflow.human_input_forms import load_form_tokens_by_form_id
from core.workflow.human_input_forms import (
load_form_dispositions_by_form_id,
)
from core.workflow.human_input_policy import (
FormDisposition,
HumanInputSurface,
enrich_human_input_pause_reasons,
resolve_human_input_pause_reason_inputs,
@ -359,7 +362,7 @@ def _build_human_input_required_events(
expiration_times_by_form_id: dict[str, int] = {}
display_in_ui_by_form_id: dict[str, bool] = {}
form_tokens_by_form_id: dict[str, str] = {}
dispositions_by_form_id: dict[str, FormDisposition] = {}
if human_input_form_ids and session_maker is not None:
stmt = select(HumanInputForm.id, HumanInputForm.expiration_time, HumanInputForm.form_definition).where(
HumanInputForm.id.in_(human_input_form_ids)
@ -372,7 +375,7 @@ def _build_human_input_required_events(
except (TypeError, json.JSONDecodeError):
definition_payload = {}
display_in_ui_by_form_id[str(form_id)] = bool(definition_payload.get("display_in_ui"))
form_tokens_by_form_id = load_form_tokens_by_form_id(
dispositions_by_form_id = load_form_dispositions_by_form_id(
human_input_form_ids,
session=session,
surface=human_input_surface,
@ -393,6 +396,7 @@ def _build_human_input_required_events(
reason.inputs,
variable_pool=variable_pool,
)
disposition = dispositions_by_form_id.get(form_id)
response = HumanInputRequiredResponse(
task_id=task_id,
@ -405,7 +409,8 @@ def _build_human_input_required_events(
inputs=resolved_inputs,
actions=reason.actions,
display_in_ui=display_in_ui_by_form_id.get(form_id, False),
form_token=form_tokens_by_form_id.get(form_id),
form_token=disposition.form_token if disposition else None,
approval_channels=list(disposition.approval_channels) if disposition else [],
resolved_default_values=reason.resolved_default_values,
expiration_time=expiration_time,
),
@ -493,11 +498,11 @@ def _build_pause_event(
for form_id in [reason.get("form_id")]
if isinstance(form_id, str)
]
form_tokens_by_form_id: dict[str, str] = {}
dispositions_by_form_id: dict[str, FormDisposition] = {}
expiration_times_by_form_id: dict[str, int] = {}
if human_input_form_ids and session_maker is not None:
with session_maker() as session:
form_tokens_by_form_id = load_form_tokens_by_form_id(
dispositions_by_form_id = load_form_dispositions_by_form_id(
human_input_form_ids,
session=session,
surface=human_input_surface,
@ -512,7 +517,7 @@ def _build_pause_event(
# otherwise clients see schema drift after resume.
reasons = enrich_human_input_pause_reasons(
reasons,
form_tokens_by_form_id=form_tokens_by_form_id,
dispositions_by_form_id=dispositions_by_form_id,
expiration_times_by_form_id=expiration_times_by_form_id,
)

View File

@ -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)

View File

@ -5,7 +5,7 @@ Runs against the model directly, not the HTTP layer. Pins:
- workspace_id is required.
- numeric bounds enforced (page >= 1, limit in [1, MAX_PAGE_LIMIT]).
- mode validates against the AppMode enum.
- name and tag have length caps.
- name has a length cap.
"""
from __future__ import annotations
@ -24,7 +24,6 @@ def test_defaults():
assert q.limit == 20
assert q.mode is None
assert q.name is None
assert q.tag is None
def test_workspace_id_required():
@ -80,12 +79,6 @@ def test_name_length_capped():
AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "name": "x" * 201})
def test_tag_length_capped():
AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "tag": "x" * 100})
with pytest.raises(ValidationError):
AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "tag": "x" * 101})
def test_all_fields_accept_valid_values():
"""Pin the happy-path acceptance for every field in one place."""
q = AppListQuery.model_validate(
@ -95,7 +88,6 @@ def test_all_fields_accept_valid_values():
"limit": 50,
"mode": "workflow",
"name": "search",
"tag": "prod",
}
)
assert q.workspace_id == "00000000-0000-0000-0000-000000000001"
@ -104,4 +96,3 @@ def test_all_fields_accept_valid_values():
assert q.mode is not None
assert q.mode.value == "workflow"
assert q.name == "search"
assert q.tag == "prod"

View File

@ -26,11 +26,13 @@ from controllers.openapi._errors import (
ErrorBody,
ErrorDetail,
FilenameNotExists,
HumanInputFormNotFound,
MemberLicenseExceeded,
MemberLimitExceeded,
OpenApiError,
OpenApiErrorCode,
OpenApiErrorFormatter,
RecipientSurfaceMismatch,
)
from controllers.service_api.app.error import (
AppUnavailableError,
@ -319,6 +321,8 @@ ERROR_MATRIX = [
(BlockedFileExtensionError(), 400, "file_extension_blocked"),
(MemberLimitExceeded(), 403, "member_limit_exceeded"),
(MemberLicenseExceeded(), 403, "member_license_exceeded"),
(HumanInputFormNotFound(), 404, "form_not_found"),
(RecipientSurfaceMismatch(), 403, "recipient_surface_mismatch"),
]

View File

@ -11,8 +11,9 @@ from unittest.mock import Mock
import pytest
from flask import Flask
from werkzeug.exceptions import NotFound, UnprocessableEntity
from werkzeug.exceptions import UnprocessableEntity
from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch
from controllers.openapi.auth.data import AuthData
from libs.oauth_bearer import Scope, TokenType
from models.human_input import RecipientType
@ -89,7 +90,7 @@ class TestOpenApiHumanInputFormGet:
caller = SimpleNamespace(id="acct-1")
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/bad"):
with pytest.raises(NotFound):
with pytest.raises(HumanInputFormNotFound):
api.get.__wrapped__(
api,
app_id="app-1",
@ -101,7 +102,10 @@ class TestOpenApiHumanInputFormGet:
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
form = SimpleNamespace(
app_id="other-app", tenant_id="tenant-1", expiration_time=datetime(2099, 1, 1, tzinfo=UTC)
app_id="other-app",
tenant_id="tenant-1",
recipient_type=RecipientType.STANDALONE_WEB_APP,
expiration_time=datetime(2099, 1, 1, tzinfo=UTC),
)
service_mock = Mock()
service_mock.get_form_by_token.return_value = form
@ -114,7 +118,7 @@ class TestOpenApiHumanInputFormGet:
caller = SimpleNamespace(id="acct-1")
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"):
with pytest.raises(NotFound):
with pytest.raises(HumanInputFormNotFound):
api.get.__wrapped__(
api,
app_id="app-1",
@ -142,7 +146,7 @@ class TestOpenApiHumanInputFormGet:
caller = SimpleNamespace(id="acct-1")
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"):
with pytest.raises(NotFound):
with pytest.raises(RecipientSurfaceMismatch):
api.get.__wrapped__(
api,
app_id="app-1",
@ -234,6 +238,38 @@ class TestOpenApiHumanInputFormPost:
)
assert result == ({}, 200)
def test_post_standalone_web_app_recipient_submits(
self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch
):
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
form = self._make_form(recipient_type=RecipientType.STANDALONE_WEB_APP)
service_mock = Mock()
service_mock.get_form_by_token.return_value = form
module = sys.modules["controllers.openapi.human_input_form"]
monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock)
monkeypatch.setattr(module, "db", SimpleNamespace(engine=object()))
api = OpenApiWorkflowHumanInputFormApi()
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
caller = SimpleNamespace(id="anyone")
with app.test_request_context(
"/openapi/v1/apps/app-1/form/human_input/tok-1",
method="POST",
json={"action": "approve", "inputs": {}},
):
result = api.post.__wrapped__(
api,
app_id="app-1",
form_token="tok-1",
auth_data=_make_auth_data(app_model, caller, "end_user"),
)
service_mock.submit_form_by_token.assert_called_once()
assert result == ({}, 200)
def test_post_rejects_invalid_body_with_422(self, app: Flask, bypass_pipeline):
"""Malformed body → 422 via @accepts (was an unmapped pydantic error → 500)."""
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi

View File

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

View File

@ -175,6 +175,7 @@ class TestAdvancedChatGenerateTaskPipeline:
"actions": [{"id": "approve", "title": "Approve", "button_style": "default"}],
"display_in_ui": True,
"form_token": "token-1",
"approval_channels": [],
"resolved_default_values": {},
"expiration_time": 123,
}

View File

@ -26,6 +26,26 @@ from models.account import Account
from models.human_input import RecipientType
class _FakeSession:
"""Stub session: `execute` feeds the form-expiration query, `scalars` the recipients."""
def __init__(self, *, execute_rows=(), scalars_rows=()):
self._execute_rows = execute_rows
self._scalars_rows = scalars_rows
def execute(self, _stmt):
return list(self._execute_rows)
def scalars(self, _stmt):
return list(self._scalars_rows)
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
class _RecordingWorkflowAppRunner(WorkflowAppRunner):
def __init__(self, **kwargs):
super().__init__(**kwargs)
@ -97,11 +117,11 @@ def test_graph_run_paused_event_emits_queue_pause_event():
assert queue_event.paused_nodes == ["node-pause-1"]
def _build_converter():
def _build_converter(*, invoke_from: InvokeFrom = InvokeFrom.SERVICE_API):
application_generate_entity = SimpleNamespace(
inputs={},
files=[],
invoke_from=InvokeFrom.SERVICE_API,
invoke_from=invoke_from,
app_config=SimpleNamespace(app_id="app-id", tenant_id="tenant-id"),
)
system_variables = build_system_variables(
@ -131,32 +151,15 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon
)
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
session = _FakeSession(
execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')],
scalars_rows=[
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.CONSOLE, access_token="console-token"),
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
],
)
class _FakeSession:
def execute(self, _stmt):
return [("form-1", expiration_time, '{"display_in_ui": true}')]
def scalars(self, _stmt):
return [
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.CONSOLE,
access_token="console-token",
),
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.BACKSTAGE,
access_token="backstage-token",
),
]
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: _FakeSession())
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: session)
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
reason = HumanInputRequired(
@ -195,10 +198,92 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon
assert hi_resp.data.inputs[0].output_variable_name == "field"
assert hi_resp.data.actions[0].id == "approve"
assert hi_resp.data.display_in_ui is True
assert hi_resp.data.form_token == "backstage-token"
assert hi_resp.data.form_token is None
assert hi_resp.data.approval_channels == ["console"]
assert hi_resp.data.expiration_time == int(expiration_time.timestamp())
def _build_paused_human_input_response(monkeypatch, recipients):
"""Drive the live OPENAPI pause path with the given recipients via a fake session."""
converter = _build_converter(invoke_from=InvokeFrom.OPENAPI)
converter.workflow_start_to_stream_response(
task_id="task",
workflow_run_id="run-id",
workflow_id="workflow-id",
reason=WorkflowStartReason.INITIAL,
)
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
session = _FakeSession(
execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')],
scalars_rows=list(recipients),
)
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: session)
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
reason = HumanInputRequired(
form_id="form-1",
form_content="Rendered",
inputs=[ParagraphInputConfig(output_variable_name="field")],
actions=[UserActionConfig(id="approve", title="Approve")],
node_id="node-id",
node_title="Human Step",
)
queue_event = QueueWorkflowPausedEvent(
reasons=[reason],
outputs={},
paused_nodes=["node-id"],
)
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
responses = converter.workflow_pause_to_stream_response(
event=queue_event,
task_id="task",
graph_runtime_state=runtime_state,
)
assert isinstance(responses[0], HumanInputRequiredResponse)
return responses
def test_openapi_pause_without_web_app_recipient_emits_approval_channels(monkeypatch: pytest.MonkeyPatch):
responses = _build_paused_human_input_response(
monkeypatch,
recipients=[
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.EMAIL_MEMBER, access_token="email-token"),
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
],
)
hi_resp = responses[0]
assert hi_resp.data.form_token is None
assert hi_resp.data.approval_channels == ["console", "email"]
pause_resp = responses[-1]
assert pause_resp.data.reasons[0]["approval_channels"] == ["console", "email"]
def test_openapi_pause_with_web_app_recipient_sets_token_and_channels(monkeypatch: pytest.MonkeyPatch):
responses = _build_paused_human_input_response(
monkeypatch,
recipients=[
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.STANDALONE_WEB_APP,
access_token="web-app-token",
),
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
],
)
hi_resp = responses[0]
assert hi_resp.data.form_token == "web-app-token"
assert hi_resp.data.approval_channels == ["console"]
pause_resp = responses[-1]
assert pause_resp.data.reasons[0]["approval_channels"] == ["console"]
def test_queue_workflow_paused_event_resolves_variable_select_options(monkeypatch: pytest.MonkeyPatch):
converter = _build_converter()
converter.workflow_start_to_stream_response(
@ -209,21 +294,9 @@ def test_queue_workflow_paused_event_resolves_variable_select_options(monkeypatc
)
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
session = _FakeSession(execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')])
class _FakeSession:
def execute(self, _stmt):
return [("form-1", expiration_time, '{"display_in_ui": true}')]
def scalars(self, _stmt):
return []
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: _FakeSession())
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: session)
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
reason = HumanInputRequired(

View File

@ -134,6 +134,7 @@ class TestWorkflowGenerateTaskPipeline:
"actions": [],
"display_in_ui": False,
"form_token": None,
"approval_channels": [],
"resolved_default_values": {},
"expiration_time": 1,
}

View File

@ -32,6 +32,7 @@ from models.human_input import (
EmailMemberRecipientPayload,
HumanInputFormRecipient,
RecipientType,
StandaloneWebAppRecipientPayload,
)
@ -307,6 +308,9 @@ class _DummyRecipient:
recipient_type: RecipientType
access_token: str
form: _DummyForm | None = None
recipient_payload: str = dataclasses.field(
default_factory=lambda: StandaloneWebAppRecipientPayload().model_dump_json()
)
class _FakeScalarResult:

View File

@ -0,0 +1,63 @@
import pytest
from core.workflow.human_input_policy import FormDisposition, enrich_human_input_pause_reasons
from graphon.entities.pause_reason import PauseReasonType
_HUMAN_INPUT_REASON = {"TYPE": PauseReasonType.HUMAN_INPUT_REQUIRED, "form_id": "f1"}
@pytest.mark.parametrize(
("dispositions", "expected_token", "expected_channels"),
[
({"f1": FormDisposition(form_token=None, approval_channels=["console", "email"])}, None, ["console", "email"]),
({"f1": FormDisposition(form_token="tok", approval_channels=[])}, "tok", []),
# form_id absent from the map (no recipient rows) falls back to no token, no channels.
({}, None, []),
],
)
def test_enrich_projects_disposition_onto_reason(dispositions, expected_token, expected_channels):
out = enrich_human_input_pause_reasons(
[dict(_HUMAN_INPUT_REASON)],
dispositions_by_form_id=dispositions,
expiration_times_by_form_id={},
)
assert out[0]["form_token"] == expected_token
assert out[0]["approval_channels"] == expected_channels
def test_enrich_leaves_non_human_input_reasons_untouched():
reason = {"TYPE": "something_else", "form_id": "f1"}
out = enrich_human_input_pause_reasons(
[reason],
dispositions_by_form_id={"f1": FormDisposition(form_token="tok", approval_channels=["email"])},
expiration_times_by_form_id={},
)
assert out[0] == reason
assert "form_token" not in out[0]
assert "approval_channels" not in out[0]
def test_pause_reason_payload_carries_approval_channels_through_factory():
# from_response_data maps fields by hand; this guards approval_channels/form_token
# (the fields this feature added) against being dropped in that mapping.
from core.app.entities.task_entities import (
HumanInputRequiredPauseReasonPayload,
HumanInputRequiredResponse,
)
data = HumanInputRequiredResponse.Data(
form_id="f",
node_id="n",
node_title="t",
form_content="c",
expiration_time=123,
form_token=None,
approval_channels=["console"],
)
payload = HumanInputRequiredPauseReasonPayload.from_response_data(data)
assert payload.approval_channels == ["console"]
assert payload.form_token is None

View File

@ -1,7 +1,16 @@
from types import SimpleNamespace
from core.workflow.human_input_forms import _load_form_tokens_by_form_id, load_form_tokens_by_form_id
from core.workflow.human_input_policy import HumanInputSurface
import pytest
from core.workflow.human_input_forms import (
load_form_dispositions_by_form_id,
load_form_tokens_by_form_id,
)
from core.workflow.human_input_policy import (
FormDisposition,
HumanInputSurface,
disposition_for_surface,
)
from models.human_input import RecipientType
@ -13,91 +22,100 @@ class _FakeSession:
return self._recipients
def test_load_form_tokens_by_form_id_prefers_backstage_token() -> None:
def _recipient(form_id: str, recipient_type: RecipientType, access_token: str | None) -> SimpleNamespace:
return SimpleNamespace(form_id=form_id, recipient_type=recipient_type, access_token=access_token)
@pytest.mark.parametrize(
("surface", "expected_token"),
[
# Unfiltered (no surface) picks the highest-priority recipient: backstage.
(None, "backstage-token"),
# SERVICE_API may only act on the web-app recipient.
(HumanInputSurface.SERVICE_API, "web-token"),
],
)
def test_load_form_tokens_picks_token_for_surface(surface, expected_token) -> None:
session = _FakeSession(
recipients=[
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.STANDALONE_WEB_APP,
access_token="web-token",
),
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.CONSOLE,
access_token="console-token",
),
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.BACKSTAGE,
access_token="backstage-token",
),
[
_recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"),
_recipient("form-1", RecipientType.CONSOLE, "console-token"),
_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"),
]
)
assert load_form_tokens_by_form_id(["form-1"], session=session) == {"form-1": "backstage-token"}
assert load_form_tokens_by_form_id(["form-1"], session=session, surface=surface) == {"form-1": expected_token}
def test_load_form_tokens_by_form_id_ignores_unsupported_recipients() -> None:
def test_load_form_tokens_drops_forms_without_actionable_token() -> None:
session = _FakeSession(
recipients=[
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.EMAIL_MEMBER,
access_token="email-token",
),
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.CONSOLE,
access_token=None,
),
[
_recipient("form-1", RecipientType.EMAIL_MEMBER, "email-token"),
_recipient("form-1", RecipientType.CONSOLE, None),
]
)
assert load_form_tokens_by_form_id(["form-1"], session=session) == {}
def test_load_form_tokens_by_form_id_uses_shared_priority() -> None:
def test_load_form_tokens_service_api_surface_uses_web_token() -> None:
session = _FakeSession(
recipients=[
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.STANDALONE_WEB_APP,
access_token="web-token",
),
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.CONSOLE,
access_token="console-token",
),
[
_recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"),
_recipient("form-1", RecipientType.CONSOLE, "console-token"),
_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"),
]
)
assert _load_form_tokens_by_form_id(session, ["form-1"]) == {"form-1": "console-token"}
assert load_form_tokens_by_form_id(["form-1"], session=session, surface=HumanInputSurface.SERVICE_API) == {
"form-1": "web-token"
}
def test_load_form_tokens_by_form_id_uses_web_token_for_service_api_surface() -> None:
def test_load_dispositions_openapi_webapp_form_is_resumable() -> None:
session = _FakeSession(
recipients=[
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.STANDALONE_WEB_APP,
access_token="web-token",
),
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.CONSOLE,
access_token="console-token",
),
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.BACKSTAGE,
access_token="backstage-token",
),
[
_recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"),
_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"),
]
)
assert load_form_tokens_by_form_id(
["form-1"],
session=session,
surface=HumanInputSurface.SERVICE_API,
) == {"form-1": "web-token"}
assert load_form_dispositions_by_form_id(["form-1"], session=session, surface=HumanInputSurface.OPENAPI) == {
"form-1": FormDisposition(form_token="web-token", approval_channels=["console"])
}
def test_load_dispositions_openapi_backstage_only_form_yields_channels_not_token() -> None:
session = _FakeSession([_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token")])
assert load_form_dispositions_by_form_id(["form-1"], session=session, surface=HumanInputSurface.OPENAPI) == {
"form-1": FormDisposition(form_token=None, approval_channels=["console"])
}
# disposition_for_surface partitions recipients into a surface-actionable resume
# token plus the approval channels of the recipients the surface may NOT act on.
_WEB = (RecipientType.STANDALONE_WEB_APP, "tok_web")
_BACKSTAGE = (RecipientType.BACKSTAGE, "tok_b")
_CONSOLE = (RecipientType.CONSOLE, "tok_c")
_EMAIL_MEMBER = (RecipientType.EMAIL_MEMBER, "t1")
_EMAIL_EXTERNAL = (RecipientType.EMAIL_EXTERNAL, "t2")
@pytest.mark.parametrize(
("recipients", "surface", "expected"),
[
# Token surface acts on the web-app recipient; blocked recipients become channels.
([_BACKSTAGE, _WEB], HumanInputSurface.OPENAPI, FormDisposition("tok_web", ["console"])),
([_EMAIL_MEMBER, _EMAIL_EXTERNAL], HumanInputSurface.OPENAPI, FormDisposition(None, ["email"])),
([_EMAIL_MEMBER, _BACKSTAGE], HumanInputSurface.OPENAPI, FormDisposition(None, ["console", "email"])),
# CONSOLE acts on console/backstage; a web-app recipient is blocked → web_app channel.
([_CONSOLE, _WEB], HumanInputSurface.CONSOLE, FormDisposition("tok_c", ["web_app"])),
([_WEB], HumanInputSurface.CONSOLE, FormDisposition(None, ["web_app"])),
# No surface: unfiltered priority token, channels never populated.
([_BACKSTAGE], None, FormDisposition("tok_b", [])),
([_WEB, _EMAIL_MEMBER], None, FormDisposition("tok_web", [])),
],
)
def test_disposition_for_surface_partitions_token_and_channels(recipients, surface, expected) -> None:
assert disposition_for_surface(recipients, surface=surface) == expected

View File

@ -12,38 +12,28 @@ from graphon.runtime import VariablePool
from models.human_input import RecipientType
def test_service_api_only_allows_public_webapp_forms() -> None:
assert is_recipient_type_allowed_for_surface(
RecipientType.STANDALONE_WEB_APP,
HumanInputSurface.SERVICE_API,
)
assert not is_recipient_type_allowed_for_surface(
RecipientType.CONSOLE,
HumanInputSurface.SERVICE_API,
)
assert not is_recipient_type_allowed_for_surface(
RecipientType.BACKSTAGE,
HumanInputSurface.SERVICE_API,
)
assert not is_recipient_type_allowed_for_surface(
RecipientType.EMAIL_MEMBER,
HumanInputSurface.SERVICE_API,
)
def test_console_only_allows_internal_console_surfaces() -> None:
assert is_recipient_type_allowed_for_surface(
RecipientType.CONSOLE,
HumanInputSurface.CONSOLE,
)
assert is_recipient_type_allowed_for_surface(
RecipientType.BACKSTAGE,
HumanInputSurface.CONSOLE,
)
assert not is_recipient_type_allowed_for_surface(
RecipientType.STANDALONE_WEB_APP,
HumanInputSurface.CONSOLE,
)
# Token surfaces (SERVICE_API, OPENAPI) may act only on public web-app forms;
# CONSOLE may act on internal console/backstage forms. OPENAPI mirrors SERVICE_API
# today but is pinned independently because the two are expected to diverge.
@pytest.mark.parametrize(
("recipient_type", "surface", "allowed"),
[
(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.SERVICE_API, True),
(RecipientType.CONSOLE, HumanInputSurface.SERVICE_API, False),
(RecipientType.BACKSTAGE, HumanInputSurface.SERVICE_API, False),
(RecipientType.EMAIL_MEMBER, HumanInputSurface.SERVICE_API, False),
(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.OPENAPI, True),
(RecipientType.CONSOLE, HumanInputSurface.OPENAPI, False),
(RecipientType.BACKSTAGE, HumanInputSurface.OPENAPI, False),
(RecipientType.CONSOLE, HumanInputSurface.CONSOLE, True),
(RecipientType.BACKSTAGE, HumanInputSurface.CONSOLE, True),
(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.CONSOLE, False),
],
)
def test_recipient_type_allowed_per_surface(
recipient_type: RecipientType, surface: HumanInputSurface, allowed: bool
) -> None:
assert is_recipient_type_allowed_for_surface(recipient_type, surface) is allowed
def test_preferred_form_token_uses_shared_priority_order() -> None:
@ -56,6 +46,17 @@ def test_preferred_form_token_uses_shared_priority_order() -> None:
assert get_preferred_form_token(recipients) == "backstage-token"
def test_preferred_form_token_skips_prioritized_type_with_empty_token() -> None:
# An empty token is not actionable: the highest-priority recipient that
# actually carries a token wins, not the highest-priority type.
recipients = [
(RecipientType.BACKSTAGE, ""),
(RecipientType.CONSOLE, "console-token"),
]
assert get_preferred_form_token(recipients) == "console-token"
def test_resolve_variable_select_input_options_uses_runtime_values() -> None:
variable_pool = VariablePool()
variable_pool.add(("start", "options"), ["approve", "reject"])

View File

@ -1,34 +0,0 @@
"""Tests for OPENAPI surface in HumanInputPolicy and human_input_forms."""
from __future__ import annotations
from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface
from models.human_input import RecipientType
def test_openapi_surface_exists():
assert HumanInputSurface.OPENAPI == "openapi"
def test_openapi_allows_standalone_web_app():
assert is_recipient_type_allowed_for_surface(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.OPENAPI)
def test_openapi_rejects_console_recipient():
assert not is_recipient_type_allowed_for_surface(RecipientType.CONSOLE, HumanInputSurface.OPENAPI)
def test_openapi_rejects_backstage_recipient():
assert not is_recipient_type_allowed_for_surface(RecipientType.BACKSTAGE, HumanInputSurface.OPENAPI)
def test_get_surface_form_token_openapi_picks_standalone_web_app():
"""OPENAPI surface should pick STANDALONE_WEB_APP token, same as SERVICE_API."""
from core.workflow.human_input_forms import _get_surface_form_token
recipients = [
(RecipientType.BACKSTAGE, "backstage-token"),
(RecipientType.STANDALONE_WEB_APP, "web-token"),
]
token = _get_surface_form_token(recipients, surface=HumanInputSurface.OPENAPI)
assert token == "web-token"

View File

@ -0,0 +1,19 @@
import pytest
from models.human_input import RecipientType
@pytest.mark.parametrize(
("recipient_type", "expected_label"),
[
(RecipientType.EMAIL_MEMBER, "email"),
(RecipientType.EMAIL_EXTERNAL, "email"),
(RecipientType.CONSOLE, "console"),
(RecipientType.BACKSTAGE, "console"),
(RecipientType.STANDALONE_WEB_APP, "web_app"),
],
)
def test_approval_channel_label_collapses_delivery_types(recipient_type: RecipientType, expected_label: str) -> None:
# Both email types collapse to "email" and console/backstage to "console":
# the user-facing approval channel, not the internal recipient type.
assert recipient_type.approval_channel_label == expected_label

View File

@ -560,6 +560,7 @@ def test_refresh_oauth_token_should_refresh_and_persist_new_credentials(
return_value=(cred_enc, cache),
)
mocker.patch.object(TriggerProviderService, "get_oauth_client", return_value={"client_id": "id"})
mock_delete_cache = mocker.patch("services.trigger.trigger_provider_service.delete_cache_for_subscription")
refreshed = SimpleNamespace(credentials={"access_token": "new"}, expires_at=12345)
oauth_handler = MagicMock()
oauth_handler.refresh_credentials.return_value = refreshed
@ -573,7 +574,12 @@ def test_refresh_oauth_token_should_refresh_and_persist_new_credentials(
assert subscription.credentials == {"access_token": "new"}
assert subscription.credential_expires_at == 12345
cache.delete.assert_called_once()
cache.delete.assert_not_called()
mock_delete_cache.assert_called_once_with(
tenant_id="tenant-1",
provider_id=str(provider_id),
subscription_id="sub-1",
)
def test_refresh_subscription_should_raise_error_when_subscription_missing(

View File

@ -16,12 +16,14 @@ from core.app.app_config.entities import WorkflowUIBasedAppConfig
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
from core.app.entities.task_entities import StreamEvent
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
from core.workflow.human_input_policy import FormDisposition, HumanInputSurface
from graphon.entities.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource
from graphon.nodes.human_input.enums import ValueSourceType
from graphon.runtime import GraphRuntimeState, VariablePool
from models.enums import CreatorUserRole
from models.human_input import RecipientType
from models.model import AppMode
from models.workflow import WorkflowRun
from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot
@ -763,7 +765,11 @@ def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.M
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
resumption_context = _build_resumption_context("task-ctx")
monkeypatch.setattr(
service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"}
service_module,
"load_form_dispositions_by_form_id",
lambda form_ids, session=None, surface=None: {
"form-1": FormDisposition(form_token="wtok", approval_channels=[])
},
)
session_maker = _SessionMaker(
SimpleNamespace(
@ -803,12 +809,99 @@ def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.M
assert pause_data["reasons"][0]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp())
def _build_recipient_snapshot_events(recipients: Sequence[Any]) -> list[Mapping[str, Any]]:
"""Drive the reconnect snapshot pause path for the OPENAPI surface.
Lets the real disposition loader run against a fake session whose ``scalars``
yields the given recipients, so the reconnect path derives the same token and
approval channels as the live path for the same recipient set.
"""
workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED)
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
resumption_context = _build_resumption_context("task-ctx")
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
session_maker = _SessionMaker(
SimpleNamespace(
execute=lambda _stmt: [("form-1", expiration_time, '{"display_in_ui": true}')],
scalars=lambda _stmt: list(recipients),
)
)
pause_entity = _FakePauseEntity(
pause_id="pause-1",
workflow_run_id="run-1",
paused_at_value=expiration_time,
pause_reasons=[
HumanInputRequired(
form_id="form-1",
form_content="content",
node_id="node-1",
node_title="Human Input",
)
],
)
return _build_snapshot_events(
workflow_run=workflow_run,
node_snapshots=[snapshot],
task_id="task-ctx",
message_context=None,
pause_entity=pause_entity,
resumption_context=resumption_context,
session_maker=cast(sessionmaker[Session], session_maker),
human_input_surface=HumanInputSurface.OPENAPI,
)
def test_reconnect_pause_without_web_app_recipient_emits_approval_channels() -> None:
events = _build_recipient_snapshot_events(
recipients=[
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.EMAIL_MEMBER, access_token="email-token"),
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
],
)
human_input_event = events[-2]
assert human_input_event["event"] == StreamEvent.HUMAN_INPUT_REQUIRED
assert human_input_event["data"]["form_token"] is None
assert human_input_event["data"]["approval_channels"] == ["console", "email"]
pause_data = events[-1]["data"]
assert pause_data["reasons"][0]["form_token"] is None
assert pause_data["reasons"][0]["approval_channels"] == ["console", "email"]
def test_reconnect_pause_with_web_app_recipient_sets_token_and_channels() -> None:
events = _build_recipient_snapshot_events(
recipients=[
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.STANDALONE_WEB_APP,
access_token="web-app-token",
),
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
],
)
human_input_event = events[-2]
assert human_input_event["event"] == StreamEvent.HUMAN_INPUT_REQUIRED
assert human_input_event["data"]["form_token"] == "web-app-token"
assert human_input_event["data"]["approval_channels"] == ["console"]
pause_data = events[-1]["data"]
assert pause_data["reasons"][0]["form_token"] == "web-app-token"
assert pause_data["reasons"][0]["approval_channels"] == ["console"]
def test_build_snapshot_events_resolves_pause_reason_select_options(monkeypatch: pytest.MonkeyPatch) -> None:
workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED)
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
resumption_context = _build_resumption_context("task-ctx", select_options=["approve", "reject"])
monkeypatch.setattr(
service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"}
service_module,
"load_form_dispositions_by_form_id",
lambda form_ids, session=None, surface=None: {
"form-1": FormDisposition(form_token="wtok", approval_channels=[])
},
)
session_maker = _SessionMaker(
SimpleNamespace(
@ -886,7 +979,11 @@ def test_build_workflow_event_stream_loads_pause_tokens_without_flask_app_contex
service_module, "_load_resumption_context", MagicMock(return_value=_build_resumption_context("task-1"))
)
monkeypatch.setattr(
service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"}
service_module,
"load_form_dispositions_by_form_id",
lambda form_ids, session=None, surface=None: {
"form-1": FormDisposition(form_token="wtok", approval_channels=[])
},
)
session = SimpleNamespace(

View File

@ -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 {

View File

@ -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')

View File

@ -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

View File

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

35
cli/src/api/app-reader.ts Normal file
View File

@ -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<AppListResponse>
describe: (appId: string, fields?: readonly string[]) => Promise<AppDescribeResponse>
}
// 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<Record<SubjectKindValue, AppReaderFactory>> = {
[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)
}

View File

@ -36,7 +36,6 @@ describe('AppsClient.list', () => {
// Optional filters are omitted entirely when not supplied.
expect(q.has('mode')).toBe(false)
expect(q.has('name')).toBe(false)
expect(q.has('tag')).toBe(false)
})
it('forwards explicit pagination and filters', async () => {
@ -48,7 +47,6 @@ describe('AppsClient.list', () => {
limit: 50,
mode: 'chat',
name: 'support bot',
tag: 'prod',
})
const q = queryOf(stub.captured.url)
@ -56,18 +54,16 @@ describe('AppsClient.list', () => {
expect(q.get('limit')).toBe('50')
expect(q.get('mode')).toBe('chat')
expect(q.get('name')).toBe('support bot')
expect(q.get('tag')).toBe('prod')
})
it('treats empty-string filters as absent (not blank query params)', async () => {
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '', tag: '' })
await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '' })
const q = queryOf(stub.captured.url)
expect(q.has('mode')).toBe(false)
expect(q.has('name')).toBe(false)
expect(q.has('tag')).toBe(false)
})
it('propagates server 403 as a classified BaseError', async () => {

View File

@ -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'
@ -9,10 +10,14 @@ export type ListQuery = {
readonly limit?: number
readonly mode?: AppMode | ''
readonly name?: string
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,9 +30,8 @@ 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,
},
})
}

View File

@ -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' } })
})
})

View File

@ -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<AppListResponse> {
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<AppDescribeResponse> {
return this.orpc.permittedExternalApps.byAppId.describe.get({
params: { app_id: appId },
query: { fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined },
})
}
}

View File

@ -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,

View File

@ -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<readonly [string, CommandConstructor]> = [
['resume app', ResumeApp],
['describe app', DescribeApp],
['get app', GetApp],
['export studio-app', ExportStudioApp],
['import studio-app', ImportStudioApp],
['auth login', Login],
]

View File

@ -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 '<none>'
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}`)

View File

@ -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')
})
})

View File

@ -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<AppDescribeOutput> {
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(

View File

@ -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
`

View File

@ -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 <app-id>',
'<%= config.bin %> export app <app-id> --output ./my-app.yaml',
'<%= config.bin %> export app <app-id> --include-secret',
'<%= config.bin %> export app <app-id> --workflow-id <workflow-id>',
'<%= config.bin %> export studio-app <app-id>',
'<%= config.bin %> export studio-app <app-id> --output ./my-app.yaml',
'<%= config.bin %> export studio-app <app-id> --include-secret',
'<%= config.bin %> export studio-app <app-id> --workflow-id <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
}
}

View File

@ -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)

View File

@ -1,4 +1,4 @@
import type { AppListResponse, AppListRow, TagItem } from '@dify/contracts/api/openapi/types.gen'
import type { AppListResponse, AppListRow } from '@dify/contracts/api/openapi/types.gen'
import type { TableCell, TableColumn } from '@/framework/output'
export const APP_MODE_KEY = 'app'
@ -7,9 +7,7 @@ export const APP_COLUMNS: readonly TableColumn[] = [
{ name: 'NAME', priority: 0 },
{ name: 'ID', priority: 0 },
{ name: 'MODE', priority: 0 },
{ name: 'TAGS', priority: 0 },
{ name: 'UPDATED', priority: 0 },
{ name: 'AUTHOR', priority: 1 },
{ name: 'WORKSPACE', priority: 1 },
]
@ -25,9 +23,7 @@ export class AppRow {
this.data.name,
this.data.id,
this.data.mode,
joinTags(this.data.tags ?? []),
this.data.updated_at ?? '',
this.data.created_by_name ?? '',
this.data.workspace_name ?? '',
]
}
@ -70,7 +66,3 @@ export class AppListOutput {
return this.envelope
}
}
function joinTags(tags: readonly TagItem[]): string {
return tags.map(t => t.name).join(',')
}

View File

@ -42,7 +42,6 @@ export default class GetApp extends DifyCommand {
'limit': Flags.string({ description: 'page size [1..200]' }),
'mode': Flags.string({ description: 'filter by app mode', options: APP_MODE_VALUES }),
'name': Flags.string({ description: 'filter by app name (server-side substring)' }),
'tag': Flags.string({ description: 'filter by tag name (server-side exact match)' }),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
}
@ -59,7 +58,6 @@ export default class GetApp extends DifyCommand {
limitRaw: flags.limit,
mode: flags.mode as AppMode | undefined,
name: flags.name,
tag: flags.tag,
format,
}, { active: ctx.active, http: ctx.http, io: ctx.io })
return table({

View File

@ -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()
})
@ -40,13 +42,12 @@ describe('runGetApp', () => {
}))
}
it('list (no id, default format) renders table with NAME ID MODE TAGS UPDATED', async () => {
it('list (no id, default format) renders table with NAME ID MODE UPDATED', async () => {
const out = await render()
expect(out).toMatch(/^NAME\s+ID\s+MODE\s+TAGS\s+UPDATED/)
expect(out).toMatch(/^NAME\s+ID\s+MODE\s+UPDATED/)
expect(out).toContain('Greeter')
expect(out).toContain('app-1')
expect(out).toContain('chat')
expect(out).toContain('demo')
expect(out).toContain('Workflow')
expect(out).not.toContain('app-3')
})
@ -56,9 +57,7 @@ describe('runGetApp', () => {
'NAME',
'ID',
'MODE',
'TAGS',
'UPDATED',
'AUTHOR',
'WORKSPACE',
])
})
@ -76,12 +75,6 @@ describe('runGetApp', () => {
expect(out).not.toContain('Greeter')
})
it('--tag filters server-side', async () => {
const out = await render({ tag: 'demo' })
expect(out).toContain('Greeter')
expect(out).not.toContain('Workflow')
})
it('-A all-workspaces aggregates across workspaces sorted by id', async () => {
const out = await render({ allWorkspaces: true })
expect(out).toContain('app-1')
@ -110,10 +103,9 @@ describe('runGetApp', () => {
expect(out.trim().split('\n').sort()).toEqual(['app-1', 'app-2'])
})
it('-o wide includes AUTHOR and WORKSPACE columns', async () => {
it('-o wide includes the WORKSPACE column', async () => {
const out = await render({ format: 'wide' })
expect(out).toMatch(/^NAME\s+ID\s+MODE\s+TAGS\s+UPDATED\s+AUTHOR\s+WORKSPACE/)
expect(out).toContain('tester')
expect(out).toMatch(/^NAME\s+ID\s+MODE\s+UPDATED\s+WORKSPACE/)
expect(out).toContain('Default')
})
@ -138,4 +130,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/)
})
})

View File

@ -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'
@ -19,7 +22,6 @@ export type GetAppOptions = {
readonly limitRaw?: string
readonly mode?: AppMode
readonly name?: string
readonly tag?: string
readonly format?: string
}
@ -28,7 +30,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 +41,10 @@ export type GetAppResult = {
export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<GetAppResult> {
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 +54,20 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<
{ io, label },
async (): Promise<AppListResponse> => {
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,
@ -69,7 +75,6 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<
limit: pageSize,
mode: opts.mode,
name: opts.name,
tag: opts.tag,
})
},
)
@ -102,9 +107,7 @@ 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,
updated_at: desc.info.updated_at,
created_by_name: desc.info.author === '' ? undefined : desc.info.author,
workspace_id: wsId,
workspace_name: wsName === '' ? undefined : wsName,
}],
@ -118,7 +121,7 @@ function workspaceNameForId(active: ActiveContext, id: string): string {
}
async function runAllWorkspaces(
apps: AppsClient,
apps: AppReader,
ws: WorkspacesClient,
opts: GetAppOptions,
page: number,
@ -139,7 +142,6 @@ async function runAllWorkspaces(
limit,
mode: opts.mode,
name: opts.name,
tag: opts.tag,
})
merged.total += env.total
merged.data = [...merged.data, ...env.data]

View File

@ -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
`

View File

@ -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 <existing-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 <existing-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
}
}

View File

@ -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<never> = { [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()
})
})

View File

@ -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<Record<string, unknown>> | undefined,
): Promise<Record<string, unknown>> {
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<string, unknown>
}
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<string, unknown>
}
return { ...(directInputs ?? {}) }
}
export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise<void> {
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

View File

@ -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<Record<string, unknown>> | undefined,
): Promise<Record<string, unknown>> {
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<string, unknown>
}
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<string, unknown>
}
return { ...(directInputs ?? {}) }
}

View File

@ -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()
})
})

View File

@ -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<Record<string, unknown>> | undefined,
): Promise<Record<string, unknown>> {
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<string, unknown>
}
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<string, unknown>
}
return { ...(directInputs ?? {}) }
}
export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<void> {
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 {

View File

@ -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: {

View File

@ -22,6 +22,9 @@ const ACCOUNT_HELP_TEXT = `difyctl: account-bearer onboarding
difyctl run app <id> "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 <id> 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)

View File

@ -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, '<html>bad gateway</html>')

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -519,14 +519,14 @@ async function provisionApps(
async function importAppCli(filePath: string, wsId: string): Promise<string> {
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]
}

View File

@ -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() }
})

View File

@ -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,

View File

@ -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.

View File

@ -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()

View File

@ -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()

View File

@ -68,8 +68,9 @@ describe('E2E / difyctl get app <id> (single)', () => {
// ── External SSO ──────────────────────────────────────────────────────────
itWithSso('[P0] external SSO user get app <id> returns insufficient_scope error (3.55)', async () => {
// Spec 3.55: dfoe_ token on get app <id> → insufficient_scope, exit 1.
itWithSso('[P0] external SSO user can get a permitted app by id', async () => {
// A dfoe_ token resolves get app <id> 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 <id> (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 <id> 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()

View File

@ -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)

Some files were not shown because too many files have changed in this diff Show More